screen #3611: merge 9d7f7aba81 onto main 319ef500ca

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 8aa4998b0b
19 files changed
+860 -18

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [Unreleased]
**/ship pauses on a red base instead of failing.** When main is red for a reason outside a PR, /ship's Step 5 used to fail or ask the fix/TODO/blame/skip question, and unattended lanes either burned runs or held by hand. Truther's two main-red fixes (#815 and the cover-contract branch) each failed only on the other's broken cases, so neither could pass /ship alone until a Captain folded them into #818 by hand (GSTA-157, from the 2026-10-29 speed feedback).
### Added
- **`gstack-base-red classify`** (bin/gstack-base-red, lib/base-red.ts): reads a new `base_red: { setup, select, timeout_sec }` block from the base branch's ship policy, then calls a failure base-red only when every failure the lane reported is named, the PR touches neither the failing test file nor any path its signature names, and `setup` then `select {files}` fails with the same test id and first error line at the base head and at HEAD in scratch worktrees under the state root. Anything else stays in-branch and /ship triages exactly as before. `--pause` writes a pause record (repository and branch hashed, 24-hour expiry).
- **`gstack-base-red wait --pause <file>`**: on each new base head it reruns the paused files there and exits 0 (`BASE_GREEN`) on the first head that passes; a head that still fails keeps it waiting. Exits 3 on `BASE_RED_WAIT_TIMEOUT` or `BASE_RED_PAUSE_EXPIRED`.
- **Conditional receipts**: `gstack-ship-receipt write --base-red <pause file>` adds a `base_red` block; `result` then prints `SHIP_PAUSED: base-red …` with `status=incomplete`. `bindReceipt` refuses a conditional receipt by default (`QUEUE_RECEIPT_CONDITIONAL`), so `ready`, `train build`, `verify --train` and `carry` never accept one.
- **Policy key `base_red`** (`BASE_RED_KEY_VERSION`) with validation: `select` needs a `{files}` slot, commands are one line under 512 characters, `timeout_sec` is 10–7200.
### Changed
- **/ship Step 5** (ship/sections/tests.md.tmpl) runs `classify` before the T3 question when T1 calls every failure pre-existing, carries base-red cases as that run's only waived failures into Step 16, and documents `/ship --resume <record>` after `BASE_GREEN` (Step 5 reruns on HEAD merged with the new base in a scratch worktree; a green run rewrites the receipt without `base_red`). Step 22 and docs/captain.md name the paused state.
### Tests
- `test/base-red.test.ts`: base-red on a real fixture with a bare origin and no leftover worktree; in-branch without running anything when the PR touches the test or a path its signature names; in-branch when the test passes on the base or fails there differently; unconfigured, unnamed and failing-setup cases; the hashed pause path; conditional receipts refused by default; `result` reports `SHIP_PAUSED` and `status=incomplete`; `carry` refuses; `wait` skips a still-red base move and returns on the first green head, and expires or times out; policy validation.
## [1.91.320.0] - 2026-10-29
**A branch that adds its own pregate lane now learns why it still fails, and `gstack-post pr-body` no longer keeps a stale copy of a section.** Pregate reads its policy from the base branch so a branch cannot loosen its own gate. A branch that had already declared a lane in its own `.gstack/pregate.json` was still told to declare it, and went round in a circle. Separately, a PR body that held `## What changed` twice (an old description pasted under the /ship body) kept the old copy every time /ship reposted.
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-base-red — /ship Step 5's red-base proof and pause (docs/ship-policy.md#base-red).
*
* gstack-base-red classify --failures <evidence.json> [--base <branch>] [--head <sha>] [--pause] [--json]
* gstack-base-red wait --pause <file> [--timeout <s>] [--poll <s>] [--json]
*
* `classify` reads the base branch's `base_red` policy (never the working tree),
* prints one BASE_RED: or IN_BRANCH: line per failure and a BASE_RED_VERDICT:
* line, and with --pause writes the pause record when every failure is
* base-red (PAUSE: <file>). Exit 0 only for base-red; 1 in-branch.
* `wait` returns 0 (BASE_GREEN: <sha>) once a new base head passes the
* paused cases, 3 on timeout or an expired pause. Logic: lib/base-red.ts.
*/
import * as fs from 'node:fs';
import { classifyBaseRed, classifyLines, parseEvidence, readPause, waitBaseGreen, writePause } from '../lib/base-red';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLineWithDocs } from '../lib/result-codes';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { git } from '../lib/ship-queue-gate';
const HELP = `gstack-base-red — prove a Step 5 failure is the base branch's own, and pause /ship on it
classify --failures <evidence.json> [--base <branch>] [--head <sha>] [--pause] [--json]
evidence: { "lane": "<Step 5 label>", "total": <failures the lane reported>,
"failures": [{ "id": "<test name>", "file": "<test file>", "signature": "<first error line>" }] }
wait --pause <file> [--timeout <s>] [--poll <s>] [--json]
${exitTableHelp()}`;
const flag = (args: string[], name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
const say = (json: boolean, lines: string[], value: unknown) => process.stdout.write(json ? JSON.stringify(value) + '\n' : lines.join('\n') + '\n');
function policyFor(repoRoot: string, base: string, cmd: string) {
git(repoRoot, ['fetch', '--no-tags', '-q', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`]);
const load = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine(cmd, base, load.detail) + '\n'); return 'unavailable' as const; }
if (load.errors.length) { process.stderr.write(`${cmd}: ${load.source.label} is invalid (${load.errors[0]!.message} at ${load.errors[0]!.path}); every failure stays in-branch\n`); return null; }
return load.policy.base_red;
}
function classify(args: string[], json: boolean, repoRoot: string): number {
const file = flag(args, '--failures');
if (!file) { process.stderr.write(`gstack-base-red classify: --failures <evidence.json> is required\n${HELP}\n`); return EXIT.usage; }
let raw: unknown;
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { process.stderr.write(`gstack-base-red classify: cannot read ${file}: ${e.message}\n`); return EXIT.usage; }
const evidence = parseEvidence(raw);
if (typeof evidence === 'string') { process.stderr.write(`gstack-base-red classify: ${file}: ${evidence}\n`); return EXIT.usage; }
const base = resolveBaseBranch(repoRoot, flag(args, '--base'));
const policy = policyFor(repoRoot, base, 'gstack-base-red classify');
if (policy === 'unavailable') return EXIT.fail;
const r = classifyBaseRed({ repoRoot, base, head: flag(args, '--head') ?? 'HEAD', evidence, policy });
const lines = classifyLines(r);
if (r.code !== 'BASE_RED' && r.code !== 'IN_BRANCH') process.stderr.write(resultLineWithDocs('gstack-base-red classify', r.code, r.detail) + '\n');
let pause: string | undefined;
if (r.code === 'BASE_RED' && args.includes('--pause')) {
const branch = git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']).out || r.head;
pause = writePause(repoRoot, branch, r, evidence).file;
lines.push(`PAUSE: ${pause}`);
}
say(json, lines, { ...r, pause });
return r.code === 'BASE_RED' ? EXIT.ok : EXIT.fail;
}
function wait(args: string[], json: boolean, repoRoot: string): number {
const file = flag(args, '--pause');
if (!file) { process.stderr.write(`gstack-base-red wait: --pause <file> is required\n${HELP}\n`); return EXIT.usage; }
const pause = readPause(file);
if (typeof pause === 'string') { process.stderr.write(`gstack-base-red wait: ${pause}\n`); return EXIT.usage; }
const policy = policyFor(repoRoot, pause.base, 'gstack-base-red wait');
if (!policy || policy === 'unavailable') { process.stderr.write(resultLineWithDocs('gstack-base-red wait', 'BASE_RED_UNCONFIGURED', `origin/${pause.base} no longer declares a usable base_red`) + '\n'); return EXIT.refused; }
const timeoutSec = Number(flag(args, '--timeout') ?? 21600);
const pollSec = Number(flag(args, '--poll') ?? 120);
if (!(timeoutSec > 0) || !(pollSec >= 5)) { process.stderr.write('gstack-base-red wait: --timeout must be > 0 and --poll >= 5 seconds\n'); return EXIT.usage; }
const r = waitBaseGreen({ repoRoot, pause, policy, timeoutSec, pollSec });
if (r.code === 'BASE_GREEN') { say(json, [`BASE_GREEN: origin/${pause.base} ${r.sha} passes ${pause.cases.length} paused case(s) after ${r.checked} base head(s); resume with /ship --resume <record>`], r); return EXIT.ok; }
process.stderr.write(resultLineWithDocs('gstack-base-red wait', r.code, r.detail) + '\n');
say(json, [`BASE_RED_WAIT: ${r.code} last base ${r.sha.slice(0, 12)} (${r.checked} head(s) checked)`], r);
return EXIT.refused;
}
const [cmd, ...rest] = process.argv.slice(2);
const json = rest.includes('--json');
const repoRoot = git(process.cwd(), ['rev-parse', '--show-toplevel']).out || process.cwd();
if (cmd === 'classify') process.exit(classify(rest, json, repoRoot));
if (cmd === 'wait') process.exit(wait(rest, json, repoRoot));
process.stdout.write(HELP + '\n');
process.exit(cmd === '--help' || cmd === '-h' ? EXIT.ok : EXIT.usage);
+6
View File
@@ -286,6 +286,12 @@ gstack_result_anchor() {
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
QUEUE_RECEIPT_CONDITIONAL) printf '%s' 'queue-receipt-conditional' ;;
BASE_RED_UNCONFIGURED) printf '%s' 'base-red-unconfigured' ;;
BASE_RED_UNNAMED) printf '%s' 'base-red-unnamed' ;;
BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;;
BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;;
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
+18 -8
View File
@@ -7,6 +7,7 @@
* [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
* [--base-red <pause.json>]
* [--out <file>] [--body <pr-body.md>] [--json]
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json]
* gstack-ship-receipt read --body <file> [--json]
@@ -47,11 +48,13 @@ import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body;
--base-red <pause.json> makes it conditional (gstack-base-red classify --pause)
read --pr <n> [--repo <r>] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID)
read --body <file> parse the block from a file
read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41)
@@ -181,6 +184,8 @@ function cmdWrite(rest: string[], json: boolean): number {
if (!read.ok) { for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message}; nothing written`) + '\n'); return EXIT.fail; }
pregate = read.field;
}
const pause = flag(rest, '--base-red') ? readPause(flag(rest, '--base-red')!) : undefined;
if (typeof pause === 'string') { process.stderr.write(`gstack-ship-receipt write: --base-red: ${pause}; nothing written\n`); return EXIT.usage; }
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const bodyFile = flag(rest, '--body');
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
@@ -238,6 +243,7 @@ function cmdWrite(rest: string[], json: boolean): number {
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile),
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -346,13 +352,16 @@ function cmdResult(rest: string[], json: boolean): number {
}
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
const bound = bindReceipt(body, pr, [head]);
const bound = bindReceipt(body, pr, [head], { conditional: true });
if (!bound.ok) return refused(bound.code, bound.message);
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete });
const red = bound.receipt.base_red;
if (red) process.stderr.write(`SHIP_PAUSED: base-red cases=${red.cases.length} base=${red.base.slice(0, 12)} lane=${red.lane} expires=${red.expires_at}; gstack-base-red wait --pause <file>, then /ship --resume <record>\n`);
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete, status: red ? 'incomplete' : 'complete' });
}
/** The run record under <state>/ship/runs/ and the terminal line, shared by `result` and `carry`. */
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown> }): number {
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown>; status?: 'complete' | 'incomplete' }): number {
const status = a.status ?? 'complete';
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dir = a.out ? path.resolve(a.out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${a.head.slice(0, 12)}`);
const receiptPath = path.join(dir, 'ship-receipt.md');
@@ -361,15 +370,15 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
fs.writeFileSync(receiptPath, renderReceiptBlock(a.receipt));
} catch (e) { return a.incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
const run = {
schema_version: 1, skill: 'ship', status: 'complete', head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
schema_version: 1, skill: 'ship', status, head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
queue_mode: a.receipt.queue_mode ?? null, session_kind: a.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
receipt: receiptPath, receipt_source: a.source, branch: a.repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], a.repoRoot) ?? null : null, created_at: new Date().toISOString(),
...(a.extra ?? {}),
};
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return a.incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
process.stderr.write(a.line + '\n');
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
return EXIT.ok;
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=${status} run=${dir}\n`);
return status === 'complete' ? EXIT.ok : EXIT.fail;
}
/**
@@ -487,7 +496,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
return EXIT.refused;
@@ -519,6 +528,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!ex.receipt || ex.errors.length) return refused('QUEUE_RECEIPT_MISSING', `${source}'s ship receipt is invalid (${ex.errors[0]?.message ?? 'invalid'}); run /ship`);
const receipt = ex.receipt;
if (pr !== undefined && receipt.pr !== undefined && String(receipt.pr) !== pr) return refused('RECEIPT_CARRY_REFUSED', `the receipt names pr=${receipt.pr}, not this PR`);
if (receipt.base_red) return refused('QUEUE_RECEIPT_CONDITIONAL', 'the receipt is paused on a red base, so there is nothing passing to carry; /ship --resume <record> re-runs Step 5 and writes a full receipt');
const from = receipt.head.toLowerCase();
if (!/^[0-9a-f]{40,64}$/.test(from)) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${receipt.head.slice(0, 12)} is not a full commit id`);
if (from === head) {
+6 -1
View File
@@ -228,7 +228,12 @@ a push included. The sequence that works unattended on Capy:
7. Body and receipt: compose the PR body (What changed, Why, Blast radius, Approvals first on Capy),
write the ship receipt into it, post body and title.
8. Terminal line: `gstack-ship-receipt result --pr <n>` prints
`GSTACK_RESULT: skill=ship status=complete`.
`GSTACK_RESULT: skill=ship status=complete`. `SHIP_PAUSED: base-red …` with
`status=incomplete` means Step 5 proved its failures are the base's own
([base_red](ship-policy.md#base-red)): the lane is waiting, not failed. It
registers `gstack-base-red wait --pause <file>` as a background operation,
tells the controller which cases hold it, and on `BASE_GREEN` runs
`/ship --resume <record>`; no READY goes out on the conditional receipt.
9. READY: `gstack-ship-queue ready --pr <n> --pregate <pregate.json> --screen|--no-screen`;
send the printed block.
10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same
+49
View File
@@ -61,6 +61,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. |
Containment, checked by `validate` and again at write time: paths and globs are
repo-relative with no `..` segment, under 256 characters, at most 64 per list,
@@ -941,6 +942,54 @@ line, and the codes a refusal ends with are
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range).
<a id="base-red"></a>
## `base_red`: pausing /ship on a red base
When the base branch is red for a reason outside the PR (Truther's #815/#818
pair each failed only on the other's broken cases), /ship's Step 5 used to fail
or ask the T3 triage question. With a `base_red` block on the base branch it can
prove the failure is the base's and pause instead:
```json
"base_red": { "setup": "bun install --frozen-lockfile", "select": "bun test {files}", "timeout_sec": 900 },
"min_gstack_version": "<the release that introduced base_red>"
```
`setup` installs dependencies in a scratch checkout (`null` when none are
needed); `select` is the lane command with a `{files}` slot that receives the
failing test files, shell-quoted; `timeout_sec` (10–7200) bounds each command.
`gstack-base-red classify --failures <evidence.json> --base <base> --pause`
reads the block from `origin/<base>` (a PR cannot declare its own) and calls a
failure base-red only when all of these hold:
1. The evidence names every failure the lane reported (`total` equals the
number of `failures`), else `BASE_RED_UNNAMED`.
2. No failing test file, and no path of the PR's diff (`git diff --name-only
<merge-base>..HEAD`) that a failure's signature names, is changed by the PR.
3. In scratch worktrees under the state root, `setup` then `select` fails at
the base head and at HEAD, each output naming the same test id and the same
first error line (digits and whitespace normalized). A setup that fails is
`BASE_RED_REPRO_ERROR`.
Anything else is `IN_BRANCH` and /ship continues its usual triage, exactly as
without the block. A base-red run writes a pause record (`PAUSE: <file>` under
`$GSTACK_STATE_ROOT/ship/base-red/`, repository and branch hashed, 24-hour
expiry), finishes every other step, and writes its receipt with `--base-red
<file>`: the receipt's `base_red` block (`cases`, `files`, `base`, `lane`,
`expires_at`) makes it **conditional**. `gstack-ship-receipt result` prints
`SHIP_PAUSED: base-red …` and `status=incomplete`; `ready`, `train build`,
`verify --train`, `carry` and every other receipt consumer refuse a conditional
receipt with `QUEUE_RECEIPT_CONDITIONAL`.
To resume, a parent runs `gstack-base-red wait --pause <file>` as a background
operation. On each new base head (`git ls-remote`) it reruns `setup` and
`select` on the paused files there and exits 0 (`BASE_GREEN: <sha>`) on the
first head that passes; a head that still fails keeps it waiting, and it exits 3
on its timeout (`BASE_RED_WAIT_TIMEOUT`) or the pause's expiry
(`BASE_RED_PAUSE_EXPIRED`). Then `/ship --resume <record>` reruns Step 5 on HEAD
merged with the new base in a scratch worktree and, when every lane passes,
rewrites the receipt without `base_red`.
<a id="receipt-carry"></a>
## Carrying a receipt across a base merge or a test-only fix
+61
View File
@@ -4589,6 +4589,67 @@ written for the pinned head is the binding one and `verify --train` accepts it.
`gstack-ship-queue ready --pr <n> --screen|--no-screen`. A lane never stamps
to fix this: the stamp comes only when the slot is called.
<a id="queue-receipt-conditional"></a>
### `QUEUE_RECEIPT_CONDITIONAL`
**Meaning.** The PR body's ship receipt carries a `base_red` block: /ship's
Step 5 failed on cases `gstack-base-red` proved are the base branch's own, so
/ship paused before READY ([base_red](ship-policy.md#base-red)). Nothing
READYs, trains, carries or merges on it.
**Fix.** Wait for the base to pass those cases (`gstack-base-red wait --pause
<file>`), then run `/ship --resume <record>`, which reruns Step 5 and rewrites
the receipt without `base_red`.
<a id="base-red-unconfigured"></a>
### `BASE_RED_UNCONFIGURED`
**Meaning.** `origin/<base>`'s ship policy has no `base_red` block, so
`gstack-base-red` cannot reproduce a failure there and every Step 5 failure
stays in-branch. An invalid ship policy on the base counts the same (the
invalid key is printed first). `wait` prints it when the block was removed
after the pause.
**Fix.** Use /ship's usual triage. To let /ship pause on a red base, add
`base_red` to `.gstack/ship-policy.json` on the base branch
([base_red](ship-policy.md#base-red)).
<a id="base-red-unnamed"></a>
### `BASE_RED_UNNAMED`
**Meaning.** The failure evidence names fewer failures than the lane reported,
so an unnamed failure could be the PR's.
**Fix.** List every failing test (`id`, `file`, first error line as
`signature`) with `total` equal to the lane's failure count, and classify again.
<a id="base-red-repro-error"></a>
### `BASE_RED_REPRO_ERROR`
**Meaning.** `gstack-base-red` could not run the reproduction: the base or HEAD
did not resolve, the PR's diff was unreadable, the scratch checkout failed, or
`setup` exited non-zero. Every failure stays in-branch.
**Fix.** Read the printed detail. Fetch the base, or fix the `base_red.setup`
command on the base branch, then classify again.
<a id="base-red-pause-expired"></a>
### `BASE_RED_PAUSE_EXPIRED`
**Meaning.** The pause is older than 24 hours, so its classification is stale.
**Fix.** Report the paused PR to its Captain or owner, then start /ship again on
the current base; it classifies afresh.
<a id="base-red-wait-timeout"></a>
### `BASE_RED_WAIT_TIMEOUT`
**Meaning.** No new base head passed the paused cases before `gstack-base-red
wait`'s `--timeout`.
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
owns the base's red.
<a id="queue-receipt-depth-raised"></a>
### `QUEUE_RECEIPT_DEPTH_RAISED`
+6 -2
View File
@@ -148,8 +148,12 @@ The other endings, each greppable: when gh cannot read the PR,
`GSTACK_RESULT: skill=ship status=incomplete run=-` (exit 1); a receipt that is
missing or names another head, `GSTACK_RESULT: skill=ship status=refused run=-`
with `QUEUE_RECEIPT_MISSING` or `QUEUE_RECEIPT_STALE` on stderr (exit 3), the
line a run that stops before publication prints itself; and `result --pr <n>`
refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head.
line a run that stops before publication prints itself; `result --pr <n>`
refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head; and a
receipt with a `base_red` block (Step 5 paused on a red base) prints
`SHIP_PAUSED: base-red …` on stderr, writes the run record and ends
`GSTACK_RESULT: skill=ship status=incomplete run=<dir>` (exit 1): the lane is
waiting on the base, not failed ([base_red](ship-policy.md#base-red)).
The one publication the unattended rules allow: when an open PR for the branch
exists and HEAD is its pushed head, Step 18 takes the pr-body section's
+197
View File
@@ -0,0 +1,197 @@
/**
* gstack-base-red — /ship Step 5's proof that a failure belongs to the base
* branch, not to the PR (docs/ship-policy.md#base-red). A failure is base-red
* only when the base branch's own ship policy declares how to reproduce one
* (`base_red: { setup, select, timeout_sec }`), every failure Step 5 counted is
* named, no failing test file or path its signature names is in the PR's diff,
* and the restricted command fails with the same test id and signature both at
* the base head and at the PR head, each in a scratch worktree under the state
* root. Any other outcome is in-branch, so /ship stops exactly as before.
*
* A base-red /ship finishes its other steps, writes a pause record here and
* ends with a conditional receipt; `wait` re-runs the cases on each new base
* head and returns once they pass there, and `/ship --resume` re-runs Step 5.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ShipReceipt } from './headless-artifacts';
import { type BaseRedPolicy } from './ship-policy';
import { git, sha256, writeJsonAtomic } from './ship-queue-gate';
import { runBashSync } from './script-invocation';
import { resolveStateRoot } from './state-root';
export const PAUSE_HOURS = 24;
export interface FailureCase { id: string; file: string; signature: string }
export interface FailureEvidence { lane: string; total: number; failures: FailureCase[] }
export interface CaseVerdict { id: string; file: string; verdict: 'BASE_RED' | 'IN_BRANCH'; reason: string }
export type ClassifyCode = 'BASE_RED' | 'IN_BRANCH' | 'BASE_RED_UNCONFIGURED' | 'BASE_RED_UNNAMED' | 'BASE_RED_REPRO_ERROR';
export interface ClassifyResult { code: ClassifyCode; base: string; base_sha: string; head: string; lane: string; cases: CaseVerdict[]; detail: string }
export interface RunOutput { status: number | null; out: string; error?: string }
export type Runner = (cwd: string, command: string, timeoutSec: number) => RunOutput;
export interface PauseRecord {
schema_version: 1; kind: 'base-red-pause'; branch: string; head: string; base: string; base_sha: string; lane: string;
cases: FailureCase[]; created_at: string; expires_at: string;
}
export const shellRunner: Runner = (cwd, command, timeoutSec) => {
const r = runBashSync(command, { cwd, encoding: 'utf8', timeout: timeoutSec * 1000, env: { ...process.env, CI: process.env.CI ?? '1' }, maxBuffer: 64 * 1024 * 1024 });
return { status: r.status, out: `${r.stdout ?? ''}\n${r.stderr ?? ''}`, error: r.error ? r.error.message : r.signal ? `killed by ${r.signal}` : undefined };
};
/** Step 5's failure evidence file: `{ lane, total, failures: [{ id, file, signature }] }`. */
export function parseEvidence(raw: unknown): FailureEvidence | string {
const e = raw as Record<string, unknown> | null;
if (!e || typeof e !== 'object' || Array.isArray(e)) return 'must be { lane, total, failures: [{ id, file, signature }] }';
if (typeof e.lane !== 'string' || !e.lane) return 'lane must be the Step 5 lane label';
if (!Number.isInteger(e.total) || (e.total as number) < 1) return 'total must be the failure count the lane reported (an integer >= 1)';
if (!Array.isArray(e.failures)) return 'failures must be an array';
for (const [i, f] of e.failures.entries()) {
const c = f as Record<string, unknown> | null;
if (!c || typeof c !== 'object') return `failures[${i}] must be { id, file, signature }`;
for (const k of ['id', 'file', 'signature'] as const) if (typeof c[k] !== 'string' || !(c[k] as string).trim()) return `failures[${i}].${k} must be a non-empty string`;
if (path.isAbsolute(c.file as string) || (c.file as string).split(/[\\/]/).includes('..')) return `failures[${i}].file must be repo-relative`;
}
return e as unknown as FailureEvidence;
}
const normalize = (s: string) => s.replace(/\d+/g, '0').replace(/\s+/g, ' ').trim();
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/** Whether a restricted run reproduces a case: non-zero exit, the test id and the normalized signature in its output. */
function reproduces(run: RunOutput, c: FailureCase): string | null {
if (run.status === 0) return 'passes';
const out = normalize(run.out);
if (!out.includes(normalize(c.id))) return 'fails without naming this test';
if (!out.includes(normalize(c.signature).slice(0, 160))) return 'fails with a different signature';
return null;
}
function scratchRoot(): string {
return path.join(resolveStateRoot(), 'ship', 'base-red', 'worktrees');
}
/** Check out `sha` in a scratch worktree, run setup then select on `files`, remove the worktree. */
export function runAt(repoRoot: string, sha: string, policy: BaseRedPolicy, files: string[], runner: Runner): RunOutput & { setupFailed?: boolean } {
fs.mkdirSync(scratchRoot(), { recursive: true });
const dir = fs.mkdtempSync(path.join(scratchRoot(), `${sha.slice(0, 12)}-`));
try {
const add = git(repoRoot, ['worktree', 'add', '--detach', '-q', dir, sha]);
if (!add.ok) return { status: null, out: '', error: `cannot check out ${sha.slice(0, 12)}: ${add.err.slice(0, 160)}`, setupFailed: true };
if (policy.setup) {
const s = runner(dir, policy.setup, policy.timeout_sec);
if (s.status !== 0) return { status: s.status, out: s.out, error: `setup exited ${s.status ?? s.error ?? 'unknown'}`, setupFailed: true };
}
return runner(dir, policy.select.split('{files}').join(files.map(quote).join(' ')), policy.timeout_sec);
} finally {
git(repoRoot, ['worktree', 'remove', '--force', dir]);
fs.rmSync(dir, { recursive: true, force: true });
}
}
export function classifyBaseRed(o: { repoRoot: string; base: string; head: string; evidence: FailureEvidence; policy: BaseRedPolicy | null; runner?: Runner }): ClassifyResult {
const runner = o.runner ?? shellRunner;
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '-q', `origin/${o.base}^{commit}`]).out;
const headSha = git(o.repoRoot, ['rev-parse', '--verify', '-q', `${o.head}^{commit}`]).out;
const all = (verdict: CaseVerdict['verdict'], reason: string) => o.evidence.failures.map(c => ({ id: c.id, file: c.file, verdict, reason }));
const out = (code: ClassifyCode, cases: CaseVerdict[], detail: string): ClassifyResult => ({ code, base: o.base, base_sha: baseSha, head: headSha, lane: o.evidence.lane, cases, detail });
if (!o.policy) return out('BASE_RED_UNCONFIGURED', all('IN_BRANCH', 'the base branch declares no base_red'), `origin/${o.base}'s ship policy has no base_red block`);
if (!baseSha || !headSha) return out('BASE_RED_REPRO_ERROR', all('IN_BRANCH', 'unresolved commit'), `cannot resolve origin/${o.base} or ${o.head}`);
if (o.evidence.failures.length !== o.evidence.total) return out('BASE_RED_UNNAMED', all('IN_BRANCH', 'not every failure is named'), `the lane reported ${o.evidence.total} failures and the evidence names ${o.evidence.failures.length}`);
const mergeBase = git(o.repoRoot, ['merge-base', baseSha, headSha]).out;
const diff = git(o.repoRoot, ['diff', '--name-only', `${mergeBase || baseSha}..${headSha}`]);
if (!mergeBase || !diff.ok) return out('BASE_RED_REPRO_ERROR', all('IN_BRANCH', 'diff unreadable'), `cannot read the PR's diff against origin/${o.base}`);
const changed = diff.out.split('\n').filter(Boolean);
const touched = o.evidence.failures.map(c => ({ c, path: changed.find(p => p === c.file || c.signature.includes(p)) }));
if (touched.some(t => t.path)) {
return out('IN_BRANCH', touched.map(({ c, path: p }) => ({ id: c.id, file: c.file, verdict: 'IN_BRANCH', reason: p ? `the PR changes ${p}` : 'another failure is in-branch' })), 'the PR touches a failing test or a path its failure names');
}
const files = [...new Set(o.evidence.failures.map(c => c.file))];
const atBase = runAt(o.repoRoot, baseSha, o.policy, files, runner);
if (atBase.setupFailed) return out('BASE_RED_REPRO_ERROR', all('IN_BRANCH', 'reproduction failed at base'), atBase.error ?? 'setup failed at base');
const atHead = runAt(o.repoRoot, headSha, o.policy, files, runner);
if (atHead.setupFailed) return out('BASE_RED_REPRO_ERROR', all('IN_BRANCH', 'reproduction failed at head'), atHead.error ?? 'setup failed at head');
const cases = o.evidence.failures.map((c): CaseVerdict => {
const base = reproduces(atBase, c);
if (base) return { id: c.id, file: c.file, verdict: 'IN_BRANCH', reason: `${base} at base ${baseSha.slice(0, 12)}` };
const head = reproduces(atHead, c);
if (head) return { id: c.id, file: c.file, verdict: 'IN_BRANCH', reason: `the restricted command ${head} at head` };
return { id: c.id, file: c.file, verdict: 'BASE_RED', reason: `fails the same way at base ${baseSha.slice(0, 12)}` };
});
const red = cases.every(c => c.verdict === 'BASE_RED');
return out(red ? 'BASE_RED' : 'IN_BRANCH', cases, red ? `every failure reproduces at origin/${o.base} ${baseSha.slice(0, 12)}` : 'at least one failure is not reproduced on the base');
}
export function classifyLines(r: ClassifyResult): string[] {
return [
...r.cases.map(c => c.verdict === 'BASE_RED' ? `BASE_RED: ${c.id} (${c.file}) base=${r.base_sha.slice(0, 12)}` : `IN_BRANCH: ${c.id} (${c.file}): ${c.reason}`),
`BASE_RED_VERDICT: ${r.code === 'BASE_RED' ? 'base-red' : 'in-branch'} lane=${r.lane} base=origin/${r.base}@${r.base_sha.slice(0, 12) || '-'} (${r.code}: ${r.detail})`,
];
}
/** `<state root>/ship/base-red/<repo>/<branch>.json`, both hashed so a branch name never becomes a path. */
export function pausePath(repoRoot: string, branch: string): string {
return path.join(resolveStateRoot(), 'ship', 'base-red', sha256(fs.realpathSync(repoRoot)).slice(0, 16), `${sha256(branch).slice(0, 16)}.json`);
}
export function writePause(repoRoot: string, branch: string, r: ClassifyResult, evidence: FailureEvidence, now = new Date()): { file: string; record: PauseRecord } {
const record: PauseRecord = {
schema_version: 1, kind: 'base-red-pause', branch, head: r.head, base: r.base, base_sha: r.base_sha, lane: r.lane, cases: evidence.failures,
created_at: now.toISOString(), expires_at: new Date(now.getTime() + PAUSE_HOURS * 3600_000).toISOString(),
};
const file = pausePath(repoRoot, branch);
writeJsonAtomic(file, record);
return { file, record };
}
export function readPause(file: string): PauseRecord | string {
let raw: unknown;
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { return `cannot read ${file}: ${e.message}`; }
const p = raw as PauseRecord;
if (p?.kind !== 'base-red-pause' || p.schema_version !== 1 || !Array.isArray(p.cases) || !p.cases.length || typeof p.base_sha !== 'string' || typeof p.expires_at !== 'string') return `${file} is not a base-red pause record`;
return p;
}
/** The receipt's `base_red` block: what makes a receipt conditional (lib/ship-receipt.ts bindReceipt refuses it by default). */
export function receiptBaseRed(p: PauseRecord): NonNullable<ShipReceipt['base_red']> {
return { cases: p.cases.map(c => c.id), files: [...new Set(p.cases.map(c => c.file))], base: p.base_sha, lane: p.lane, expires_at: p.expires_at };
}
export type WaitResult = { code: 'BASE_GREEN'; sha: string; checked: number } | { code: 'BASE_RED_PAUSE_EXPIRED' | 'BASE_RED_WAIT_TIMEOUT'; sha: string; checked: number; detail: string };
/**
* Wait for the base to stop failing the paused cases: on each new base head
* (read with `git ls-remote`), fetch it and re-run setup and select there;
* return when that run passes. A base that moved but still fails keeps waiting.
*/
export function waitBaseGreen(o: {
repoRoot: string; pause: PauseRecord; policy: BaseRedPolicy; timeoutSec: number; pollSec: number; remote?: string;
runner?: Runner; now?: () => number; sleep?: (ms: number) => void; lsRemote?: () => string | null;
}): WaitResult {
const remote = o.remote ?? 'origin';
const now = o.now ?? Date.now;
const sleep = o.sleep ?? ((ms: number) => Bun.sleepSync(ms));
const lsRemote = o.lsRemote ?? (() => {
const r = git(o.repoRoot, ['ls-remote', remote, `refs/heads/${o.pause.base}`]);
return r.ok ? r.out.split(/\s/)[0] || null : null;
});
const files = [...new Set(o.pause.cases.map(c => c.file))];
const deadline = now() + o.timeoutSec * 1000;
const expires = Date.parse(o.pause.expires_at);
let last = o.pause.base_sha;
let checked = 0;
for (;;) {
if (now() >= expires) return { code: 'BASE_RED_PAUSE_EXPIRED', sha: last, checked, detail: `the pause expired at ${o.pause.expires_at}` };
const sha = lsRemote();
if (sha && sha !== last) {
git(o.repoRoot, ['fetch', '--no-tags', '-q', remote, `+refs/heads/${o.pause.base}:refs/remotes/${remote}/${o.pause.base}`]);
checked++;
const run = runAt(o.repoRoot, sha, o.policy, files, o.runner ?? shellRunner);
if (!run.setupFailed && run.status === 0) return { code: 'BASE_GREEN', sha, checked };
last = sha;
}
if (now() >= deadline) return { code: 'BASE_RED_WAIT_TIMEOUT', sha: last, checked, detail: `no base head passed the paused cases within ${o.timeoutSec}s` };
sleep(Math.min(o.pollSec * 1000, Math.max(0, deadline - now())));
}
}
+3
View File
@@ -111,6 +111,8 @@ export interface ShipReceipt {
review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] };
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } };
/** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */
base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
@@ -233,6 +235,7 @@ export const ARTIFACT_SCHEMAS = {
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
head_tree: str, product_patch_id: str, gstack_version: str,
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } },
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } },
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } },
+30
View File
@@ -148,6 +148,36 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
},
QUEUE_RECEIPT_CONDITIONAL: {
anchor: 'queue-receipt-conditional',
summary: 'the PR body’s /ship receipt is conditional: Step 5 failed on cases gstack-base-red proved are the base branch’s own, so /ship paused before READY',
fix: 'wait for the base to pass those cases (gstack-base-red wait --pause <file>), then /ship --resume <record>, which re-runs Step 5 and rewrites the receipt without base_red; nothing READYs, trains or merges on a conditional receipt',
},
BASE_RED_UNCONFIGURED: {
anchor: 'base-red-unconfigured',
summary: 'the base branch’s ship policy declares no base_red block, so gstack-base-red cannot reproduce a Step 5 failure there and every failure stays in-branch',
fix: 'handle the failure with /ship’s usual triage; to let /ship pause on a red base, add base_red { setup, select, timeout_sec } to .gstack/ship-policy.json on the base branch',
},
BASE_RED_UNNAMED: {
anchor: 'base-red-unnamed',
summary: 'the failure evidence names fewer failures than the Step 5 lane reported, so an unnamed failure could be in-branch',
fix: 'list every failing test in the evidence file (id, file, first error line), with total equal to the lane’s failure count, and classify again',
},
BASE_RED_REPRO_ERROR: {
anchor: 'base-red-repro-error',
summary: 'gstack-base-red could not reproduce at the base or the PR head: a commit did not resolve, the scratch checkout failed, or the setup command exited non-zero',
fix: 'read the printed detail; fix the base_red setup command on the base branch or fetch the base, then classify again. Until then every failure is in-branch',
},
BASE_RED_PAUSE_EXPIRED: {
anchor: 'base-red-pause-expired',
summary: 'the base-red pause is older than 24 hours, so its classification is stale',
fix: 'report the paused PR to its Captain (or owner), then start /ship again on the current base; it classifies afresh',
},
BASE_RED_WAIT_TIMEOUT: {
anchor: 'base-red-wait-timeout',
summary: 'no new base head passed the paused cases before gstack-base-red wait’s timeout',
fix: 'run gstack-base-red wait --pause <file> again (the pause stays valid until it expires), or check who owns the base red',
},
QUEUE_RECEIPT_DEPTH_RAISED: {
anchor: 'queue-receipt-depth-raised',
summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all',
+25
View File
@@ -73,8 +73,13 @@ export interface ShipPolicy {
train: TrainPolicy | null;
/** Documentation caps /ship Step 14.5 checks before the verification gate (`gstack-ship-receipt docs-caps`); null declares none. */
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
/** How /ship reproduces a Step 5 failure on the base branch before calling it base-red (docs/ship-policy.md#base-red); null never calls one base-red. */
base_red: BaseRedPolicy | null;
}
/** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */
export interface BaseRedPolicy { setup: string | null; select: string; timeout_sec: number }
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
export interface TrainPolicy {
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
@@ -118,6 +123,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
min_gstack_version: null,
train: null,
docs: null,
base_red: null,
};
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
@@ -146,6 +152,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|bisect|rebuild|prune, ready --screen|--no-screen, verify --train). null keeps today\'s per-PR queue unchanged.',
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
base_red: '{ setup, select, timeout_sec } or null: how /ship Step 5 proves a failure is the base branch\'s own (docs/ship-policy.md#base-red). gstack-base-red checks out the base head and the PR head in scratch worktrees, runs setup (dependency install, or null), then select with the failing test files in its {files} slot; only the same failures in both, in files the PR does not touch, are base-red, and /ship then pauses before READY instead of failing. Read from the base branch only. null keeps every failure in-branch.',
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
};
@@ -176,12 +183,14 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0';
// its key, so a value a stamp did not move fails there.
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
export const BASE_RED_KEY_VERSION = '1.92.0.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -360,6 +369,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
if ('base_red' in obj && obj.base_red !== null) baseRedErrors(obj.base_red, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
@@ -441,6 +451,21 @@ function docsErrors(raw: unknown, errors: PolicyError[]): void {
});
}
function baseRedErrors(raw: unknown, errors: PolicyError[]): void {
const b = raw as Record<string, unknown> | null;
if (!b || typeof b !== 'object' || Array.isArray(b)) { errors.push({ code: 'POLICY_INVALID', path: 'base_red', message: 'must be { setup, select, timeout_sec } or null' }); return; }
for (const k of Object.keys(b)) if (!['setup', 'select', 'timeout_sec'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `base_red.${k}`, message: 'unknown key' });
const command = (key: string, value: unknown, nullable: boolean) => {
if (nullable && value === null) return;
if (typeof value !== 'string' || !value.trim()) errors.push({ code: 'POLICY_INVALID', path: `base_red.${key}`, message: `must be a non-empty string${nullable ? ' or null' : ''}` });
else if (value.length > MAX_COMMAND || /[\0\r\n]/.test(value)) errors.push({ code: 'POLICY_CONTAINMENT', path: `base_red.${key}`, message: `must be one line under ${MAX_COMMAND} characters` });
};
command('setup', b.setup, true);
command('select', b.select, false);
if (typeof b.select === 'string' && !b.select.includes('{files}')) errors.push({ code: 'POLICY_INVALID', path: 'base_red.select', message: 'needs a {files} slot for the failing test files' });
if (!Number.isInteger(b.timeout_sec) || (b.timeout_sec as number) < 10 || (b.timeout_sec as number) > 7200) errors.push({ code: 'POLICY_INVALID', path: 'base_red.timeout_sec', message: 'must be an integer from 10 to 7200' });
}
/** Merge a parsed file over the defaults, recording per-key provenance. */
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
+9 -3
View File
@@ -18,7 +18,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'carry',
'review_loop_exception', 'carry', 'base_red',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -53,7 +53,7 @@ export function extractReceipt(body: string): { found: boolean; receipt?: ShipRe
}
}
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE' | 'QUEUE_RECEIPT_CONDITIONAL';
export type ReceiptBinding =
| { ok: true; receipt: ShipReceipt; head: string; line: string }
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
@@ -66,8 +66,12 @@ export type ReceiptBinding =
* head is the pin's stamp commit (the stamp changes only stamp files, so the
* receipt written for the pin stays the binding one). `line` is the
* informational `SHIP_RECEIPT:` line the READY block prints either way.
* A receipt with a `base_red` block (lib/base-red.ts) is conditional: its
* Step 5 did not pass, so it binds only for a caller that asks for it
* (`/ship`'s terminal line, which reports the pause) and is refused
* everywhere else.
*/
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[], opts: { conditional?: boolean } = {}): ReceiptBinding {
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
const ex = extractReceipt(body);
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
@@ -83,6 +87,7 @@ export function bindReceipt(body: string, pr: string | undefined, accepted: read
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
}
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
if (r.base_red && !opts.conditional) return { ok: false, code: 'QUEUE_RECEIPT_CONDITIONAL', message: `${subject} ship receipt is paused on a red base (${oneLine(r.base_red.cases.join(', ')).slice(0, 160)} fail at ${oneLine(r.base_red.base.slice(0, 12))}); /ship --resume rewrites it once the base passes them`, line };
return { ok: true, receipt: r, head, line };
}
@@ -125,6 +130,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
if (r.base_red && typeof r.base_red.base === 'string') parts.push(`base_red=${r.base_red.base.slice(0, 12)}`);
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
}
+2 -1
View File
@@ -224,7 +224,8 @@ rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
```
`--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the
A Step 5 red-base pause adds `--base-red <pause file>` (a conditional receipt the queue
refuses, `QUEUE_RECEIPT_CONDITIONAL`). `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the
receipt carries it as `docs`, and a `blocked` status without the owner's exception,
or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written.
When the owner accepted a stop after a fix-check edit, add
+2 -1
View File
@@ -207,7 +207,8 @@ rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
```
`--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the
A Step 5 red-base pause adds `--base-red <pause file>` (a conditional receipt the queue
refuses, `QUEUE_RECEIPT_CONDITIONAL`). `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the
receipt carries it as `docs`, and a `blocked` status without the owner's exception,
or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written.
When the owner accepted a stop after a fix-check edit, add
+27 -1
View File
@@ -383,7 +383,33 @@ esac && rm -f "$TITLE_FILE" "$BODY_FILE"
- Continue with the workflow.
- Note in output: "Pre-existing test failure skipped: <test-name>"
**After triage:** If any in-branch failures remain unfixed, **STOP**. Do not proceed. If all failures were pre-existing and handled (fixed, TODOed, assigned, or skipped), continue to Step 6.
**Red base (before T3).** When T1 calls every failure likely pre-existing,
prove it before asking: write `.gstack/tmp/base-red.json` with your file-write tool
(`{"lane":"<label>","total":<failures the lane reported>,"failures":[{"id":"<test
name>","file":"<test file>","signature":"<first error line>"}]}`), then run
```bash
~/.claude/skills/gstack/bin/gstack-base-red classify --failures .gstack/tmp/base-red.json --base <base> --pause
```
It reads `base_red` from the base branch's ship policy and reruns those files at
the base head and at HEAD in scratch worktrees. Exit 0 with `BASE_RED_VERDICT:
base-red` and `PAUSE: <file>`: skip T3, record the pause file and its cases in
the invocation record, and continue to Step 6; Step 16 treats exactly these cases
as this run's waived failures (any other failure is new), and the receipt write
adds `--base-red <pause file>`, so Step 22 ends `SHIP_PAUSED` with
`status=incomplete` instead of READY. Exit 1 (`IN_BRANCH:` lines,
`BASE_RED_UNCONFIGURED`, `BASE_RED_UNNAMED`, `BASE_RED_REPRO_ERROR`): continue T3
as written.
**Resume a red-base pause.** A parent waits with `gstack-base-red wait --pause
<file>` (a background operation; `BASE_GREEN:` once a new base head passes the
cases), then runs `/ship --resume <record>`: if HEAD is still the paused head,
rerun this Step 5 on HEAD merged with the new base in a scratch worktree (never a
pushed base merge); all green rewrites the receipt without `--base-red` and runs
Step 22. Any failure is triaged here as new; a moved HEAD starts /ship fresh.
**After triage:** If any in-branch failures remain unfixed, **STOP**. Do not proceed. If all failures were pre-existing and handled (fixed, TODOed, assigned, skipped, or base-red and paused), continue to Step 6.
**If all pass:** Report each lane's pass count (or `count unavailable`) in one line and continue to Step 6.
+27 -1
View File
@@ -71,7 +71,33 @@ Failure Ownership Triage:
{{TEST_FAILURE_TRIAGE}}
**After triage:** If any in-branch failures remain unfixed, **STOP**. Do not proceed. If all failures were pre-existing and handled (fixed, TODOed, assigned, or skipped), continue to Step 6.
**Red base (before T3).** When T1 calls every failure likely pre-existing,
prove it before asking: write `.gstack/tmp/base-red.json` with your file-write tool
(`{"lane":"<label>","total":<failures the lane reported>,"failures":[{"id":"<test
name>","file":"<test file>","signature":"<first error line>"}]}`), then run
```bash
~/.claude/skills/gstack/bin/gstack-base-red classify --failures .gstack/tmp/base-red.json --base <base> --pause
```
It reads `base_red` from the base branch's ship policy and reruns those files at
the base head and at HEAD in scratch worktrees. Exit 0 with `BASE_RED_VERDICT:
base-red` and `PAUSE: <file>`: skip T3, record the pause file and its cases in
the invocation record, and continue to Step 6; Step 16 treats exactly these cases
as this run's waived failures (any other failure is new), and the receipt write
adds `--base-red <pause file>`, so Step 22 ends `SHIP_PAUSED` with
`status=incomplete` instead of READY. Exit 1 (`IN_BRANCH:` lines,
`BASE_RED_UNCONFIGURED`, `BASE_RED_UNNAMED`, `BASE_RED_REPRO_ERROR`): continue T3
as written.
**Resume a red-base pause.** A parent waits with `gstack-base-red wait --pause
<file>` (a background operation; `BASE_GREEN:` once a new base head passes the
cases), then runs `/ship --resume <record>`: if HEAD is still the paused head,
rerun this Step 5 on HEAD merged with the new base in a scratch worktree (never a
pushed base merge); all green rewrites the receipt without `--base-red` and runs
Step 22. Any failure is triaged here as new; a moved HEAD starts /ship fresh.
**After triage:** If any in-branch failures remain unfixed, **STOP**. Do not proceed. If all failures were pre-existing and handled (fixed, TODOed, assigned, skipped, or base-red and paused), continue to Step 6.
**If all pass:** Report each lane's pass count (or `count unavailable`) in one line and continue to Step 6.
+284
View File
@@ -0,0 +1,284 @@
/**
* gstack-base-red (docs/ship-policy.md#base-red): a Step 5 failure is base-red
* only when the base branch's policy declares how to reproduce it, every
* failure is named, the PR touches none of the failing files or the paths
* their signatures name, and the same test fails the same way at the base
* head and at the PR head in scratch worktrees. A conditional receipt is
* refused by every receipt consumer except /ship's own terminal line, which
* reports SHIP_PAUSED; wait returns only once a new base head passes.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { classifyBaseRed, parseEvidence, pausePath, readPause, receiptBaseRed, shellRunner, waitBaseGreen, writePause, type FailureEvidence, type Runner } from '../lib/base-red';
import { EXIT } from '../lib/headless-artifacts';
import { bindReceipt, extractReceipt, placeReceiptFirst, receiptSummaryLine } from '../lib/ship-receipt';
import { BASE_RED_KEY_VERSION, validatePolicy, type BaseRedPolicy } from '../lib/ship-policy';
const ROOT = path.resolve(import.meta.dir, '..');
const ID = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid' };
const SELECT = 'for f in {files}; do bash "$f" || status=1; done; exit ${status:-0}';
const POLICY: BaseRedPolicy = { setup: null, select: SELECT, timeout_sec: 60 };
const FAIL_TEST = 'echo "(fail) totals add up"; echo "error: expected 3 got 4 in lib/sum.sh"; exit 1\n';
const PASS_TEST = 'echo "(pass) totals add up"\n';
let root = '';
let work = '';
let state = '';
const prevState = process.env.GSTACK_STATE_ROOT;
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...ID } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const write = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(work, rel)), { recursive: true }); fs.writeFileSync(path.join(work, rel), text); };
const policyJson = (base_red: BaseRedPolicy | null) => JSON.stringify({ base_red, min_gstack_version: base_red ? BASE_RED_KEY_VERSION : null }) + '\n';
const evidence = (over: Partial<FailureEvidence> = {}): FailureEvidence => ({ lane: 'tests', total: 1, failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: expected 3 got 4' }], ...over });
const counting = () => { const calls: string[] = []; const runner: Runner = (cwd, cmd, s) => { calls.push(cmd); return shellRunner(cwd, cmd, s); }; return { calls, runner }; };
/** origin/main carries a red test; the PR branch changes only README.md. */
function fixture(base: { test?: string; policy?: BaseRedPolicy | null } = {}) {
write('t/sum.test.sh', base.test ?? FAIL_TEST);
write('lib/sum.sh', 'echo 4\n');
write('README.md', 'base\n');
write('.gstack/ship-policy.json', policyJson(base.policy === undefined ? POLICY : base.policy));
git('add', '-A'); git('commit', '-qm', 'base'); git('push', '-q', 'origin', 'HEAD:main'); git('fetch', '-q', 'origin');
git('checkout', '-q', '-b', 'feat');
write('README.md', 'pr\n'); git('commit', '-qam', 'pr: readme');
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'base-red-'));
work = path.join(root, 'work');
state = path.join(root, 'state');
process.env.GSTACK_STATE_ROOT = state;
spawnSync('git', ['init', '-q', '--bare', '-b', 'main', path.join(root, 'origin.git')], { timeout: 30_000 });
fs.mkdirSync(work);
git('init', '-q', '-b', 'main'); git('remote', 'add', 'origin', path.join(root, 'origin.git'));
});
afterEach(() => {
if (prevState === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prevState;
fs.rmSync(root, { recursive: true, force: true });
});
describe('classify', () => {
test('a failure that reproduces at base and head in a file the PR does not touch is base-red, and leaves no worktree behind', () => {
fixture();
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY });
expect(r.code).toBe('BASE_RED');
expect(r.cases).toEqual([{ id: 'totals add up', file: 't/sum.test.sh', verdict: 'BASE_RED', reason: `fails the same way at base ${r.base_sha.slice(0, 12)}` }]);
expect(git('worktree', 'list').split('\n')).toHaveLength(1);
expect(fs.readdirSync(path.join(state, 'ship', 'base-red', 'worktrees'))).toEqual([]);
});
test('the PR touching the failing test, or a path its signature names, is in-branch without running anything', () => {
fixture();
write('t/sum.test.sh', FAIL_TEST + '# touched\n'); git('commit', '-qam', 'touch test');
const a = counting();
const touched = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY, runner: a.runner });
expect([touched.code, touched.cases[0]!.reason, a.calls]).toEqual(['IN_BRANCH', 'the PR changes t/sum.test.sh', []]);
git('reset', '-q', '--hard', 'HEAD~1');
write('lib/sum.sh', 'echo 5\n'); git('commit', '-qam', 'touch lib');
const named = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: expected 3 got 4 in lib/sum.sh' }] }), policy: POLICY, runner: a.runner });
expect([named.code, named.cases[0]!.reason, a.calls]).toEqual(['IN_BRANCH', 'the PR changes lib/sum.sh', []]);
});
test('a test that passes on the base, or fails there with another signature, is in-branch', () => {
fixture({ test: PASS_TEST });
const passes = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: POLICY });
expect(passes.code).toBe('IN_BRANCH');
expect(passes.cases[0]!.reason).toStartWith('passes at base ');
const other = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ failures: [{ id: 'totals add up', file: 't/sum.test.sh', signature: 'error: timeout after 5000ms' }] }), policy: { ...POLICY, select: 'bash -c "echo totals add up; echo error: expected; exit 1" # {files}' } });
expect([other.code, other.cases[0]!.reason.split(' at base')[0]]).toEqual(['IN_BRANCH', 'fails with a different signature']);
});
test('no base_red policy, an unnamed failure, or a failing setup keeps every failure in-branch', () => {
fixture({ policy: null });
expect(classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: null }).code).toBe('BASE_RED_UNCONFIGURED');
expect(classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ total: 2 }), policy: POLICY }).code).toBe('BASE_RED_UNNAMED');
const setup = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: { ...POLICY, setup: 'exit 7' } });
expect([setup.code, setup.detail, setup.cases.every(c => c.verdict === 'IN_BRANCH')]).toEqual(['BASE_RED_REPRO_ERROR', 'setup exited 7', true]);
});
test('evidence must name the lane, the total and repo-relative files', () => {
expect(parseEvidence({ lane: 'tests', total: 0, failures: [] })).toContain('total');
expect(parseEvidence({ lane: 'tests', total: 1, failures: [{ id: 'x', file: '../escape.sh', signature: 's' }] })).toContain('repo-relative');
expect(typeof parseEvidence(evidence())).toBe('object');
});
});
describe('pause, receipt and wait', () => {
test('the pause path hashes the branch; the receipt block from it is refused by default and bound only on request', () => {
fixture();
const ev = evidence();
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY });
const { file } = writePause(work, 'feat/../../etc', r, ev, new Date('2026-10-29T00:00:00Z'));
expect(file).toBe(pausePath(work, 'feat/../../etc'));
expect(path.basename(file)).toMatch(/^[0-9a-f]{16}\.json$/);
const pause = readPause(file);
if (typeof pause === 'string') throw new Error(pause);
expect(pause.expires_at).toBe('2026-10-30T00:00:00.000Z');
const head = git('rev-parse', 'HEAD');
const body = placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any);
expect(extractReceipt(body).errors).toEqual([]);
const refused = bindReceipt(body, '7', [head]);
expect(refused.ok ? 'bound' : refused.code).toBe('QUEUE_RECEIPT_CONDITIONAL');
expect(bindReceipt(body, '7', [head], { conditional: true }).ok).toBe(true);
});
test('the terminal line reports SHIP_PAUSED with status=incomplete, and carry refuses a conditional receipt', () => {
fixture();
const ev = evidence();
fs.writeFileSync(path.join(root, 'ev.json'), JSON.stringify(ev));
const bin = (name: string, args: string[]) => spawnSync('bun', [path.join(ROOT, 'bin', name), ...args], { cwd: work, encoding: 'utf8', timeout: 120_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
const c = bin('gstack-base-red', ['classify', '--failures', path.join(root, 'ev.json'), '--base', 'main', '--pause']);
expect(c.status).toBe(EXIT.ok);
const pauseLine = c.stdout.split('\n').find(l => l.startsWith('PAUSE: '))!;
expect(c.stdout).toContain('BASE_RED_VERDICT: base-red lane=tests');
const pause = readPause(pauseLine.slice(7));
if (typeof pause === 'string') throw new Error(pause);
const head = git('rev-parse', 'HEAD');
const bodyFile = path.join(root, 'body.md');
fs.writeFileSync(bodyFile, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any));
const res = bin('gstack-ship-receipt', ['result', '--body', bodyFile, '--head', head, '--out', path.join(root, 'run')]);
expect(res.status).toBe(EXIT.fail);
expect(res.stdout.trim()).toBe(`GSTACK_RESULT: skill=ship status=incomplete run=${path.join(root, 'run')}`);
expect(res.stderr).toContain(`SHIP_PAUSED: base-red cases=1 base=${pause.base_sha.slice(0, 12)} lane=tests`);
expect(JSON.parse(fs.readFileSync(path.join(root, 'run', 'ship-run.json'), 'utf8')).status).toBe('incomplete');
const carry = bin('gstack-ship-receipt', ['carry', '--body', bodyFile]);
expect([carry.status, carry.stdout.split('\n')[0]]).toEqual([EXIT.refused, 'CARRY: refused (QUEUE_RECEIPT_CONDITIONAL)']);
});
test('wait skips a base move that still fails and returns on the first head that passes', () => {
fixture();
const ev = evidence();
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY });
const pause = writePause(work, 'feat', r, ev).record;
git('checkout', '-q', 'main');
write('README.md', 'still red\n'); git('commit', '-qam', 'unrelated'); const stillRed = git('rev-parse', 'HEAD');
write('t/sum.test.sh', PASS_TEST); git('commit', '-qam', 'fix'); const green = git('rev-parse', 'HEAD');
git('push', '-q', 'origin', 'main');
const heads = [pause.base_sha, stillRed, stillRed, green];
let t = 0;
const res = waitBaseGreen({ repoRoot: work, pause, policy: POLICY, timeoutSec: 600, pollSec: 5, lsRemote: () => heads.shift() ?? green, now: () => t, sleep: ms => { t += ms; } });
expect(res).toEqual({ code: 'BASE_GREEN', sha: green, checked: 2 });
const expired = waitBaseGreen({ repoRoot: work, pause: { ...pause, expires_at: '2000-01-01T00:00:00Z' }, policy: POLICY, timeoutSec: 60, pollSec: 5, lsRemote: () => green });
expect(expired.code).toBe('BASE_RED_PAUSE_EXPIRED');
let u = 0;
const timeout = waitBaseGreen({ repoRoot: work, pause, policy: POLICY, timeoutSec: 30, pollSec: 5, lsRemote: () => pause.base_sha, now: () => u, sleep: ms => { u += ms; } });
expect([timeout.code, timeout.checked]).toEqual(['BASE_RED_WAIT_TIMEOUT', 0]);
});
});
describe('policy', () => {
test('base_red needs {files} in select, bounded timeout, no unknown keys and the introducing min_gstack_version', () => {
const codes = (base_red: unknown, min: string | null = BASE_RED_KEY_VERSION) => validatePolicy({ base_red, min_gstack_version: min }).map(e => `${e.code}@${e.path}`);
expect(codes(POLICY)).toEqual([]);
expect(codes({ ...POLICY, setup: 'bun install --frozen-lockfile' })).toEqual([]);
expect(codes({ ...POLICY, select: 'bun test' })).toEqual(['POLICY_INVALID@base_red.select']);
expect(codes({ ...POLICY, timeout_sec: 5 })).toEqual(['POLICY_INVALID@base_red.timeout_sec']);
expect(codes({ ...POLICY, extra: 1 })).toEqual(['POLICY_INVALID@base_red.extra']);
expect(codes({ ...POLICY, select: 'a {files}\nrm -rf /' })).toEqual(['POLICY_CONTAINMENT@base_red.select']);
expect(codes(POLICY, null)).toEqual(['POLICY_INVALID@base_red']);
});
});
describe('coverage audit additions', () => {
// Generated by /ship Step 7 coverage audit (PR #3611).
// Value: protects=a failure reproduced at base but not by the restricted run at the PR head stays in-branch, and one unreproduced case keeps the whole verdict in-branch;
// fails_when=the head reproduction or the every-case aggregation is dropped, so a PR that changes behaviour is paused as base-red; why_new=existing tests vary only the base run, with one case;
// seam=none; no_claim=real test runners (shell scripts stand in for the lane's tests)
test('a case the head run does not reproduce, or a case the base output never names, keeps the verdict in-branch', () => {
fixture();
write('pr-only.txt', 'x\n'); git('add', '-A'); git('commit', '-qm', 'pr: marker');
const headPasses = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence(), policy: { ...POLICY, select: `[ -f pr-only.txt ] && { echo "(pass) totals add up"; exit 0; }; ${SELECT}` } });
expect([headPasses.code, headPasses.cases[0]!.reason]).toEqual(['IN_BRANCH', 'the restricted command passes at head']);
const ghost = { id: 'ghost case', file: 't/sum.test.sh', signature: 'error: expected 3 got 4' };
const mixed = classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: evidence({ total: 2, failures: [evidence().failures[0]!, ghost] }), policy: POLICY });
expect(mixed.code).toBe('IN_BRANCH');
expect(mixed.cases.map(c => c.verdict)).toEqual(['BASE_RED', 'IN_BRANCH']);
expect(mixed.cases[1]!.reason).toStartWith('fails without naming this test at base ');
});
// Generated by /ship Step 7 coverage audit (PR #3611).
// Value: protects=an unresolvable PR head is BASE_RED_REPRO_ERROR with every case in-branch and nothing run; fails_when=the commit-resolution guard is removed or inverted;
// why_new=no existing test resolves a missing commit; seam=none; no_claim=none beyond protects
test('an unresolvable head is a reproduction error that runs nothing', () => {
fixture();
const a = counting();
const r = classifyBaseRed({ repoRoot: work, base: 'main', head: 'no-such-ref', evidence: evidence(), policy: POLICY, runner: a.runner });
expect([r.code, r.cases.every(c => c.verdict === 'IN_BRANCH'), a.calls]).toEqual(['BASE_RED_REPRO_ERROR', true, []]);
expect([r.detail, r.cases[0]!.reason]).toEqual([`cannot resolve origin/main or no-such-ref`, 'unresolved commit']);
});
// Generated by /ship Step 7 coverage audit (PR #3611).
// Value: protects=readPause rejects missing or non-pause files, and wait refuses BASE_RED_UNCONFIGURED once the base no longer declares base_red;
// fails_when=readPause accepts any JSON or wait polls without a usable base policy; why_new=existing tests read only valid pauses and never run the wait CLI;
// seam=none; no_claim=remote fetch over a network (local bare origin)
test('readPause rejects non-pause files and the wait CLI refuses when the base declares no base_red', () => {
fixture({ policy: null });
const bogus = path.join(root, 'bogus.json');
fs.writeFileSync(bogus, JSON.stringify({ kind: 'other', schema_version: 1, cases: [] }));
expect(readPause(bogus)).toContain('is not a base-red pause record');
expect(readPause(path.join(root, 'missing.json'))).toStartWith('cannot read ');
const pauseFile = path.join(root, 'pause.json');
fs.writeFileSync(pauseFile, JSON.stringify({ schema_version: 1, kind: 'base-red-pause', branch: 'feat', head: git('rev-parse', 'HEAD'), base: 'main', base_sha: git('rev-parse', 'origin/main'), lane: 'tests', cases: evidence().failures, created_at: '2026-10-29T00:00:00Z', expires_at: '2999-01-01T00:00:00Z' }));
const w = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-base-red'), 'wait', '--pause', pauseFile, '--timeout', '30', '--poll', '5'], { cwd: work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
expect(w.status).toBe(EXIT.refused);
expect(w.stderr).toContain('BASE_RED_UNCONFIGURED');
});
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
// Value: protects=write --base-red adds the base_red block from a valid pause, and an unreadable or non-pause file exits usage with nothing written;
// fails_when=the flag is ignored, so an unconditional receipt is written for a paused run; why_new=no test runs write with --base-red;
// seam=none; no_claim=PR body upload via gh (local body file only)
test('write --base-red makes the receipt conditional, and a bad pause file writes nothing', () => {
fixture();
const ev = evidence();
const { file, record } = writePause(work, 'feat', classifyBaseRed({ repoRoot: work, base: 'main', head: 'HEAD', evidence: ev, policy: POLICY }), ev);
const body = path.join(root, 'body.md');
const run = (pause: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-ship-receipt'), 'write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--body', body, '--base-red', pause], { cwd: work, encoding: 'utf8', timeout: 120_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state } });
for (const bad of [path.join(root, 'missing.json'), path.join(root, 'ev.json')]) {
fs.writeFileSync(path.join(root, 'ev.json'), JSON.stringify(ev));
fs.writeFileSync(body, 'prose\n');
const r = run(bad);
expect([r.status, r.stderr.includes('--base-red: '), r.stderr.includes('nothing written'), fs.readFileSync(body, 'utf8')]).toEqual([EXIT.usage, true, true, 'prose\n']);
}
const ok = run(file);
expect(ok.status).toBe(EXIT.ok);
const head = git('rev-parse', 'HEAD');
const written = fs.readFileSync(body, 'utf8');
expect(extractReceipt(written).receipt?.base_red).toEqual(receiptBaseRed(record));
const bound = bindReceipt(written, '7', [head]);
expect(bound.ok ? 'bound' : bound.code).toBe('QUEUE_RECEIPT_CONDITIONAL');
});
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
// Value: protects=the SHIP_RECEIPT summary line names base_red=<base12> for a conditional receipt and omits it otherwise; fails_when=the base_red part is dropped or printed unconditionally;
// why_new=summary-line tests cover carried_from only; seam=none; no_claim=none beyond protects
test('the summary line names the red base only for a conditional receipt', () => {
const base = 'c'.repeat(40);
const plain = { schema_version: 1, head: 'a'.repeat(40), base: 'main' } as any;
expect(receiptSummaryLine({ ...plain, base_red: { cases: ['x'], files: ['t/x.sh'], base, lane: 'tests', expires_at: '2026-10-30T00:00:00Z' } })).toContain(` base_red=${base.slice(0, 12)}`);
expect(receiptSummaryLine(plain)).not.toContain('base_red=');
});
// Generated by /ship Step 7 coverage audit (PR #3611), pass 2.
// Value: protects=classify exits usage on a missing --failures, an unreadable file or invalid evidence before any git fetch; fails_when=evidence checks move after the policy fetch or stop refusing;
// why_new=parseEvidence is unit-tested only, never through the CLI; seam=none; no_claim=none beyond protects (GIT_TRACE proves no fetch ran)
test('classify refuses bad evidence with a usage exit before fetching anything', () => {
git('commit', '-q', '--allow-empty', '-m', 'init');
const trace = path.join(root, 'git-trace.log');
const bad = path.join(root, 'bad.json');
fs.writeFileSync(bad, JSON.stringify({ lane: 'tests', total: 1, failures: [{ id: 'x', file: '/abs.sh', signature: 's' }] }));
const cases: Array<[string[], string]> = [[[], '--failures <evidence.json> is required'], [['--failures', path.join(root, 'missing.json')], 'cannot read '], [['--failures', bad], 'must be repo-relative']];
for (const [args, why] of cases) {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-base-red'), 'classify', ...args], { cwd: work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...ID, GSTACK_STATE_ROOT: state, GIT_TRACE: trace } });
expect([r.status, r.stderr.includes(why)]).toEqual([EXIT.usage, true]);
}
const traced = fs.readFileSync(trace, 'utf8');
expect([traced.includes('rev-parse'), traced.includes('fetch')]).toEqual([true, false]);
});
});
@@ -0,0 +1,4 @@
{
"bytes": 1699,
"why": "GSTA-157: Step 5 red-base pause (gstack-base-red classify before T3, resume after BASE_GREEN) and the conditional receipt flag in the PR-body step"
}