diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fded999..220029eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # 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 `**: 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 ` 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 ` 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 - **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 `#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 `[#]`, 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`. diff --git a/VERSION b/VERSION index 3ffacc691..e849722ad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.373.0 +1.91.374.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f5ca186bd..2817a10b0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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 for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-base-red b/bin/gstack-base-red new file mode 100755 index 000000000..ad9c1673d --- /dev/null +++ b/bin/gstack-base-red @@ -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 [--base ] [--head ] [--pause] [--json] + * gstack-base-red wait --pause [--timeout ] [--poll ] [--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: ). Exit 0 only for base-red; 1 in-branch. + * `wait` returns 0 (BASE_GREEN: ) 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 [--base ] [--head ] [--pause] [--json] + evidence: { "lane": "", "total": , + "failures": [{ "id": "", "file": "", "signature": "" }] } + wait --pause [--timeout ] [--poll ] [--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 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 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 `], 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); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 36caac274..4e70a2be6 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -285,6 +285,12 @@ gstack_result_anchor() { QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; + QUEUE_RECEIPT_CONDITIONAL) printf '%s' 'queue-receipt-conditional' ;; + BASE_RED_UNCONFIGURED) printf '%s' 'base-red-unconfigured' ;; + BASE_RED_UNNAMED) printf '%s' 'base-red-unnamed' ;; + BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;; + BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;; + BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;; QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;; QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index f7b30d005..3abb2714a 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -7,6 +7,7 @@ * [--session-kind ] [--artifacts-consumed /] [--run ] [--thread ] * [--receipt ] [--policy