v1.92.42.0 fix(ship): notes-only review passes bind; carry handles large diffs; scoped carry route (GST-430, GST-448, GST-442, GST-444) (#3694)

* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)

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

* ship driver: trim two phrases for headroom with #3662

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

* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere

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

* test: regenerate ship goldens

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

* fix: pre-landing review fixes

comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.

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

* fix: pass-2 review fixes

A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.

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

* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)

gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.

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

* fix: no test edit skips review; guard packet writes; exclusive attempt state

A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.

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

* fix: attempt prints gstack's install command; carry checks the product diff outside context paths

The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.

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

* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs

A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.

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

* docs(unattended): carry's scoped-review refusal and what it keeps

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

* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)

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

* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)

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

* fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430)

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

* fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448)

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

* fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442)

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

* docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444)

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

* fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals

- A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the
  blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account
  for the claimed issue and critical counts (GST-430).
- The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442).
- patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448).
- Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444).

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

* test: pin the Step 16 --docs-requested route with its --docs-result

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

* fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1)

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

* fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept

- deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot
  dispose of a gate finding the unattended logger forces open (outside-voice#1).
- reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses;
  noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL.
- patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR.

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

* fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT

- isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition.
- reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so
  interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before.

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

* fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor

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

* fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team)

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

* docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit)

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

* fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin)

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

* fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check

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

* test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055)

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

* fix: restore bin/gstack-ship-receipt after the main merge

The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.

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

* ship: trim a Step 3 sentence so the skeleton fits maxSkeletonBytes with train 77

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

