From 2d73fadd56a5e83ceb574ec94e7dbb3dc29ee903 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 19:03:51 +0000 Subject: [PATCH] feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 27 ++ bin/gstack-coauthor-trailers | 85 +++++ bin/gstack-doctor-components.sh | 3 + bin/gstack-review-scope | 15 +- bin/gstack-ship-receipt | 137 ++++++-- docs/review-policy.md | 60 +++- docs/ship-policy.md | 58 +++- docs/troubleshooting.md | 42 +++ docs/unattended.md | 153 ++++++--- lib/result-codes-release.ts | 15 + lib/review-scope.ts | 14 +- lib/ship-policy.ts | 25 +- lib/ship-receipt-docs.ts | 72 +++++ lib/ship-receipt-prose.ts | 58 ++++ lib/ship-receipt.ts | 16 +- review/checklist.md | 4 + scripts/resolvers/review-army.ts | 22 +- ship/SKILL.md | 9 +- ship/SKILL.md.tmpl | 9 +- ship/sections/changelog.md | 7 + ship/sections/changelog.md.tmpl | 7 + ship/sections/documentation.md | 22 +- ship/sections/documentation.md.tmpl | 22 +- ship/sections/pr-body.md | 11 +- ship/sections/pr-body.md.tmpl | 11 +- ship/sections/review-army.md | 52 ++- ship/sections/review-army.md.tmpl | 37 ++- test/eng-finding-retry-budget.test.ts | 2 +- test/exercised-map.test.ts | 2 + test/fixtures/golden/agy-ship-SKILL.md | 9 +- test/fixtures/golden/claude-ship-SKILL.md | 9 +- test/fixtures/golden/codex-ship-SKILL.md | 9 +- test/fixtures/golden/factory-ship-SKILL.md | 9 +- test/fixtures/module-size-ratchet.json | 1 + test/gstack-coauthor-trailers.test.ts | 78 +++++ test/gstack-ship-receipt-docs.test.ts | 197 +++++++++++ test/helpers/carve-guards.ts | 2 +- test/helpers/e2e-hosts.ts | 2 +- test/helpers/ship-frozen-scope-actor.ts | 360 +++++++++++++++++++++ test/helpers/touchfiles-data.ts | 18 ++ test/paid-run-manifest.test.ts | 10 +- test/review-scope-followups.test.ts | 124 +++++++ test/ship-frozen-scope-actor.test.ts | 116 +++++++ test/ship-receipt-prose.test.ts | 64 ++++ test/ship-skip-selection.test.ts | 13 +- test/ship-workflow-clarity.test.ts | 32 ++ test/skill-e2e-ship-frozen-scope.test.ts | 14 + 47 files changed, 1909 insertions(+), 155 deletions(-) create mode 100755 bin/gstack-coauthor-trailers create mode 100644 lib/ship-receipt-docs.ts create mode 100644 lib/ship-receipt-prose.ts create mode 100644 test/gstack-coauthor-trailers.test.ts create mode 100644 test/gstack-ship-receipt-docs.test.ts create mode 100644 test/helpers/ship-frozen-scope-actor.ts create mode 100644 test/review-scope-followups.test.ts create mode 100644 test/ship-frozen-scope-actor.test.ts create mode 100644 test/ship-receipt-prose.test.ts create mode 100644 test/skill-e2e-ship-frozen-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0916118..cde5ac5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [Unreleased] + +### Added +- **Repositories without a version source** (`ship/SKILL.md` Step 12, `ship/sections/changelog.md`, `gstack-ship-receipt write`): a repo with no `.gstack/ship-policy.json` pins, `VERSION` or `package.json#version` ships without a version change: `Version bump: skipped (no version source: )`, no `VERSION` is ever created, the PR title is unprefixed, the run log records `"version":null` and the receipt records `"version": "none"`. A repo with a CHANGELOG but no CHANGELOG entry source prints `CHANGELOG: skipped (none at )` and never creates the file ([ship-policy.md#ship-receipt](docs/ship-policy.md#ship-receipt)). +- **Docs audit record and doc caps** (`lib/ship-receipt-docs.ts`, `gstack-ship-receipt write --docs `, `gstack-ship-receipt docs-caps [--policy ]`, policy key `docs.caps: [{ path, max_bytes }]`): Step 14.5's outcome (`updated` | `current` | `blocked`, with the owner's `exception: { accepted_by, date, via, reason }`) is part of the receipt; `blocked` without an exception, or a `--body` that lacks the exception line word for word, refuses with `DOCS_AUDIT_BLOCKED`; a declared doc over its byte cap (or absent) prints `DOCS_CAP: / over` and blocks before the verification gate. The `docs` key needs `min_gstack_version` (`DOCS_CAPS_KEY_VERSION`). +- **`docs/unattended.md#ship-evidence`**: what an unattended /ship leaves for a parent: the READY evidence through `gstack-ship-receipt result`, the `review_loop` counters, the cross-family Capy subagent as the Step 11 outside voice with its review-log row, and the `CARRY:` lines. +- **Paid case `ship-frozen-scope-convergence`** (behavior, gate; `test/helpers/ship-frozen-scope-actor.ts`, `test/skill-e2e-ship-frozen-scope.test.ts`): the native actor runs the generated Step 9.4 and its repeat decision under `review_scope: frozen` against a fixture with one CRITICAL in the diff and one in untouched code; the contract is the in-diff fix committed, the pre-existing file byte-identical and deferred, pass 2 on the range only, exit through decision 4 and `REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 fixed=1`. First panel 3/3 at $0.66. +- **`gstack-ship-receipt read --check-prose`** (`lib/ship-receipt-prose.ts`): every counter the PR body quotes (`passes=2`, `1 deferred`, `passed: 41`) is compared with the receipt's `review_loop`, `gate` and `ci` integers; a disagreement is `RECEIPT_PROSE_MISMATCH` (exit 1) with a `PROSE:` line naming each field and both values. +- **`review/checklist.md` rules with ids**: `prereg-with-results` (Pass 1, Gate Integrity, ASK: a preregistration and its results in one diff) and `test-keyboard-count-loop` (Pass 2: a browser test that reaches an element by a counted loop of key presses). +- **`bin/gstack-coauthor-trailers`**: the `Co-authored-by` trailers a ported range owes (distinct authors other than the checkout's committer, first-appearance order; `--message ` appends the missing ones idempotently), for porting a contributor's PR with their authorship kept ([review-policy.md#porting-a-contributors-work](docs/review-policy.md#porting-a-contributors-work)). +- **Result codes** `DOCS_AUDIT_BLOCKED`, `RECEIPT_GIT_TOO_OLD`, `RECEIPT_PROSE_MISMATCH` (`lib/result-codes-release.ts`, `docs/troubleshooting.md`). + +### Changed +- **`gstack-ship-receipt carry`**: needs git 2.39 or later for `git patch-id --verbatim` and refuses older gits with `RECEIPT_GIT_TOO_OLD` naming the installed version; a named path whose patch-id git cannot compute refuses with git's own first stderr line instead of comparing an empty id; the per-path walk covers only the branch-touched paths among `CARRY_NAMED_PATHS`. +- **`gstack-review-scope defer`**: `gh repo view` receives the repository positionally (the visibility read works under `--repo`); the marker search reads one page of 200 open issues, and an exactly-full page prints `DEFERRED_SEARCH: truncated at 200 open issues` and records `filed; the marker search was truncated at 200, so an older issue may duplicate it`. +- **Step 11 late defers** (`lib/review-scope.ts` `LATE_DEFER_PHASE`, `ship/sections/review-army.md` item 3.5): a queued adversarial finding scoped `defer` after the last Step 9 pass is logged as a `review` row with `"phase":"late-defer"`; the receipt's summary counts its deferred findings and never counts it as a pass. `render` shows a filed issue's reason when it is not the plain "already filed". +- **`ship/sections/review-army.md` Step 9.4** reordered: `0. Scope the findings` → `1. Apply the cap before any edit, then classify` → `2. Auto-fix`; scratch files live under `.gstack/tmp/` (`review-findings-p$PASS.json`, `review-classified-p$PASS.json`, `review-deferred.json`). The pass-2+ range paragraph says the outside backends receive `RANGE_BASE..HEAD`, and the specialist and Red Team prompts compute `DIFF_BASE=${RANGE_BASE:-$(git merge-base origin/ HEAD)}` in /ship (`scripts/resolvers/review-army.ts`). +- **Reviewer fan-out** (`--review-fanout auto|serial|parallel`, /ship only; `docs/review-policy.md#reviewer-fan-out`): when the host reports fewer free subagent slots than selected specialists, `auto` runs them in batches of the free-slot count and prints `Fan-out: N specialists in M batches of K`; /review keeps one parallel batch because of its size pin. +- **`ship/sections/documentation.md`** runs `gstack-ship-receipt docs-caps --policy .gstack/ship-policy.json` under "Continue or recover" before the verification gate and writes `.gstack/tmp/docs-audit.json`; `ship/sections/pr-body.md` names the verbatim exception line in the Documentation section and the receipt write adds `--docs @.gstack/tmp/docs-audit.json`. An unattended run has no owner to accept a blocked audit and stops there. + +### Tests +- `test/gstack-ship-receipt-docs.test.ts` (new): the docs record, exception line and doc caps, `version: "none"` for a version-less repo, the git floor, the named patch-id refusal and the branch-touched walk. `test/review-scope-followups.test.ts` (new): late-defer counting, the 200-issue truncation record, `gh repo view` positional, the two checklist rule ids. `test/ship-workflow-clarity.test.ts`: Step 9.4 order, scratch-file names, the late-defer row, `DIFF_BASE` with `RANGE_BASE` in /ship prompts only. `test/ship-frozen-scope-actor.test.ts` (new): a scripted actor passes the frozen-scope verdict with the real binaries, and the undeclared interactions, an unfixed diff defect and a one-pass exit are named failures. `test/ship-receipt-prose.test.ts` and `test/gstack-coauthor-trailers.test.ts` (new). +- Registries: `ship-frozen-scope-convergence` in `E2E_TOUCHFILES`, `E2E_TIERS` (gate), `E2E_KINDS` (behavior) with its `BEHAVIOR_WHY`, the SDK host list, and `lib/ship-receipt-docs.ts` in the exercised-map reviewed superset and the module-size ratchet; `test/ship-skip-selection.test.ts` now expects both native ship fixtures from `test/helpers/scratch-repo.ts`. + +### Gate integrity +- PR 2: the ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.7150 → 1.7400 (measured 326,437 / 187,706 = 1.7391) for the Step 9.4 scope and late-defer prose, the docs-caps step and the version-less branch; the skeleton cap is unchanged. New case kind: `ship-frozen-scope-convergence` is `behavior` (3 trials, pass at 2) because the live model's wording of the fix and the review rows varies while the scope contract is asserted; no existing threshold, timeout, trial count or kind changed. + ## [1.91.256.0] - 2026-10-26 **The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes= blocking_found= deferred=` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it. diff --git a/bin/gstack-coauthor-trailers b/bin/gstack-coauthor-trailers new file mode 100755 index 000000000..67cd78852 --- /dev/null +++ b/bin/gstack-coauthor-trailers @@ -0,0 +1,85 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-coauthor-trailers — the contributor-credit trailers a ported branch owes + * (docs/review-policy.md#porting-a-contributors-work). + * + * gstack-coauthor-trailers [ | --base ] [--message ] [--exclude ]... [--json] + * + * Lists every distinct author of the commits in (default + * `..HEAD`, base `origin/HEAD`'s branch) other than this checkout's own + * committer identity and any --exclude, and prints one + * `Co-authored-by: Name ` line per author in first-appearance order. + * With --message , the trailers the file lacks are appended after a + * blank line (idempotent; existing trailers are matched by email, case + * folded), so a port's own fix commits keep the original author's credit. + * Exit: 0 printed · 1 the range does not resolve · 2 usage. + */ +import * as fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { EXIT } from '../lib/headless-artifacts'; +import { resolveBaseBranch } from '../lib/ship-policy'; + +const HELP = `gstack-coauthor-trailers — Co-authored-by trailers for the authors of a ported range + + gstack-coauthor-trailers [ | --base ] [--message ] [--exclude ]... [--json] + +Defaults: range ..HEAD with base origin/HEAD's branch; this checkout's committer identity is never a co-author. +--message appends the missing trailers to the commit message file (idempotent). Exit 0 · 1 bad range · 2 usage.`; + +function git(args: string[]): { ok: boolean; out: string; err: string } { + const r = spawnSync('git', args, { encoding: 'utf8', timeout: 60_000 }); + return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }; +} + +function main(argv: string[]): number { + if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } + const excludes = new Set(); + let base: string | undefined; + let message: string | undefined; + let range: string | undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--base' || arg === '--message' || arg === '--exclude') { + const value = argv[++i]; + if (!value) { process.stderr.write(`gstack-coauthor-trailers: ${arg} needs a value\n${HELP}\n`); return EXIT.usage; } + if (arg === '--base') base = value; else if (arg === '--message') message = value; else excludes.add(value.toLowerCase()); + } else if (arg === '--json') continue; + else if (arg.startsWith('-')) { process.stderr.write(`gstack-coauthor-trailers: unknown flag ${arg}\n${HELP}\n`); return EXIT.usage; } + else if (range) { process.stderr.write(`gstack-coauthor-trailers: one range only\n${HELP}\n`); return EXIT.usage; } + else range = arg; + } + const root = git(['rev-parse', '--show-toplevel']); + if (!root.ok) { process.stderr.write('gstack-coauthor-trailers: not inside a git repository\n'); return EXIT.fail; } + if (!range) { + const ref = base ?? resolveBaseBranch(root.out); + range = `${/^[0-9a-f]{7,64}$/i.test(ref) || ref.includes('/') ? ref : `origin/${ref}`}..HEAD`; + } + const self = /<([^>]*)>/.exec(git(['var', 'GIT_COMMITTER_IDENT']).out)?.[1]?.toLowerCase(); + if (self) excludes.add(self); + const log = git(['log', '--no-merges', '--reverse', '--format=%an%x1f%ae', range]); + if (!log.ok) { process.stderr.write(`gstack-coauthor-trailers: cannot read ${range}: ${log.err.split('\n')[0] ?? ''}\n`); return EXIT.fail; } + const authors = new Map(); + for (const line of log.out.split('\n').filter(Boolean)) { + const [name = '', email = ''] = line.split('\x1f'); + const key = email.toLowerCase(); + if (!key || excludes.has(key) || authors.has(key)) continue; + authors.set(key, { name: name.trim(), email: email.trim() }); + } + const trailers = [...authors.values()].map(a => `Co-authored-by: ${a.name} <${a.email}>`); + let appended: string[] = []; + if (message !== undefined) { + const text = fs.existsSync(message) ? fs.readFileSync(message, 'utf8') : ''; + const present = new Set([...text.matchAll(/^co-authored-by:.*<([^>]+)>\s*$/gim)].map(m => m[1]!.toLowerCase())); + appended = [...authors.entries()].filter(([key]) => !present.has(key)).map(([, a]) => `Co-authored-by: ${a.name} <${a.email}>`); + if (appended.length) { + const body = text.replace(/\s+$/, ''); + fs.writeFileSync(message, (body ? body + '\n\n' : '') + appended.join('\n') + '\n'); + } + } + if (argv.includes('--json')) process.stdout.write(JSON.stringify({ range, authors: [...authors.values()], trailers, appended }) + '\n'); + else process.stdout.write(trailers.length ? trailers.join('\n') + '\n' : ''); + process.stderr.write(`COAUTHORS: ${authors.size} in ${range}${message !== undefined ? `, ${appended.length} appended to ${message}` : ''}\n`); + return EXIT.ok; +} + +process.exit(main(process.argv.slice(2))); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 5237f8383..a1d65ac7f 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -295,6 +295,9 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; + DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; + RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; + RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;; APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;; APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;; diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope index 4b233dec4..ee24f6506 100755 --- a/bin/gstack-review-scope +++ b/bin/gstack-review-scope @@ -83,8 +83,10 @@ function publishIssue(repoRoot: string, repo: string | undefined, title: string, const code = runPost(['issue-create', '--title-file', titleFile, '--body-file', bodyFile], { run(cmd, args, input) { // --repo names the destination: the remote is that GitHub repository and every gh call targets it. + // `gh repo view` takes the repository as its positional argument and rejects --repo, so the visibility read + // (public-strict scan or not) names it that way; every other gh command takes --repo. if (repo && cmd === 'git' && args[0] === 'remote') return { status: 0, stdout: `https://github.com/${repo}\n`, stderr: '' }; - const argv = cmd === 'gh' && repo ? [...args, '--repo', repo] : args; + const argv = cmd === 'gh' && repo ? (args[0] === 'repo' && args[1] === 'view' ? [...args.slice(0, 2), repo, ...args.slice(2)] : [...args, '--repo', repo]) : args; const r = spawnSync(cmd, argv, { cwd: repoRoot, input: input ?? '', encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); if (r.error) return { status: 1, stdout: '', stderr: (r.error as NodeJS.ErrnoException).code === 'ENOENT' ? `${cmd} is not installed` : r.error.message }; return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; @@ -104,6 +106,9 @@ function publishIssue(repoRoot: string, repo: string | undefined, title: string, } } +/** One `gh issue list` page; a page exactly this full may hide older markers, so the run says so and the records carry it. */ +const DEFERRED_SEARCH_LIMIT = 200; + function readFindings(spec: string | undefined, what: string): ScopeFinding[] { const text = spec === undefined || spec === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(spec, 'utf8'); const parsed = JSON.parse(text.trim() || '[]'); @@ -178,11 +183,15 @@ function cmdDefer(rest: string[], json: boolean): number { // One search for every marker, before any create: a failed search files nothing, so a rate-limited rerun cannot duplicate issues. let filed: Array<{ number: number; url: string; body: string }> | null = null; let searchError = ''; + let truncated = false; if (!dryRun && toDefer.length) { - const existing = gh(['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${DEFERRED_MARKER}" in:body`, '--json', 'number,url,body', '--limit', '200'], repoRoot); + const existing = gh(['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${DEFERRED_MARKER}" in:body`, '--json', 'number,url,body', '--limit', String(DEFERRED_SEARCH_LIMIT)], repoRoot); if (existing.ok) { try { filed = JSON.parse(existing.out); } catch { searchError = 'gh issue list returned no JSON'; } } else searchError = `gh issue list failed: ${(existing.err || 'unknown').split('\n')[0]!.slice(0, 160)}`; if (!Array.isArray(filed)) filed = null; + // An exactly-full page is read as truncated: an older issue with this finding's marker may sit past it, so a new issue may duplicate one. + truncated = (filed?.length ?? 0) >= DEFERRED_SEARCH_LIMIT; + if (truncated) process.stderr.write(`DEFERRED_SEARCH: truncated at ${DEFERRED_SEARCH_LIMIT} open issues; an existing issue for a finding may not have been found\n`); } const records: DeferredRecord[] = []; for (const f of toDefer) { @@ -197,7 +206,7 @@ function cmdDefer(rest: string[], json: boolean): number { if (created.ok) { const url = created.out.split('\n').map(l => l.trim()).filter(l => /\/issues\/\d+$/.test(l)).pop() ?? ''; const n = Number(/\/issues\/(\d+)$/.exec(url)?.[1]); - if (Number.isInteger(n)) { record.issue = n; record.url = url; } else record.reason = `gh returned no issue url: ${url.slice(0, 120)}`; + if (Number.isInteger(n)) { record.issue = n; record.url = url; if (truncated) record.reason = `filed; the marker search was truncated at ${DEFERRED_SEARCH_LIMIT}, so an older issue may duplicate it`; } else record.reason = `gh returned no issue url: ${url.slice(0, 120)}`; } else record.reason = created.err; } // Unfiled is the documented degraded path, not a failure: the finding stays deferred and the PR body names the reason. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index b6f4b8f9e..d0aff6a75 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -8,11 +8,12 @@ * [--receipt ] [--policy