diff --git a/CHANGELOG.md b/CHANGELOG.md index 566bb525c..8c4e5230e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [1.91.270.0] - 2026-10-27 + +### Added +- **Repositories without a version source** (`ship/SKILL.md` Step 12, `ship/sections/changelog.md`, `gstack-ship-receipt write`): a repo with no `.gstack/version-path` pin and no `VERSION` ships without a version change: `Version bump: skipped (no version source: )`, no `VERSION` is ever created, the PR title is unprefixed and the run log records `"version":null`; the receipt records `"version": "none"` when the repo also has no `package.json#version`, no policy stamp file and no CHANGELOG. A repo with no CHANGELOG (the policy's `changelog` is null or the file is absent) 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 [--base ] [--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 caps come from `origin/`'s policy, never the branch's copy, so a PR cannot raise its own cap, and an unreadable base policy refuses with `POLICY_SOURCE_UNAVAILABLE` (`--policy ` is for a repository bootstrapping its policy). The `docs` key needs `min_gstack_version` (`DOCS_CAPS_KEY_VERSION`, the release that stamps this entry). +- **`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 ` adds the missing ones to the file's trailer block through `git interpret-trailers` with pinned placement, idempotently, so an existing co-author keeps attribution; a `--base` or range starting with `-` is a usage refusal and a slash-named default branch resolves through `origin/`), 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 product patch-id git cannot compute at either head refuses with git's own first stderr line instead of comparing an empty id; the per-path walk covers only the paths the branch itself touched and names at most `CARRY_NAMED_PATHS` (20). +- **`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_SKILL`, `ship/sections/review-army.md` item 3.5): a queued adversarial finding scoped `defer` after the last Step 9 pass is logged under its own skill, `review-late-defer`; the receipt's summary counts its deferred findings, never counts it as a pass, and the dashboards that pick the newest `review` row never see it (a tokenless `review` row would read UNVERIFIED). `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 every specialist and Red Team prompt `scripts/resolvers/review-army.ts` renders for /ship carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), because a reviewer is its own process and a shell variable left in the prompt would silently re-read the whole branch. +- **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 the shared `.gstack/tmp` exclude line and clears `.gstack/tmp/docs-audit.json` on first entry (the record is never an untracked file, whichever step reaches `.gstack/tmp` first), runs `gstack-ship-receipt docs-caps --base ` 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. + +- **Policy key versions** (`lib/ship-policy.ts`): `REVIEW_SCOPE_KEY_VERSION` is 1.91.256.0, the release that first read `review_scope` (its earlier value 1.91.249.0 admitted a gstack that fails on the unknown key), so a policy that set `review_scope` with `min_gstack_version` between 1.91.249.0 and 1.91.255.0 must raise it to 1.91.256.0; `DOCS_CAPS_KEY_VERSION` names the release that stamps this entry, and `test/gstack-ship-policy.test.ts` holds every unreleased key newer than the CHANGELOG headings the checkout can see. +- **`gstack-ship-receipt write`** records `version: "none"` only when the repository has no version source at all; a `VERSION` that exists but holds no parsable version, a pin that escapes the repository or an unreadable base policy keeps the no-version refusal. `checkReceiptProse` matches a shared leaf key (`pass` under every gate lane) against any of its counters and never inside a longer word (`3 pass-through`). + +### 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.269.0] - 2026-10-27 **An autoplan gate now arrives with its price, and the approvals record lands where the whole crew can read it.** Captains reported the same two frictions: review rounds and reviewer subagents had no credit figure at the gate, and the approvals mirror sat in a private project-user folder no sibling thread could read. The Eng phase also gains the performance-budget question, after a correctness pass doubled a consumer's p95 with no bench step to catch it. (The standing "take the recommendations" answer planned beside these, `answer --owner-standing rec`, was cut from this release after review: a keyword grammar over the owner's words could still read a refusal as a grant, so it returns with a structured grant in its own change.) diff --git a/VERSION b/VERSION index 6437fa3e4..8ed250420 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.269.0 +1.91.270.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4bdc2879f..c6fe441c9 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.269.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.270.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-coauthor-trailers b/bin/gstack-coauthor-trailers new file mode 100755 index 000000000..6250b78ed --- /dev/null +++ b/bin/gstack-coauthor-trailers @@ -0,0 +1,96 @@ +#!/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 join its trailer block + * (after a blank line when it has none; 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; + } + if ((range ?? base ?? '').startsWith('-')) { process.stderr.write(`gstack-coauthor-trailers: a range or base cannot start with -\n${HELP}\n`); return EXIT.usage; } + 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) { + // The base branch name may itself hold a slash (release/1.2), so "has a slash" is not "already qualified": + // origin/ wins when it resolves; a sha or a ref that only resolves as given is used as given. + const ref = base ?? resolveBaseBranch(root.out); + const qualified = /^[0-9a-f]{7,64}$/i.test(ref) || ref.startsWith('origin/') ? ref + : git(['rev-parse', '--verify', '--quiet', `origin/${ref}^{commit}`]).ok ? `origin/${ref}` : ref; + range = `${qualified}..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', '--end-of-options', 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) { + // git places the lines in the message's trailer block (joining an existing block, above comment + // lines, CRLF kept), so an existing co-author keeps GitHub's attribution; a separate paragraph would not. + // Placement is pinned on the command line: a `---` body line is not a divider, and the user's trailer.* + // config cannot replace or drop the existing lines. + if (!fs.existsSync(message)) fs.writeFileSync(message, ''); + const placed = git(['-c', 'trailer.separators=:', 'interpret-trailers', '--in-place', '--no-divider', '--where', 'end', '--if-exists', 'addIfDifferentNeighbor', '--if-missing', 'add', ...appended.flatMap(t => ['--trailer', t]), '--', message]); + if (!placed.ok) { process.stderr.write(`gstack-coauthor-trailers: cannot append to ${message}: ${placed.err.split('\n')[0] ?? ''}\n`); return EXIT.fail; } + } + } + 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 ed16ab94f..aba0977cd 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -305,6 +305,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' ;; PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;; PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;; APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope index 4b233dec4..569aba495 100755 --- a/bin/gstack-review-scope +++ b/bin/gstack-review-scope @@ -27,7 +27,7 @@ import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBr import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post'; import { parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop, - DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding, + ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding, } from '../lib/review-scope'; const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#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) { @@ -192,12 +201,12 @@ function cmdDefer(rest: string[], json: boolean): number { if (dryRun) { record.reason = 'dry run'; continue; } if (!filed) { record.reason = searchError; continue; } const hit = filed.find(i => typeof i.body === 'string' && i.body.includes(marker)); - if (hit) { record.issue = hit.number; record.url = hit.url; record.reason = 'already filed'; continue; } + if (hit) { record.issue = hit.number; record.url = hit.url; record.reason = ALREADY_FILED_REASON; continue; } const created = publishIssue(repoRoot, repo, title, body); 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..533262e14 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -8,11 +8,12 @@ * [--receipt ] [--policy