* ship: keep the no-edit rule in the trimmed Step 3 sentence; regenerate the ship goldens

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 21:26:59 +00:00
1 parent 74836e41d6
commit 5225e35cc6
25 files changed
+556 -112

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [1.92.42.0] - 2026-11-01
**A /ship review pass that only left notes now binds at Step 11.5.** A pass whose findings were all fixed, deferred or noted has no open defect, but a driver could still log it as `issues_found`. Step 11.5 then refused it, and the only way out was an extra rebind pass (CAPY-2, qm#2837). When the tree changes during a pass, the refusal now names the start and end trees and the cheapest repair (GST-430).
**Receipt carry and write handle results PRs with thousands of files (GST-448).** A PR that adds about 8,300 run receipts (harness-evals#147) refused every carry with `git could not compute HEAD's product patch-id: git diff … failed`, and every base move cost it about 3 hours of /ship. The name-only listing ran past a 1 MB output buffer.
### Fixed
- **`gstack-ship-receipt carry` and `write`** read git's path listings with a 256 MB buffer instead of the 1 MB default, so a large PR's `product_patch_id` is recorded and carried. The product patch-id streams `git diff` through a file under the git directory (`.git/gstack-patch-id`) into `git patch-id --verbatim` and no longer holds the diff in memory; a directory a killed run left is removed after an hour. The per-path walk compares only touched paths that changed at either head or either base, so a 6,000-path PR refuses or carries in seconds.
- **/ship Step 1's carry check takes the scoped-review route (GST-442).** When the base edited context next to a reviewed hunk, `gstack-ship-preflight` now prints `NEXT: carry route`: fix what the base merge broke, commit and push, write the packet, one fix-check, then `carry --scoped-review`, instead of running the full loop. `carry --dry-run` writes no packet and offers this route only when none of carry's other checks would refuse.
- **Step 16 names finish's scoped-review STOP and the `--docs-requested` fix (GST-444).** Stage 2 sends a STOP that names a scoped-review packet to its fix-check and `--context-reviewed` rerun, and stage 3 offers a fix the audit's result named before a re-audit or a named risk. The RECEIPT_CARRY_PRODUCT_CHANGED fix text points to the scoped-review refusal. Trims in Steps 3, 12 and 16 keep ship/SKILL.md under its cap.
- **`gstack-tree-receipt`** fails closed (`EVIDENCE_IDENTITY_UNKNOWN`) when git cannot produce the diff between the gated and current heads, and names git's reason. Before, it read a failed diff as no changes.
- **`gstack-review-log`** records a completed `review` or `review-fix-check` row claimed as `issues_found` as `clean` with zero counts when every finding's logger-stamped action is `fixed` or `auto-fixed`, or `deferred` or `noted` on a finding marked `blocking: false` that is not CRITICAL, and the findings account for the claimed issue and critical counts. A skipped finding, a gate finding left open, a CRITICAL or blocking note, a finding with no action, or an incomplete pass keeps the claimed status.
- **Findings files** (`--findings`) count `auto-fixed` as resolved, the same as `fixed`, and `deferred` or `noted` only below the floor. Their gate rows are stamped like inline ones, so an unattended run's gate stays open whatever its status or disposition says.
- **`review_freshness`** reads a claimed clean row as UNVERIFIED when a finding is a gate left `open`, a CRITICAL or blocking deferral or note, or any other unresolved CRITICAL finding.
- **`review_freshness`** for a pass whose tree moved now reads `content changed during review: the tree moved from <start> to <end> while <skill> ran …`, followed by the repair: keep the checkout frozen until every reviewer returns, then rerun on the frozen tree (docs/troubleshooting.md#review-content-changed).
### Tests
- `test/ship-rereview-triggers.test.ts`: preflight on a founderstar-style adjacent edit prints the scoped-review `NEXT: carry route`, and its dry run writes no packet. `test/ship-control-flow.test.ts`: Step 1.4 and Step 16 stages 2 and 3 name the routes.
- `test/ship-receipt-carry-large-diff.test.ts`: a PR that adds 6,000 receipts under a long directory name writes a receipt with its product patch-id and carries across a base merge. Negative control: one rescored receipt still refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`.
- `test/review-notes-only-pass.test.ts`: a pass with three notes is recorded clean and reads CURRENT. Negative controls: a skipped finding, an open gate finding (also under a caller's resolved status), a CRITICAL or blocking note, a note with no `blocking` field, counts the findings do not cover, a finding with no action, and an incomplete pass keep `issues_found`. A claimed clean row with an open gate, a CRITICAL deferral, or a skipped or action-less CRITICAL finding is not CURRENT, while kept and restored gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT. A `--findings` gate row stays open in an unattended run. Also checks the reason a pass gets when its tree moved.
- `test/ship-receipt-carry-large-diff.test.ts` also: the patch-id file lives under the git directory (an unwritable TMPDIR still carries; an unwritable spot refuses by name), and a stale directory is swept. `test/train-gate.test.ts`: a diff git cannot produce refuses the tree receipt with git's reason. `test/ship-rereview-triggers.test.ts`: a dry run the receipt would refuse anyway names that refusal.
## [1.92.41.0] - 2026-11-01
**The Ubicloud runner works on macOS and on a project with no VMs (#3680, #3679, #3678).** On a Mac, `gstack-ubi pack` failed, so every `sync`, `run` and `verify` broke after the VM was already created, and `gstack-ubi usage` was a syntax error that also switched off the vCPU quota check. On any machine, an empty Ubicloud project made `reap` and every `up` die. All three were reported by Jared.
+1 -1
View File
@@ -1 +1 @@
1.92.41.0
1.92.42.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.41.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.92.42.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,
+50 -13
View File
@@ -32,6 +32,7 @@
*/
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 { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
@@ -119,7 +120,7 @@ function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
return parsed;
}
function git(args: string[], cwd?: string): string | undefined {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 });
return r.status === 0 ? r.stdout.trim() : undefined;
}
function gh(args: string[]): { ok: boolean; out: string; err: string } {
@@ -399,12 +400,37 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
*/
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
if (!diff.stdout.trim()) return '';
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
return id.stdout.trim().split(/\s+/)[0] ?? '';
const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id');
sweepStalePatchDirs(root);
let dir: string;
try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; }
const file = path.join(dir, 'diff.patch');
let fd: number | undefined;
try {
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 });
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
fd = fs.openSync(file, 'r');
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 });
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
return id.stdout.trim().split(/\s+/)[0] ?? '';
} finally {
if (fd !== undefined) fs.closeSync(fd);
fs.rmSync(dir, { recursive: true, force: true });
}
}
/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */
let swept = false;
function sweepStalePatchDirs(root: string): void {
if (swept) return;
swept = true;
const cutoff = Date.now() - 60 * 60 * 1000;
let names: string[] = [];
try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; }
for (const n of names) {
const p = path.join(root, n);
try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ }
}
}
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
@@ -581,6 +607,7 @@ function cmdCarry(rest: string[], json: boolean): number {
const reviewedBase = receipt.base.replace(/^origin\//, '');
const retarget = reviewedBase !== base.replace(/^origin\//, '');
let plan: ReturnType<typeof planCarry>;
let scopedDryRun: string | undefined;
if (!ancestor) {
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' };
@@ -620,10 +647,14 @@ function cmdCarry(rest: string[], json: boolean): number {
else if (productFrom !== productHead && !retarget) {
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the
// branch itself touched against the base at either head: a base merge brings in every path the base changed, none of
// which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks
// which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
for (const p of [...touched].sort()) {
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const [movedHead, movedBase] = moved as Set<string>[];
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
if (cls !== 'product') continue;
@@ -644,7 +675,10 @@ function cmdCarry(rest: string[], json: boolean): number {
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
if (packetBody && !changedProduct.length) {
const record = flag(rest, '--scoped-review');
if (!record) {
if (!record && rest.includes('--dry-run')) {
scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review <record.json>; nothing written`;
scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`;
} else if (!record) {
const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot);
const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`);
try {
@@ -655,9 +689,11 @@ function cmdCarry(rest: string[], json: boolean): number {
}
return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review <record.json> ({"head":"${head}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`);
}
const review = readScopedReview(record, { head }, contextPaths);
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
if (record) {
const review = readScopedReview(record, { head }, contextPaths);
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
}
}
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
@@ -698,6 +734,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const newBody = placeReceiptFirst(body, carried);
const line = receiptSummaryLine(carried);
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
if (rest.includes('--dry-run')) {
process.stderr.write(line + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
+20 -4
View File
@@ -1345,7 +1345,12 @@ falling back to the whitespace-stripping default, and a `git diff` or
patch-ids differ, the refusal names up to `CARRY_NAMED_PATHS` (20) product
paths whose patch against the base changed, walking only the paths the branch
itself touched at either head (a base merge brings in every path the base
changed, none of which can differ between the two base diffs). Product means every changed path that is not test code (`test/**`,
changed, none of which can differ between the two base diffs), and of those
only the paths that changed between the two heads or the two bases (a path with
the same blob on both sides has the same patch). The product diff streams
through a file under the git directory (`.git/gstack-patch-id`) into
`git patch-id --verbatim`, so a PR with thousands of paths is not held in
memory; a directory a killed run left there is removed after an hour. Product means every changed path that is not test code (`test/**`,
`spec/**`, `e2e/**`, `**/*.test.<code ext>`, `**/*.spec.<code ext>` and the
other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each
@@ -1383,7 +1388,11 @@ along the receipt's own history, `rewritten` once any hop was a rebase;
`CARRY: carried from <old> to <new> (<reason>)` and the `GSTACK_RESULT:
skill=ship status=complete` line, and its summary line ends with
`carried_from=<old12>`. `CARRY: current` means the receipt already names HEAD;
`--dry-run` prints `CARRY: would carry …` and writes nothing.
`--dry-run` prints `CARRY: would carry …` and writes nothing. When the only
difference is base context next to the branch's unchanged hunks and none of
carry's other checks would refuse, `--dry-run` refuses
`RECEIPT_CARRY_SCOPED_REVIEW` without writing the packet; carry without
`--dry-run` writes it.
A changed product path, or a product patch-id that differs, is
[`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed)
@@ -1416,7 +1425,12 @@ the carry when the branch has an open PR, in this order, and never reruns the
review loop for a carried receipt:
1. `gstack-ship-receipt carry --pr <n> --base <base> --dry-run`. `CARRY: would
carry` is eligibility only; a refusal continues the full ship.
carry` is eligibility only; a refusal continues the full ship, except
`RECEIPT_CARRY_SCOPED_REVIEW`, for which `gstack-ship-preflight` prints
`NEXT: carry route`: fix what the base merge broke, commit and push, run carry
without `--dry-run` to write the packet, have one fix-check reviewer read it,
then carry with `--scoped-review <record.json>` and `--pregate`. A blocking
finding continues the full ship.
2. At that pinned HEAD: the CHANGELOG check, the size-cap check the policy
declares, and `gstack-pregate` (the remote tests stage when
`requires_remote` asks for it). A failing check, or HEAD moving, continues
@@ -1706,7 +1720,9 @@ hand edit), and the replacement the current one, so a dependency pin
that happens to equal the old version is `changed`; `stamp_patterns`
templates count as stamp forms too). With `stamp_files` set, a file outside it
is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever
its diff. Any other byte is a change.
its diff. Any other byte is a change. A diff git cannot produce between the two
heads fails closed with `EVIDENCE_IDENTITY_UNKNOWN` and git's reason; it never
reads as no change.
Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always
`rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps
+32 -2
View File
@@ -550,6 +550,25 @@ local `origin/<base>` at `<rev>`.
**Fix.** Run `git fetch origin <base>` outside the sandbox and rerun /review if
the base has moved.
<a id="review-content-changed"></a>
### `review_freshness: STALE content changed during review: the tree moved from <start> to <end> while <skill> ran ...` / **Review records missing or mismatched**
**Meaning.** The working tree changed between the pass's `gstack-review-log --start` and
its logged row: an edit, commit or merge landed while reviewers were still reading. The
reviewers may not have seen that change, so the row's `review_binding.state` is `changed`.
Step 11.5 cannot bind it and inserts `9 → 10 → 11 → 11.5`. Logging the same results again
cannot repair it.
**Fix.** Keep the checkout frozen (no edits, commits, merges or pushes) from a pass's start
until every reviewer in it has returned and its row is logged. If the mid-pass change was
your own fix to that pass's findings, log the pass with `"converged":false` and the fix
as `"action":"fixed"`. `gstack-review-plan` then picks the repeat: one fix-check reading
the touched hunks when every fix is below the blocking floor, and 11.5 accepts that
fix-check record as Step 9's. Rerun Step 11's native pass on the same frozen tree.
**Expected result.** `gstack-review-read` shows both records at one tree with
`review_freshness` `CURRENT`, and Step 11.5 continues to Step 12.
<a id="review-fingerprint-tmpdir"></a>
### `gstack-wtree: cannot create a temp index under <dir>; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under <dir>; no working-tree fingerprint. Set TMPDIR to a writable directory.`
@@ -3140,9 +3159,14 @@ or pass `--force-gate <reason>` in `/ship` to run the full gate again.
**Meaning.** The bundle (or ledger record) lacks a committed tree id, runtime
versions, lockfile hashes or the command hash, so no reuse decision can rest on
it. Imported assertions without verification count as unknown too.
`gstack-tree-receipt` also fails with this code when git cannot produce the
diff between the gated and current heads (a missing object, a timeout): the
error names git's reason, and a failed diff is never read as no change.
**Fix.** Rebuild the bundle on the gate machine after the lanes ran on a
committed head: `gstack-evidence bundle --out <file> --label <lane>...`.
committed head: `gstack-evidence bundle --out <file> --label <lane>...`. For
the tree receipt, fix what git's reason names (fetch a missing head with
`git fetch origin <sha>`), then rerun.
<a id="evidence-bundle-invalid"></a>
### `EVIDENCE_BUNDLE_INVALID`
@@ -5530,7 +5554,9 @@ compare (a receipt written before `head_tree`, whose head is not in this
clone), the PR was retargeted to the base after its stack parent landed but
the receipt records no `product_patch_id` and `origin/<parent>` no longer
resolves, so the reviewed range cannot be recomputed, or git could not
compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records
compare the two product diffs (including when carry cannot create its temp
directory under `.git/gstack-patch-id`; the message names it) or read the
working tree's status. It also refuses when an owner exception the receipt records
no longer stands, and, for a receipt from a gstack that records review
depth, when HEAD needs a deeper review than the receipt records, `--head` is
not the checked-out HEAD, or the receipt records `gstack_version` without
@@ -5623,6 +5649,10 @@ the tree it is bound to. Finish also stops this way for test code changed since
review with no gate edit (`tests=N`): the packet holds those test hunks, and the
reviewer checks that each test still tests what it did. A packet that cannot be
written (an unwritable `.git`) is a STOP or `RECEIPT_CARRY_REFUSED` naming the path.
`carry --dry-run` reports this code without writing the packet, and only when
none of carry's other checks would refuse; carry without `--dry-run` writes it.
In /ship Step 1, `gstack-ship-preflight` prints the route as `NEXT: carry
route (Step 1.4)`.
<a id="receipt-prose-mismatch"></a>
### `RECEIPT_PROSE_MISMATCH`
+3 -2
View File
@@ -240,8 +240,9 @@ now names HEAD with a `carry` object holding the `git range-diff` evidence, and
counters are kept, because the reviewed product diff is unchanged or its only
difference passed a scoped review. `CARRY: current` means nothing moved.
`CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)` means the base edited context
next to reviewed hunks: one fix-check reviewer reads the packet the refusal
names, and a clean record reruns carry with `--scoped-review <record.json>`
next to reviewed hunks. Carry without `--dry-run` writes the packet the
refusal names (a dry run writes none), one fix-check reviewer reads it, and a
clean record reruns carry with `--scoped-review <record.json>`
([troubleshooting](troubleshooting.md#receipt-carry-scoped-review)). `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`,
`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused
result line means the product diff changed, or history was rewritten onto a
+8 -1
View File
@@ -551,9 +551,16 @@ export interface DerivedReviewStatus {
findings_total: number; findings_open: number; findings_resolved: number;
}
export function isOpenFinding(row: Record<string, unknown>): boolean {
if (row.action === 'open') return true;
if (typeof row.disposition === 'string') return !RESOLVED.includes(row.disposition);
if (typeof row.status === 'string') return !['resolved', 'fixed', 'closed'].includes(row.status);
return row.action !== 'fixed';
return isOpenByAction(row);
}
/** A finding's `action` alone: fixed and auto-fixed resolve it; deferred and noted resolve only one marked `blocking: false` that is not CRITICAL (review policy's floor). */
export function isOpenByAction(row: Record<string, unknown>): boolean {
if (row.action === 'fixed' || row.action === 'auto-fixed') return false;
if (row.action === 'deferred' || row.action === 'noted') return row.blocking !== false || isCriticalFinding(row);
return true;
}
export function isCriticalFinding(row: Record<string, unknown>): boolean {
return typeof row.severity === 'string' && row.severity.toLowerCase() === 'critical';
+1 -1
View File
@@ -383,7 +383,7 @@ export const RELEASE_RESULT_CODES = {
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, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD',
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged',
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged; base context next to unchanged reviewed hunks is RECEIPT_CARRY_SCOPED_REVIEW instead (one scoped fix-check, no /ship)',
},
RECEIPT_CARRY_SCOPED_REVIEW: {
anchor: 'receipt-carry-scoped-review',
+21 -5
View File
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { deriveReviewStatus, readJsonl } from './headless-artifacts';
import { deriveReviewStatus, isCriticalFinding, isOpenByAction, readJsonl } from './headless-artifacts';
import { resultLine, type ResultCodeName } from './result-codes';
const DIFF_REVIEWS = new Set(['review', 'review-fix-check', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
@@ -248,11 +248,12 @@ const DERIVED_KEYS = ['status', 'unresolved', 'issues_found', 'critical_gaps'] a
* severity + disposition/action). A claimed value that disagrees rejects the
* row with REVIEW_STATUS_MISMATCH; the file is the record, never the claim.
*/
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string): Record<string, any> {
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string, sessionKind?: string): Record<string, any> {
let read;
try { read = readJsonl(findingsPath); }
catch (e: any) { throw new ReviewLogError('ARTIFACT_MISSING', resultLine('review-log', 'ARTIFACT_MISSING', `${findingsPath}: ${e.message}`)); }
if (read.errors.length) throw new ReviewLogError('ARTIFACT_MALFORMED_JSONL', resultLine('review-log', 'ARTIFACT_MALFORMED_JSONL', `${read.errors[0]!.path}: ${read.errors[0]!.message}`));
stampGateFindings(read.rows, sessionKind);
const derived = deriveReviewStatus(read.rows);
const mismatches = DERIVED_KEYS.filter(key => rec[key] !== undefined && rec[key] !== derived[key])
.map(key => `claimed ${key} ${JSON.stringify(rec[key])}, findings file has ${JSON.stringify(derived[key])}`);
@@ -266,11 +267,20 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
// The findings file is validated BEFORE the start token is consumed below:
// a rejected row must leave the token for the retry.
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
rec = findingsPath ? deriveFromFindings(rec, findingsPath, env.GSTACK_STAMP_SESSION_KIND) : { ...rec, status_source: 'claimed' };
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND);
// A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army
// item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the
// logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue.
if (!findingsPath && (rec.skill === 'review' || rec.skill === 'review-fix-check') && rec.status === 'issues_found' && rec.completed === true &&
Array.isArray(rec.findings) && rec.findings.length > 0 && rec.findings.length >= Number(rec.issues_found ?? 0) &&
rec.findings.filter(f => record(f) && isCriticalFinding(f)).length >= Number(rec.critical ?? 0) &&
rec.findings.every(f => record(f) && typeof f.action === 'string' && !isOpenByAction(f))) {
Object.assign(rec, { status: 'clean', issues_found: 0, critical: 0, informational: 0 });
}
if (!DIFF_REVIEWS.has(rec.skill)) {
if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE;
return rec;
@@ -326,7 +336,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
if (!DIFF_REVIEWS.has(rec.skill)) return;
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
if (binding?.state === 'changed') {
const short = (w: unknown) => typeof w === 'string' ? w.slice(0, 12) : 'unknown';
return { status: 'STALE', reason: `content changed during review: the tree moved from ${short(binding.start_wtree)} to ${short(binding.end_wtree)} while ${rec.skill} ran (an edit, commit or merge landed before its row was logged), so the record cannot bind. Repair: freeze the checkout until every reviewer returns, then rerun the steps 11.5 names on the frozen tree; when the edit was your own fix, log it as a fix so gstack-review-plan picks the repeat depth (docs/troubleshooting.md#review-content-changed)` };
}
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
@@ -340,7 +353,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
// issues_found, and the legacy `critical` and Codex counters stay as they were.
const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found;
if (rec.status !== 'clean' || open > 0 || rec.critical > 0 ||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0))) {
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0)) ||
(Array.isArray(rec.findings) && rec.findings.some(f => record(f) && (f.action === 'open' ||
((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) ||
(!f.gate && isCriticalFinding(f) && isOpenByAction(f)))))) {
return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' };
}
return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' };
+4
View File
@@ -53,6 +53,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`);
return false;
}
if (line === 'CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)') {
r.next(`carry route (Step 1.4): the base edited context next to reviewed hunks: fix what the base merge broke (CHANGELOG, size caps), commit and push; then gstack-ship-receipt carry --pr ${n} --base ${base} writes the packet for that head; one fix-check reviewer reads it; gstack-pregate --allow-repo-commands, then carry again with --scoped-review <record.json> --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`);
return false;
}
} else r.add('PR: none open');
const apple = git(cwd, ['ls-files', '--', '*.xcodeproj/*', '*.xcworkspace/*', 'Package.swift']).stdout.split('\n').filter(Boolean).map(p => p.replace(/(\.xcodeproj|\.xcworkspace)\/.*$/, '$1'));
if (apple.length) r.add(`APPLE: ${short([...new Set(apple)], 3)}`).next('Step 0.9: for an App Store/TestFlight ask, confirm the app target and follow apple-release.md; otherwise ignore');
+6 -1
View File
@@ -170,7 +170,12 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
const versionRel = o.versionRel ?? ('rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION');
const oldV = 'version' in gv ? gv.version : null;
const newV = 'version' in cv ? cv.version : null;
const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? '';
const d = spawnSync('git', ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
if (d.status !== 0) {
const why = (d.error as NodeJS.ErrnoException | undefined)?.code ?? (d.signal ? `killed by ${d.signal}` : (d.stderr ?? '').trim().split('\n')[0]?.slice(0, 200) || `exit ${d.status}`);
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
}
const diffText = d.stdout.replace(/\n$/, '');
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.92.41",
"version": "1.92.42",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+13 -13
View File
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -457,7 +458,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -696,7 +695,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -777,8 +778,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+13 -13
View File
@@ -142,7 +142,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -187,7 +188,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -252,8 +253,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -272,8 +272,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -417,7 +416,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -440,6 +440,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -477,9 +478,9 @@ every allowed edit is release metadata:
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -487,8 +488,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -498,8 +499,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+13 -13
View File
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -457,7 +458,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -696,7 +695,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -777,8 +778,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+13 -13
View File
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -457,7 +458,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -696,7 +695,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -777,8 +778,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+13 -13
View File
@@ -334,7 +334,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -456,7 +457,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -698,7 +697,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -721,6 +721,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -759,9 +760,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --la
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -769,8 +770,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -780,8 +781,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+13 -13
View File
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
`NEXT: carry route` re-runs Steps 2–16.
5. Display historical readiness using the dashboard below, then finish Step 1.
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
@@ -458,7 +459,7 @@ not web services already deployed.
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
@@ -531,8 +532,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
use the first changed component from `baseVersion` to `currentVersion`
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
not another automatic bump.
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
Success follows ALREADY_BUMPED, including its queue check; failure stops.
Repair alone never re-bumps.
@@ -551,8 +551,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
3. **Queue-aware pick:**
```bash
@@ -700,7 +699,8 @@ Classify the comparison in this order:
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
This repair excludes Step 14.5 because the rebuild can change generated docs.
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
documentation freshness. Further repairs use the same work list.
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
report and counts. Recheck affected plan items using their recorded verification
and append current evidence to the invocation record. If a classification is no
@@ -723,6 +723,7 @@ audit or risk decision never qualifies.
Report changed inputs, blockers and attempts used:
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15, then restart Step 16 stage 1.
@@ -761,9 +762,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --la
|---|---|
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
| STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
No test lanes: require Step 5's explicit untested-scope approval for final content,
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
@@ -771,8 +772,8 @@ Report the gap, never FRESH; builds must pass.
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
keeps its existing audit count; it does not authorize a third attempt.
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
does not authorize a third attempt.
### 5. Report, then push
@@ -782,8 +783,7 @@ Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
pre-existing failures and approved scope; cite the actual approval and failing
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
stage 4's recovery before publication.
counts, never FRESH or all-green.
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
its records:
+144
View File
@@ -0,0 +1,144 @@
/**
* GST-430 (CAPY-2, qm#2837): a /ship review pass whose only findings were notes or deferrals has no
* open defect, so the logger records it clean with issues_found 0 and Step 11.5 can bind it. A finding
* still open (skipped, gate open, no action) keeps the claimed status. A row whose tree moved during
* the pass names that cause and the cheapest repair.
*/
import { describe, test, expect, beforeEach, afterEach } 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 { isOpenFinding } from '../lib/headless-artifacts';
import { reviewFreshness } from '../lib/review-evidence';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-review-log');
let home: string;
beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-notes-only-')); });
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });
function run(args: string[]) {
const r = spawnSync(BIN, args, { cwd: ROOT, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_HOME: home, GSTACK_SESSION_KIND: 'unattended' } });
return { code: r.status, err: r.stderr, out: r.stdout };
}
function lastRow(): any {
const projects = path.join(home, 'projects');
const rows = fs.readdirSync(projects).flatMap(slug => fs.readdirSync(path.join(projects, slug)).filter(f => f.endsWith('-reviews.jsonl'))
.flatMap(f => fs.readFileSync(path.join(projects, slug, f), 'utf8').trim().split('\n').map(l => JSON.parse(l))));
return rows.at(-1);
}
const note = (i: number, action: string, extra: Record<string, unknown> = {}) =>
({ fingerprint: `lib/a.ts:${i}:testing`, severity: 'INFORMATIONAL', action, locus: 'diff', kind: 'test-gap', blocking: false, raised_by: 'specialist-batched', ...extra });
const pass = (findings: unknown[], extra: Record<string, unknown> = {}) => JSON.stringify({
skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', status: 'issues_found', issues_found: findings.length, critical: 0,
informational: findings.length, quality_score: 9, specialists: {}, findings, completed: true, converged: true, cycles: 0, pass: 2,
review_scope: 'frozen', range_base: null, ...extra,
});
describe('a notes-only /ship review pass (GST-430)', () => {
// Value: protects=Step 11.5 binding a pass whose 3 notes are not defects; catches=a notes-only pass logged as
// issues_found and left UNVERIFIED, which cost CAPY-2 an extra rebind pass.
test('three noted findings are recorded clean with 0 issues and read CURRENT', () => {
const token = run(['--start', 'review']).out.trim();
const r = run([pass([note(1, 'noted'), note(2, 'noted'), note(3, 'deferred', { issue: null })]), '--finish', token]);
expect(r.code).toBe(0);
const row = lastRow();
expect([row.status, row.issues_found, row.critical, row.informational, row.review_binding.state]).toEqual(['clean', 0, 0, 0, 'verified']);
expect(reviewFreshness(row, row.wtree)).toEqual({ status: 'CURRENT', reason: 'completed clean pass on unchanged content' });
});
// Value: protects=open defects still blocking 11.5; catches=a normalization that clears a skipped finding, a
// gate finding the unattended logger forced open, a finding with no action, or an incomplete pass.
test('negative controls keep issues_found', () => {
const cases: Array<[string, string]> = [
['skipped', pass([note(1, 'noted'), note(2, 'skipped')])],
['gate open', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r' })])],
['no action', pass([note(1, 'noted'), { fingerprint: 'lib/a.ts:9:testing', severity: 'CRITICAL' }])],
['incomplete', pass([note(1, 'noted')], { completed: false })],
['caller status over a stamped-open gate', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL', status: 'resolved' })])],
['critical noted', pass([note(1, 'noted'), note(2, 'noted', { severity: 'CRITICAL' })])],
['blocking deferred', pass([note(1, 'noted'), note(2, 'deferred', { blocking: true, issue: null })])],
['counts beyond the findings', pass([note(1, 'noted')], { issues_found: 2, critical: 1 })],
['noted with no blocking field', pass([note(1, 'noted'), note(2, 'noted', { blocking: undefined })])],
];
for (const [name, row] of cases) {
expect(run([row]).code).toBe(0);
expect([name, lastRow().status]).toEqual([name, 'issues_found']);
}
});
// Value: protects=a claimed clean row cannot carry a gate finding the unattended logger stamped open past Step 11.5;
// catches=a reader that trusts the claimed status and counts over the stamped finding (red-team#2, pass 1).
test('a claimed clean row with a stamped-open gate finding is not CURRENT', () => {
const token = run(['--start', 'review']).out.trim();
const gate = note(1, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL' });
expect(run([pass([gate], { status: 'clean', issues_found: 0, critical: 0, informational: 0 }), '--finish', token]).code).toBe(0);
const row = lastRow();
expect([row.status, row.findings[0].action, row.review_binding.state]).toEqual(['clean', 'open', 'verified']);
expect(reviewFreshness(row, row.wtree)?.status).toBe('UNVERIFIED');
});
// Value: protects=a claimed clean row reads open findings by the same rule the rewrite uses; catches=a CRITICAL
// deferral or a skipped finding under a claimed clean status reading CURRENT (adversarial-native#3, Step 11).
// Kept and restored interactive gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT, as before this PR
// (fix-check and adversarial-native, Step 11 round 2).
test('a claimed clean row with a CRITICAL deferral is not CURRENT; resolved gate, skipped and below-floor actions are', () => {
const w = 'c'.repeat(40);
const row = (findings: unknown[]) => ({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: w,
review_binding: { state: 'verified', start_wtree: w, end_wtree: w }, findings, status_source: 'claimed' });
expect(reviewFreshness(row([note(1, 'deferred', { severity: 'CRITICAL', issue: null })]), w)?.status).toBe('UNVERIFIED');
expect(reviewFreshness(row([note(1, 'deferred', { blocking: true })]), w)?.status).toBe('UNVERIFIED');
expect(reviewFreshness(row([note(1, 'open', { gate: 'RH1' })]), w)?.status).toBe('UNVERIFIED');
expect([note(1, 'skipped', { severity: 'CRITICAL' }), { fingerprint: 'lib/a.ts:2:x', severity: 'CRITICAL' }, note(1, 'mystery', { severity: 'CRITICAL' })]
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['UNVERIFIED', 'UNVERIFIED', 'UNVERIFIED']);
expect(reviewFreshness(row([note(1, 'kept', { gate: 'RH1', reason: 'r', actor: 'interactive', severity: 'CRITICAL' })]), w)?.status).toBe('CURRENT');
const gate = (action: string) => note(1, action, { gate: 'RH1', reason: 'r', actor: 'interactive' });
expect([gate('kept'), gate('restored'), note(1, 'skipped'), note(1, 'deferred', { blocking: undefined }), note(1, 'noted')]
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['CURRENT', 'CURRENT', 'CURRENT', 'CURRENT', 'CURRENT']);
});
// Value: protects=a findings file cannot dispose of a gate finding the unattended logger must force open; catches=gate rows
// from --findings derived as resolved without stamping (outside-voice#1, Step 11).
test('a --findings file gate row is stamped open before its status is derived', () => {
const file = path.join(home, 'findings.jsonl');
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g' }) + '\n');
const token = run(['--start', 'review']).out.trim();
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
const row = lastRow();
expect([row.findings_open, row.status === 'clean']).toEqual([1, false]);
expect(reviewFreshness(row, row.wtree)?.status).not.toBe('CURRENT');
});
// Value: protects=the forced-open stamp wins over a findings file's own status or disposition; catches=a gate row marked
// status resolved deriving clean after it was stamped open (outside-voice#1, Step 11 round 2).
test('a --findings gate row stamped open stays open whatever its status or disposition says', () => {
for (const extra of [{ status: 'resolved' }, { disposition: 'fixed' }]) {
const file = path.join(home, 'findings.jsonl');
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g', ...extra }) + '\n');
const token = run(['--start', 'review']).out.trim();
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
expect([JSON.stringify(extra), lastRow().findings_open]).toEqual([JSON.stringify(extra), 1]);
}
});
test('isOpenFinding: fixed and auto-fixed are not open; noted and deferred only below the floor; skipped and missing actions are', () => {
expect(['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', undefined].map(action => isOpenFinding({ action, blocking: false })))
.toEqual([false, false, false, false, true, true]);
expect(['deferred', 'noted'].map(action => isOpenFinding({ action }))).toEqual([true, true]);
});
});
describe('a tree that changed during the pass (GST-430)', () => {
// Value: protects=a refusal that says why the record cannot bind and what the cheapest repair is; catches=the
// bare 'content changed during review' that sent CAPY-2 through a ~$20 full pass without saying why.
test('the STALE reason names both trees, the cause and the repair', () => {
const s = 'a'.repeat(64), e = 'b'.repeat(64);
const fresh = reviewFreshness({ skill: 'adversarial-review', review_binding: { state: 'changed', start_wtree: s, end_wtree: e } }, e);
expect(fresh?.status).toBe('STALE');
expect(fresh?.reason).toContain(`content changed during review: the tree moved from ${s.slice(0, 12)} to ${e.slice(0, 12)} while adversarial-review ran`);
expect(fresh?.reason).toContain('freeze the checkout until every reviewer returns');
expect(fresh?.reason).toContain('docs/troubleshooting.md#review-content-changed');
});
});
+11 -1
View File
@@ -55,7 +55,7 @@ describe('ship source controller', () => {
test('Step 1 carries a receipt only after a dry run and a pregate at HEAD', () => {
const carry = compact(entry.slice(entry.indexOf('4. **Carry check.**'), entry.indexOf('5. Display historical readiness')));
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.'];
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `current`, or `refused` with no `NEXT: carry route` re-runs Steps 2–16.'];
const at = order.map(t => carry.indexOf(t));
expect(at.every(i => i >= 0)).toBe(true);
expect(at).toEqual([...at].sort((a, b) => a - b));
@@ -338,6 +338,16 @@ describe('ship source controller', () => {
expect(docs).toMatch(/never silently refresh hashes/i);
});
// Value: protects=finish's scoped-review STOP and --docs-requested carry are routes Step 16 names, so a driver that reads
// the template instead of the STOP text keeps the GST-384/GST-391 saving; fails_when=stage 2 sends every tests=/context=
// STOP to the full loop or stage 3 offers only a re-audit or a named risk (GST-444); seam=none; no_claim=the bins' own STOP text
test('Step 16 routes the scoped-review STOP and the --docs-requested fix (GST-444)', () => {
const behavior = gate.slice(gate.indexOf('1. **Behavior'), gate.indexOf('2. **Only authored'));
expect(behavior).toContain('A STOP naming a scoped-review packet instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.');
const docs = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
expect(docs).toContain('**A fix the audit\'s result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.');
});
test('tests, absent test approval and new writes reopen final verification', () => {
const tests = gate.slice(gate.indexOf('### 4.'), gate.indexOf('### 5.'));
expect(tests).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i);
@@ -0,0 +1,90 @@
/**
* gstack-ship-receipt carry on a PR whose own diff is large (GST-448, harness-evals#147): a results PR that adds thousands
* of run-receipt files has a name-only listing and a product diff over the child-process output buffer, so carry refused
* with "git could not compute HEAD's product patch-id: git diff … failed" after every base move.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt } from '../lib/ship-receipt';
import { cleanup, commit, makeFixtureRepo as makeRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const makeFixtureRepo = (): FixtureRepo => { const r = makeRepo(); r.git('config', 'core.longpaths', 'true'); return r; };
setDefaultTimeout(120_000);
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const DIR = `results/${'harness-eval-run-receipts-'.repeat(8)}`;
/** `count` run receipts under a long directory name: about 1.4 MB of paths and 2 MB of diff, past the 1 MB default buffer. */
const receipts = (count: number, tag: string) => Object.fromEntries(Array.from({ length: count }, (_, i) => [`${DIR}/${tag}-run-${String(i).padStart(5, '0')}.json`, `{"run":${i},"tag":"${tag}","score":0.${i % 10}}\n`]));
describe('carry with a large product diff (GST-448)', () => {
// Value: protects=a results PR keeps its receipt across a base move when its own diff is megabytes of added files
// (harness-evals#147 paid ~3h of /ship per base move); fails_when=carry buffers git's name-only listing or the diff in
// a bounded child-process buffer and refuses; seam=none; no_claim=gh pr edit (--body stands in)
test('a base merge carries the receipt of a PR that adds 6000 files', () => {
const repo = makeFixtureRepo(); repos.push(repo);
commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts');
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
const reviewed = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id;
expect(reviewed).toMatch(/^[0-9a-f]{40}$/);
repo.git('checkout', '-q', 'main');
write(repo.work, 'docs/README.md', '# docs moved on main\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:')), out.err.split('\n').find(l => l.includes('RECEIPT_CARRY'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: carried/), undefined]);
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id).toBe(reviewed);
});
// Value: protects=negative control: the large-diff path still refuses when the PR's own receipts change after review;
// fails_when=the streamed patch-id reads as unchanged for a changed diff; seam=none; no_claim=none
test('one changed receipt in the large diff still refuses the carry', () => {
const repo = makeFixtureRepo(); repos.push(repo);
commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts');
const body = path.join(repo.root, 'body.md');
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
commit(repo, { [`${DIR}/base-run-00042.json`]: '{"run":42,"tag":"base","score":0.9}\n' }, 'results: rescore run 42');
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)']);
});
// Value: protects=the patch-id diff lives under the git dir (on disk, not a tmpfs TMPDIR), and a spot it cannot write is a
// named refusal, never a stack trace preflight reads as 'CARRY: check failed'; fails_when=patchId writes to TMPDIR or
// creates its directory outside its error handling; seam=TMPDIR, a file where the directory goes; no_claim=none
test('patch-id diffs go under the git dir: an unwritable TMPDIR still carries, an unwritable git-dir spot refuses by name', () => {
const repo = makeFixtureRepo(); repos.push(repo);
commit(repo, receipts(3, 'base'), 'results: 3 run receipts');
const body = path.join(repo.root, 'body.md');
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
repo.git('checkout', '-q', 'main');
write(repo.work, 'docs/README.md', '# docs moved on main\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--no-edit', 'origin/main');
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run'], { TMPDIR: path.join(repo.root, 'no-such-dir') });
expect([dry.status, dry.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: would carry/)]);
fs.rmSync(path.join(repo.work, '.git', 'gstack-patch-id'), { recursive: true, force: true });
fs.writeFileSync(path.join(repo.work, '.git', 'gstack-patch-id'), 'not a directory\n');
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run']);
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']);
expect(out.err).toContain('cannot create a temp file under');
});
// Value: protects=a carry killed mid patch-id (preflight's 120 s timeout) does not leave product diffs in TMPDIR for good;
// fails_when=nothing removes a gstack-patch-id-* directory a killed run left; seam=TMPDIR, an aged directory; no_claim=none
test('a stale patch-id directory a killed run left is swept; a fresh one is kept', () => {
const repo = makeFixtureRepo(); repos.push(repo);
commit(repo, receipts(3, 'base'), 'results: 3 run receipts');
const tmp = path.join(repo.work, '.git', 'gstack-patch-id'); fs.mkdirSync(tmp, { recursive: true });
const stale = path.join(tmp, 'run-stale'); const fresh = path.join(tmp, 'run-fresh');
for (const d of [stale, fresh]) { fs.mkdirSync(d); fs.writeFileSync(path.join(d, 'diff.patch'), 'diff\n'); }
const old = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(stale, old, old);
const body = path.join(repo.root, 'body.md');
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
expect([fs.existsSync(stale), fs.existsSync(fresh)]).toEqual([false, true]);
});
});
+42 -1
View File
@@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts';
import { comparePath } from '../lib/ship-patch';
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(90_000);
const BIN = path.dirname(RELEASE);
@@ -484,3 +484,44 @@ describe('test-only and audit-requested edits after the docs audit (GST-391, gbr
expect([reset.status, reset.stdout.includes('ATTEMPT: counted (a second install refusal in this invocation)')]).toEqual([3, true]);
});
});
describe('preflight carry route for an adjacent base edit (GST-442)', () => {
// Value: protects=/ship Step 1's carry check offers the scoped review when the base edited context next to a reviewed hunk,
// instead of treating the refusal as "run the full loop", and its dry run writes nothing; fails_when=preflight acts only on
// 'CARRY: would carry' or carry --dry-run writes the packet; seam=gstack-ship-receipt carry --dry-run; no_claim=the reviewer
test('preflight routes RECEIPT_CARRY_SCOPED_REVIEW to one scoped review, and the dry run writes no packet', () => {
const src = (edit: Record<number, string> = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n';
const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) });
const reviewed = f.git('rev-parse', 'HEAD').stdout.trim();
f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) });
mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) });
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const view = path.join(f.root, 'view.json');
fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head }));
f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`);
const r = f.run(PREFLIGHT, ['--base', 'main']);
expect(lineOf(r.stdout, 'CARRY')).toBe('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)');
expect(r.stdout).toMatch(/^NEXT: carry route \(Step 1\.4\): .*fix what the base merge broke.*commit and push.*writes the packet.*one fix-check reviewer.*--scoped-review <record\.json>/m);
expect(fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))).toBe(false);
const wrote = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main']);
expect([wrote.status, fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))]).toEqual([EXIT.refused, true]);
});
// Value: protects=the dry run offers the scoped route only when nothing else would refuse the carry, so preflight never sends
// the agent through a commit, packet, reviewer and pregate that end in another refusal (adversarial-native#5, Step 11
// round 2); fails_when=the dry run returns the scoped refusal before the receipt's other carry checks; seam=gh; no_claim=none
test('a dry run that the receipt would refuse anyway names that refusal, not the scoped route', () => {
const src = (edit: Record<number, string> = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n';
const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) });
const reviewed = f.git('rev-parse', 'HEAD').stdout.trim();
f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) });
mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) });
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const view = path.join(f.root, 'view.json');
fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', gstack_version: '1.0.0' } as any), headRefOid: head }));
f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`);
const r = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main', '--dry-run']);
expect([r.status, lineOf(r.stdout, 'CARRY')]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']);
});
});
@@ -0,0 +1,4 @@
{
"bytes": -6,
"why": "GST-442/GST-444: Step 1.4 follows a scoped-review NEXT; Step 16 stage 2 and 3 route finish scoped-review STOP and --docs-requested with its --docs-result; trims in Steps 3, 12 and 16 keep the skill under its cap, and a Step 3 merge-conflict trim keeps the skeleton under maxSkeletonBytes with train 77 on main"
}
+17
View File
@@ -224,6 +224,23 @@ describe('prefix trees modulo stamps', () => {
expect(stampFreeTree(git, 'f'.repeat(40), repo.work, policy)).toHaveProperty('error');
});
// Value: protects=a gated-to-current diff git cannot produce refuses with its cause instead of reading as 'no changes'
// (GST-448); fails_when=buildReceipt treats a failed diff as an empty one or drops why it failed; seam=a missing blob; no_claim=none
test('a diff git cannot produce refuses the receipt with EVIDENCE_IDENTITY_UNKNOWN and git\'s reason', () => {
const policy = loadPolicyFromHead(repo.work).policy;
repo.git('checkout', '-q', '-B', 'lost-blob', sha.t1!);
fs.writeFileSync(path.join(repo.work, 'lost.txt'), 'a blob this test deletes from the object store\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'add a blob');
const head = repo.git('rev-parse', 'HEAD');
const blobId = repo.git('rev-parse', `${head}:lost.txt`);
const gitDir = repo.git('rev-parse', '--absolute-git-dir');
fs.rmSync(path.join(gitDir, 'objects', blobId.slice(0, 2), blobId.slice(2)));
const r = buildReceipt({ repoRoot: repo.work, gated: sha.t1!, current: head, policy, policyLabel: 'fixture' }) as { error?: string; code?: string };
expect(r.code).toBe('EVIDENCE_IDENTITY_UNKNOWN');
expect(r.error).toMatch(/^git diff [0-9a-f]{12} [0-9a-f]{12} failed \(.+\), so the changed files cannot be classified/);
repo.git('checkout', '-q', '-f', 'squash');
});
// #3512: a stamp path carrying a tentative `*_KEY_VERSION = '<next minor>'` (gstack's own convention since #3511) digests the
// same before and after the slot stamp moves it, so train-backstop finds the record that covers main; a base-version mention
// on a line the base already carried digests the same at the prefix (where it equals VERSION) and on the stamped main.