v1.91.374.0 ship: pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1) (#3611)

* feat(ship): pause /ship on a red base instead of failing (gstack-base-red, conditional receipts) (GSTA-157 PR 1)

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

* fix: base-red shell runner launches bash through runBashSync

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

* test: base-red coverage additions (unreproduced head, unresolved head, readPause, wait unconfigured)

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

* docs: base-red pause in unattended endings; BASE_RED_UNCONFIGURED covers an invalid base policy

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

* test: base-red coverage pass 2 (write --base-red, summary line, classify usage before fetch, unresolved-head detail)

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 16:31:27 +00:00
1 parent 0cd2b4a9f9
commit 1b46f375df
22 files changed
+863 -21

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## [1.91.374.0] - 2026-10-31
**/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`). docs/captain.md names 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.373.0] - 2026-10-31 ## [1.91.373.0] - 2026-10-31
- **No-gstack arm in every head-to-head eval.** `/eval-plan` preregistrations now say in a required `## Comparison` section whether any arm loads gstack or another skill stack (`stack: loaded`) or none does (`stack: none` with a `why:` line). A loaded comparison must add a `## No-gstack arm` section and one `<model>#no-gstack` arm per model. That arm uses the same model and inputs, a plain prompt with no skill text, and the same panel and judge, and its result is reported beside the variants. `gstack-eval-plan prereg --check` refuses a loaded comparison without it (`PREREG_NO_GSTACK_ARM`). Arms are now identities `<model>[#<loadout>]`, priced by the model, so the pilot reserves a variant and its no-gstack arm on the same model separately; a repeated identity is refused (`EVAL_ARM_DUPLICATE`). The rule is written up in `docs/TESTING_INTERNALS.md`. - **No-gstack arm in every head-to-head eval.** `/eval-plan` preregistrations now say in a required `## Comparison` section whether any arm loads gstack or another skill stack (`stack: loaded`) or none does (`stack: none` with a `why:` line). A loaded comparison must add a `## No-gstack arm` section and one `<model>#no-gstack` arm per model. That arm uses the same model and inputs, a plain prompt with no skill text, and the same panel and judge, and its result is reported beside the variants. `gstack-eval-plan prereg --check` refuses a loaded comparison without it (`PREREG_NO_GSTACK_ARM`). Arms are now identities `<model>[#<loadout>]`, priced by the model, so the pilot reserves a variant and its no-gstack arm on the same model separately; a repeated identity is refused (`EVAL_ARM_DUPLICATE`). The rule is written up in `docs/TESTING_INTERNALS.md`.
+1 -1
View File
@@ -1 +1 @@
1.91.373.0 1.91.374.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.373.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.374.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+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
@@ -285,6 +285,12 @@ gstack_result_anchor() {
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; 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_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; 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>] * [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>] * [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]... * [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
* [--base-red <pause.json>]
* [--out <file>] [--body <pr-body.md>] [--json] * [--out <file>] [--body <pr-body.md>] [--json]
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json] * gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json]
* gstack-ship-receipt read --body <file> [--json] * gstack-ship-receipt read --body <file> [--json]
@@ -47,11 +48,13 @@ import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root'; import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) 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 --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 --body <file> parse the block from a file
read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41) read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41)
@@ -184,6 +187,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; } 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; 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 base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const bodyFile = flag(rest, '--body'); const bodyFile = flag(rest, '--body');
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : ''; const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
@@ -247,6 +252,7 @@ function cmdWrite(rest: string[], json: boolean): number {
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product, review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined, gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
}; };
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } } 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; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -355,13 +361,16 @@ function cmdResult(rest: string[], json: boolean): number {
} }
source = `PR #${pr}${repo ? ` (${repo})` : ''}`; 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; } } 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); 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`. */ /** 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 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 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'); const receiptPath = path.join(dir, 'ship-receipt.md');
@@ -370,15 +379,15 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
fs.writeFileSync(receiptPath, renderReceiptBlock(a.receipt)); 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)}`); } } catch (e) { return a.incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
const run = { 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, 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(), 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 ?? {}), ...(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)}`); } 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.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`); process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=${status} run=${dir}\n`);
return EXIT.ok; return status === 'complete' ? EXIT.ok : EXIT.fail;
} }
/** /**
@@ -497,7 +506,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; } 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(); 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; } 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.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`); 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; return EXIT.refused;
@@ -529,6 +538,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`); 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; 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 (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(); 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 (!/^[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) { if (from === head) {
+6 -1
View File
@@ -243,7 +243,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), 7. Body and receipt: compose the PR body (What changed, Why, Blast radius, Approvals first on Capy),
write the ship receipt into it, post body and title. write the ship receipt into it, post body and title.
8. Terminal line: `gstack-ship-receipt result --pr <n>` prints 8. Terminal line: `gstack-ship-receipt result --pr <n>` prints
`GSTACK_RESULT: skill=ship status=complete`. `GSTACK_RESULT: skill=ship status=complete`. `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`; 9. READY: `gstack-ship-queue ready --pr <n> --pregate <pregate.json> --screen|--no-screen`;
send the printed block. send the printed block.
10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same 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. | | `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`. | | `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`. | | `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`. |
| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | | `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. |
Containment, checked by `validate` and again at write time: paths and globs are Containment, checked by `validate` and again at write time: paths and globs are
@@ -1042,6 +1043,54 @@ line, and the codes a refusal ends with are
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and [`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range). [`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> <a id="receipt-carry"></a>
## Carrying a receipt across a base merge or a test-only fix ## Carrying a receipt across a base merge or a test-only fix
+61
View File
@@ -4759,6 +4759,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 `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. 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> <a id="queue-receipt-depth-raised"></a>
### `QUEUE_RECEIPT_DEPTH_RAISED` ### `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 `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=-` 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 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>` 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. 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 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 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
@@ -113,6 +113,8 @@ export interface ShipReceipt {
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } };
/** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */ /** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */
review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string }; review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: 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']; export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
@@ -235,6 +237,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 } } }, 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'] } } }, 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, 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_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_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
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 } } } }, 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 } } } },
+30
View File
@@ -153,6 +153,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)', 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', 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: { QUEUE_RECEIPT_DEPTH_RAISED: {
anchor: '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', 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
@@ -75,8 +75,13 @@ export interface ShipPolicy {
train: TrainPolicy | null; train: TrainPolicy | null;
/** Documentation caps /ship Step 14.5 checks before the verification gate (`gstack-ship-receipt docs-caps`); null declares none. */ /** 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; 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. */ /** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
export interface TrainPolicy { export interface TrainPolicy {
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */ /** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
@@ -121,6 +126,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
min_gstack_version: null, min_gstack_version: null,
train: null, train: null,
docs: null, docs: null,
base_red: null,
}; };
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>; export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
@@ -150,6 +156,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).', 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.', 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.', 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).', 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).',
}; };
@@ -183,6 +190,7 @@ export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0'; export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0';
/** `bump: never` is a value of an older key, so the per-key floor check misses it; compatErrors checks it by value. */ /** `bump: never` is a value of an older key, so the per-key floor check misses it; compatErrors checks it by value. */
export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BUMP_NEVER_KEY_VERSION = '1.91.353.0';
export const BASE_RED_KEY_VERSION = '1.91.374.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = { 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', 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', 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',
@@ -190,6 +198,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION, 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, train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION,
}; };
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -373,6 +382,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors); if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors); if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, errors); if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, 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); if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) { for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue; if (!(key in obj) || obj[key] === null) continue;
@@ -457,6 +467,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' });
}
function fragmentsErrors(raw: unknown, errors: PolicyError[]): void { function fragmentsErrors(raw: unknown, errors: PolicyError[]): void {
const d = raw as Record<string, unknown> | null; const d = raw as Record<string, unknown> | null;
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir, required_checks? } or null' }); return; } if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir, required_checks? } or null' }); return; }
+9 -3
View File
@@ -19,7 +19,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', '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', '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', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'review_targeted', 'carry', 'review_loop_exception', 'review_targeted', 'carry', 'base_red',
]; ];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */ /** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -54,7 +54,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 = export type ReceiptBinding =
| { ok: true; receipt: ShipReceipt; head: string; line: string } | { ok: true; receipt: ShipReceipt; head: string; line: string }
| { ok: false; code: ReceiptBindingCode; message: string; line: string }; | { ok: false; code: ReceiptBindingCode; message: string; line: string };
@@ -67,8 +67,12 @@ export type ReceiptBinding =
* head is the pin's stamp commit (the stamp changes only stamp files, so the * 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 * receipt written for the pin stays the binding one). `line` is the
* informational `SHIP_RECEIPT:` line the READY block prints either way. * 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 subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
const ex = extractReceipt(body); 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' }; 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' };
@@ -84,6 +88,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 }; 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 (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 }; return { ok: true, receipt: r, head, line };
} }
@@ -126,6 +131,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}` 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)}` : '')); + (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.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(' '))}`; return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.91.373", "version": "1.91.374",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+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" ~/.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, 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. 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 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" ~/.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, 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. 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 When the owner accepted a stop after a fix-check edit, add
+27 -1
View File
@@ -368,7 +368,33 @@ esac && rm -f "$TITLE_FILE" "$BODY_FILE"
- Continue with the workflow. - Continue with the workflow.
- Note in output: "Pre-existing test failure skipped: <test-name>" - 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. **If all pass:** Report each lane's pass count (or `count unavailable`) in one line and continue to Step 6.
+27 -1
View File
@@ -56,7 +56,33 @@ Failure Ownership Triage:
{{TEST_FAILURE_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. **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"
}