mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.236.0 feat(ship-queue): require a current /ship receipt before READY, train build and train merge (#3438)
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without /ship. Now ready, train build (dry run included) and verify --train refuse with QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts the receipt written for the pinned head when the current head is its stamp commit. gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship, run dir with the head and receipt), unattended /ship may update an existing PR's body, and INSTALL_UNVERIFIED with no verdict names the project directory to install from (GSTA-85). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton Gate edit: test/fixtures/workflow-required-steps.json pins the new "## Step 22: Terminal line (every run)" heading (strengthens: a later edit that drops the terminal line turns the required-steps test red). Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json) - ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2 merge of main), as verify --train already did; a real edit stays STALE - ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is missing or merges an older head; a finished screen that planned 0 cases is never followed, and train build seats that slot unscreened (GSTA-95 on #3431) - gstack-ship-receipt result writes ship-run.json (a /ship record, not a run v1 manifest), validates --head, and prints status=incomplete on gh failures - bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines - tests for every state-matrix cell the review named; the train fixture's merge ref is a real merge commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train build): say when the repository slug is unreadable instead of counting the screen silently Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin 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:
1 parent
188e574d84
commit
6665e20d56
40 files changed
+1155
-152
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.236.0] - 2026-10-26
|
||||
|
||||
**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it.
|
||||
|
||||
### Changed
|
||||
- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output).
|
||||
- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull/<n>/merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen that planned no cases (a superseded run skips plan-slices and uploads no `paid-plan` artifact; a manifest with nothing planned counts the same), and `train build` prints `SCREEN: #<n> run <id> planned no cases …; counted as not-run` and seats that slot unscreened (a plan it cannot read keeps the slot screened and prints `… counted as screened, unverified`).
|
||||
- **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp.
|
||||
- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `<state root>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=<dir>`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections.
|
||||
- **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body.
|
||||
- **`INSTALL_UNVERIFIED` with no verdict names the directory to install from.** An installer run from the skill's own folder records a verdict for no project, so /ship launched from the project found none and the fix said only "run the installer on this machine" (GSTA-85). Both twins (`lib/install-verdict.ts`, `bin/gstack-install-verdict.sh`) now say `run … scripts/install.sh --for ship from <project>: the verdict is keyed by the project directory the installer runs in`.
|
||||
- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt; the receipt is a process gate, not an authenticity check), the three codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt.
|
||||
- **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed.
|
||||
|
||||
### Tests
|
||||
- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull/<n>/merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt; a bare merge of main with no stamp on top, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr <n>` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed `ls-remote` or fetch of the merge ref instead of blaming a conflict. Both install-verdict twins tell an unreadable verdict file from a missing one. `test/auq-error-fallback-hook.test.ts`: the unattended directive names the same `GSTACK_RESULT` writers as docs/unattended.md and `gstack-skill-start` (GSTA-91). `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block.
|
||||
|
||||
## [1.91.235.0] - 2026-10-26
|
||||
|
||||
**An issue we intend to fix later now stays open under the `parked` label instead of being closed.** The Oct 11 triage closed 75 feature requests with a "not rejected" note, and GitHub showed them as closed with no fix. Those 75 are open again under the new `parked` label on garrytan/gstack ("Not fixed yet, not rejected: stays open; a comment says where it is tracked and what fixes it"), each with one comment saying where the work is tracked and what fixes it. The rule is written down: an issue closes only as fixed, duplicate or won't fix, each with its reason; a report that main already fixed closes as fixed, not won't fix; and a duplicate uses GitHub's own duplicate close reason.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.235.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.236.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,
|
||||
|
||||
@@ -277,6 +277,9 @@ gstack_result_anchor() {
|
||||
QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;;
|
||||
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
|
||||
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_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
|
||||
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
|
||||
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
|
||||
APPROVAL_CLASSES_INVALID) printf '%s' 'approval-classes-invalid' ;;
|
||||
|
||||
@@ -133,11 +133,15 @@ gstack_install_admission() {
|
||||
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
|
||||
if [ ! -f "$file" ]; then
|
||||
detail="no install verdict at $file for $src"
|
||||
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints"
|
||||
else
|
||||
trailer=$(gstack_install_verdict_field "$file" trailer)
|
||||
v_head=$(gstack_install_verdict_field "$file" head)
|
||||
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
|
||||
if [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
|
||||
v_schema=$(gstack_install_verdict_field "$file" schema_version)
|
||||
if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then
|
||||
detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer"
|
||||
elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
|
||||
detail="the install verdict says: $trailer"
|
||||
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
|
||||
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue).
|
||||
*
|
||||
* gstack-ship-queue ready --pr <n> [--pregate <pregate.json>] [--gate-exit <rc> --gate-log <file>]
|
||||
* (refused with QUEUE_RECEIPT_MISSING|STALE unless the PR body carries /ship's receipt for HEAD)
|
||||
* gstack-ship-queue stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>
|
||||
* [--expect-order <pr,...>] [--allow-repo-commands] [--gate-exit <rc> --gate-log <file>]
|
||||
* gstack-ship-queue verify --pr <n> (--gated <sha> | --record <gate-ahead.json>) [--receipt <sha256>]
|
||||
@@ -33,7 +34,8 @@ import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type Queu
|
||||
const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue)
|
||||
|
||||
ready --pr <n> [--pregate <file>] [--gate-exit <rc> --gate-log <file>]
|
||||
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state
|
||||
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state,
|
||||
SHIP_RECEIPT (refused unless the PR body carries /ship's receipt for that head)
|
||||
stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha> [--expect-order <pr,...>] [--allow-repo-commands]
|
||||
[--gate-exit <rc> --gate-log <file>]
|
||||
restamp, commit and prove: QUEUE_STAMP: <pr> <X> head= gated= tree= receipt=
|
||||
|
||||
+80
-2
@@ -10,11 +10,16 @@
|
||||
* [--out <file>] [--body <pr-body.md>] [--json]
|
||||
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--json]
|
||||
* gstack-ship-receipt read --body <file> [--json]
|
||||
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
|
||||
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
|
||||
*
|
||||
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
|
||||
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
|
||||
* and with --body makes it the first section of that PR body file.
|
||||
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
|
||||
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
|
||||
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
|
||||
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
|
||||
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -27,15 +32,20 @@ import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
import {
|
||||
computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock,
|
||||
validateReceipt, type CensusPr,
|
||||
bindReceipt, computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus,
|
||||
renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
|
||||
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
|
||||
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
|
||||
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
|
||||
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
|
||||
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
|
||||
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
|
||||
census --repo <r> --since <tag|date> [--cohort <file>]
|
||||
the P7 adoption number over merged PRs (deduplicated by run and thread)
|
||||
|
||||
@@ -182,6 +192,73 @@ function cmdRead(rest: string[], json: boolean): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* /ship's terminal line (docs/unattended.md#the-terminal-line). The receipt in
|
||||
* the PR body (or the handoff file) must bind to HEAD: then the run record
|
||||
* goes under <state>/ship/runs/ with the head, the PR and the receipt, and
|
||||
* the last stdout line is GSTACK_RESULT: skill=ship status=complete run=<dir>.
|
||||
* A missing or stale receipt is status=refused run=- (exit 3), so a parent
|
||||
* never has to parse the PR body to know whether /ship finished at this head.
|
||||
*/
|
||||
function cmdResult(rest: string[], json: boolean): number {
|
||||
const bodyFile = flag(rest, '--body');
|
||||
const prFlag = flag(rest, '--pr');
|
||||
if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; }
|
||||
const pr = prFlag;
|
||||
if (prFlag !== undefined && flag(rest, '--body') !== undefined) { process.stderr.write('gstack-ship-receipt result: pass --pr <n> (the PR body, checked against its pushed head) or --body <file> (the handoff receipt), not both\n'); return EXIT.usage; }
|
||||
const repoRoot = git(['rev-parse', '--show-toplevel']);
|
||||
const head = (flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined))?.toLowerCase();
|
||||
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => {
|
||||
process.stderr.write(resultLineWithDocs('gstack-ship-receipt result', code, message) + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : 'GSTACK_RESULT: skill=ship status=refused run=-\n');
|
||||
return EXIT.refused;
|
||||
};
|
||||
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
|
||||
if (!/^[0-9a-f]{40,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a full hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
|
||||
const incomplete = (why: string) => {
|
||||
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
|
||||
return EXIT.fail;
|
||||
};
|
||||
let body: string;
|
||||
let source: string;
|
||||
if (bodyFile) {
|
||||
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist; /ship did not write its receipt`);
|
||||
try { body = fs.readFileSync(bodyFile, 'utf8'); } catch (e) { return incomplete(`cannot read ${bodyFile}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
source = path.resolve(bodyFile);
|
||||
} else if (pr) {
|
||||
const repo = flag(rest, '--repo');
|
||||
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
|
||||
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
|
||||
let pushed: unknown;
|
||||
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
|
||||
if (typeof pushed !== 'string' || !/^[0-9a-f]{12,64}$/i.test(pushed)) return incomplete(`gh pr view returned no headRefOid for PR #${pr}`);
|
||||
if (!pushed.toLowerCase().startsWith(head.toLowerCase())) {
|
||||
return refused('QUEUE_RECEIPT_STALE', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push it, then rerun gstack-ship-receipt result`);
|
||||
}
|
||||
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
|
||||
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
|
||||
const bound = bindReceipt(body, pr, [head]);
|
||||
if (!bound.ok) return refused(bound.code, bound.message);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const out = flag(rest, '--out');
|
||||
const dir = out ? path.resolve(out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${head.slice(0, 12)}`);
|
||||
const receiptPath = path.join(dir, 'ship-receipt.md');
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(receiptPath, renderReceiptBlock(bound.receipt));
|
||||
} catch (e) { return incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
const run = {
|
||||
schema_version: 1, skill: 'ship', status: 'complete', head, pr: bound.receipt.pr ?? (pr ? Number(pr) : null), base: bound.receipt.base, version: bound.receipt.version ?? null,
|
||||
queue_mode: bound.receipt.queue_mode ?? null, session_kind: bound.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
|
||||
receipt: receiptPath, receipt_source: source, branch: repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) ?? null : null, created_at: new Date().toISOString(),
|
||||
};
|
||||
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
process.stderr.write(bound.line + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function sinceDate(repo: string, since: string): string | undefined {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since.slice(0, 10);
|
||||
const local = git(['log', '-1', '--format=%cI', since]);
|
||||
@@ -214,6 +291,7 @@ function main(argv: string[]): number {
|
||||
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
if (cmd === 'write') return cmdWrite(rest, json);
|
||||
if (cmd === 'read') return cmdRead(rest, json);
|
||||
if (cmd === 'result') return cmdResult(rest, json);
|
||||
if (cmd === 'census') return cmdCensus(rest, json);
|
||||
process.stderr.write(`gstack-ship-receipt: unknown command ${cmd}\n${HELP}\n`);
|
||||
return EXIT.usage;
|
||||
|
||||
@@ -322,7 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
|
||||
# GSTACK_SESSION_KIND=spawned override must light this up too.
|
||||
[ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt first), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim
|
||||
@@ -648,13 +648,13 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
|
||||
case "$SKILL_NAME" in
|
||||
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
|
||||
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
|
||||
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr <n> --json\`) only once a PR exists." ;;
|
||||
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
|
||||
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
|
||||
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
|
||||
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
|
||||
esac
|
||||
_emit_block unattended-session <<EOI
|
||||
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
|
||||
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent; the only exception is the one named in your skill's record rule below. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
|
||||
EOI
|
||||
fi
|
||||
|
||||
|
||||
+60
-5
@@ -170,7 +170,10 @@ SHIP_RECEIPT: pr=42 head=<head12> …
|
||||
|
||||
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
|
||||
is not the PR's pushed head, CI at that exact head is not green, or the pregate
|
||||
result is not a current pass. Send the block as printed.
|
||||
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
|
||||
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
|
||||
head: /ship runs before every READY ([details](#ship-before-ready)). Send the
|
||||
block as printed.
|
||||
|
||||
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
|
||||
once the predecessor lands, while it is still open:
|
||||
@@ -304,13 +307,53 @@ are mirrored to origin as git notes on `refs/notes/gstack-trains` before any
|
||||
remote effect, so a lost machine loses nothing: `train status` on a clean
|
||||
state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests.
|
||||
|
||||
<a id="ship-before-ready"></a>
|
||||
**0. Lane: /ship before READY.** Every READY follows a /ship run on the branch
|
||||
at the head being READY'd, including a re-READY after a push (Garry,
|
||||
2026-10-25: "we should really be using the gstack ship skill before merging
|
||||
PRs"). /ship runs the tests, both reviews, the gate-integrity list and the docs
|
||||
audit, and ends by writing its receipt into the PR body
|
||||
(`gstack-ship-receipt write --pr <n> … --body <file>`, posted with
|
||||
`gstack-post pr-body`). Under `queue_mode: stamp-at-merge` it bumps no version
|
||||
and never merges or merges main into the branch; the train still stamps at the
|
||||
slot. The queue enforces this at every step from the PR body alone:
|
||||
|
||||
- `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid
|
||||
receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the
|
||||
head being READY'd (or its `pr` is another PR); the informational
|
||||
`SHIP_RECEIPT:` line still prints. There is no flag around it.
|
||||
- `train build` refuses a pin whose PR has no current receipt with the same
|
||||
codes, naming the PR, before anything is written, pushed or dispatched,
|
||||
`--dry-run` included.
|
||||
- `verify --train` refuses the slot with the same codes before printing
|
||||
`MERGE_CONDITION`.
|
||||
|
||||
**The stamp keeps the pin's receipt.** The slot's stamp commit changes only
|
||||
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
|
||||
on it: the receipt written for the pinned head stays the binding one. `verify
|
||||
--train` accepts a receipt whose `head` is either the slot's pinned head or
|
||||
the current head, and the re-READY after the stamp push (`ready` at the stamp
|
||||
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
|
||||
or (from slot 2 on) the pin merged with main plus stamp files on top of the
|
||||
merge; any other head is `QUEUE_RECEIPT_STALE`, including a merge of main with
|
||||
no stamp above it (a lane's own push under rule 14) and a merge whose pin side
|
||||
carries an edit made after /ship.
|
||||
|
||||
This is a process gate, not an authenticity check: the receipt is a block in
|
||||
the PR body, which the PR author writes, so the codes prove that /ship was run
|
||||
and recorded for this head, not that nobody typed the block by hand. Fork heads
|
||||
are already refused (`QUEUE_TRAIN_UNTRUSTED`). The `ship-run.json` that
|
||||
`gstack-ship-receipt result` writes under the lane's state root is a
|
||||
normalized copy of that same block bound to the head it was run at, not an
|
||||
authenticity proof either.
|
||||
|
||||
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
|
||||
explicit choice:
|
||||
|
||||
```bash
|
||||
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
|
||||
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
|
||||
```
|
||||
|
||||
Without a choice it prints the `SCREEN:` selection (with
|
||||
@@ -326,7 +369,17 @@ dispatches `gate_workflow` with `screen_pr`, `screen_merge_sha` (the
|
||||
the policy, so a repeat follows the same run and a broad screen never adopts
|
||||
a narrow one. The READY block gains `screen: …`, and the receipt is written
|
||||
locally and to the notes ref. `--dry-run` prints the dispatch without sending
|
||||
it.
|
||||
it. Before dispatching, `ready` fetches the merge ref and requires its second
|
||||
parent to be the PR head: a missing or lagging merge ref (GitHub stops
|
||||
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
|
||||
included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans
|
||||
no cases and reports success. A finished screen that planned no cases (no
|
||||
`paid-plan` artifact, which a superseded run never uploads, or a plan manifest
|
||||
with nothing planned) is never followed as a screen: `ready --screen`
|
||||
dispatches a fresh one and `train build` prints `SCREEN: #<n> run <id> planned
|
||||
no cases …; counted as not-run` and seats the slot unscreened. A plan the
|
||||
queue cannot read keeps the slot screened, with `SCREEN: #<n> <reason>;
|
||||
counted as screened, unverified`.
|
||||
|
||||
**2. Coordinator: build.** Name the pins exactly as READY reported them:
|
||||
|
||||
@@ -350,8 +403,10 @@ has waited `fill_hours` (`QUEUE_TRAIN_WAITING`); `--urgent` dispatches a
|
||||
one-PR train at once. The same pins on the same base return the live train
|
||||
instead of a second one; `train build --train <nonce>` resumes a train from
|
||||
its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
|
||||
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_NOT_READY` (no
|
||||
READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
|
||||
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
|
||||
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
|
||||
receipt for the pin; see [/ship before READY](#ship-before-ready)),
|
||||
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
|
||||
`QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after
|
||||
merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
|
||||
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
|
||||
|
||||
+77
-4
@@ -1604,8 +1604,9 @@ message lists.
|
||||
|
||||
**Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH`
|
||||
line), an unattended or spawned workflow starts only with a passing install
|
||||
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>.json`,
|
||||
named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a
|
||||
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>/<sha256
|
||||
of the project directory, or none>.json`, named as `GSTACK_INSTALL_VERDICT`)
|
||||
for this checkout, its current HEAD and a
|
||||
workflow at least as wide as the one starting: a verdict for `--for autoplan`
|
||||
does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a
|
||||
workflow that runs the repo's tests. `gstack-skill-start` printed this row and
|
||||
@@ -1613,8 +1614,12 @@ workflow that runs the repo's tests. `gstack-skill-start` printed this row and
|
||||
an unattended run whose Bun on PATH is below the supported minimum
|
||||
(`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host.
|
||||
|
||||
**Fix.** Run the Capy skill installer with `--for <workflow>` on this machine
|
||||
and launch with the `GSTACK_LAUNCH` line it prints.
|
||||
**Fix.** Run the Capy skill installer (`scripts/install.sh --for <workflow>`)
|
||||
from the project directory the workflow will run in, since the verdict is
|
||||
keyed by that directory (GSTA-85), and launch with the `GSTACK_LAUNCH` line it
|
||||
prints. A verdict file that exists but cannot be read (`the install verdict at
|
||||
<file> is unreadable or has an unsupported schema_version`) is repaired the
|
||||
same way, by rerunning the installer.
|
||||
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as
|
||||
`INSTALL_VERDICT: overridden`); record it as a deviation.
|
||||
|
||||
@@ -4021,6 +4026,74 @@ trains and other trains' merges are refused until a repair lands.
|
||||
<n>@<sha>`). The freeze clears when verify finds main's tree after that merge
|
||||
in a green record; `NEXT:` then builds a train of the PRs still unmerged.
|
||||
|
||||
<a id="queue-receipt-missing"></a>
|
||||
### `QUEUE_RECEIPT_MISSING`
|
||||
|
||||
**Meaning.** The PR body carries no valid ```gstack-ship-receipt``` block, so
|
||||
/ship never ran on this branch (or its receipt was lost when the body was
|
||||
rewritten). `gstack-ship-queue ready` refuses to READY it, `train build`
|
||||
refuses the pin by PR number before any write, push or dispatch (dry run
|
||||
included), and `verify --train` refuses the slot. The block still prints the
|
||||
informational `SHIP_RECEIPT: none in the PR body` (or `invalid`) line. There
|
||||
is no flag that skips this check (Garry, 2026-10-25: use the gstack ship skill
|
||||
before merging PRs; [ship-policy.md](ship-policy.md#ship-before-ready)).
|
||||
|
||||
**Fix.** Run /ship on the branch. Unattended, it ends by writing the receipt
|
||||
into the PR body (`gstack-ship-receipt write --pr <n> … --body <file>`, then
|
||||
`gstack-post pr-body`) when an open PR exists for the branch at HEAD;
|
||||
otherwise it leaves the receipt in `.gstack/tmp/ship-receipt.md` and the
|
||||
parent posts it ([unattended.md](unattended.md)). Then rerun
|
||||
`gstack-ship-queue ready --pr <n> --screen|--no-screen` and send the new READY
|
||||
block.
|
||||
|
||||
<a id="queue-receipt-stale"></a>
|
||||
### `QUEUE_RECEIPT_STALE`
|
||||
|
||||
**Meaning.** The PR body's ship receipt is valid but names another head than
|
||||
the one being READY'd or merged (a push after /ship, a receipt copied from
|
||||
another PR, or a `pr` field for another PR), so the receipt does not cover
|
||||
what would merge. The one accepted difference is a train slot's stamp: when
|
||||
the current head is the pinned head's stamp commit (same tree modulo stamp
|
||||
files, or the pin with main merged in at slot 2 and later), the receipt
|
||||
written for the pinned head is the binding one and `verify --train` accepts it.
|
||||
|
||||
**Fix.** Rerun /ship on the current head so the receipt names it, then rerun
|
||||
`gstack-ship-queue ready --pr <n> --screen|--no-screen`. A lane never stamps
|
||||
to fix this: the stamp comes only when the slot is called.
|
||||
|
||||
<a id="queue-screen-stale-ref"></a>
|
||||
### `QUEUE_SCREEN_STALE_REF`
|
||||
|
||||
**Meaning.** `gstack-ship-queue ready --screen` dispatches the screen against
|
||||
GitHub's `refs/pull/<n>/merge`, and that ref is missing or still merges an
|
||||
older head than the PR's pushed head. GitHub stops recomputing the merge ref
|
||||
while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
|
||||
counts), so a screen dispatched there checks out the old merge, sees itself
|
||||
superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431:
|
||||
`ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares
|
||||
the merge ref's second parent with the PR head before dispatching, and a
|
||||
finished screen that planned no cases is never adopted as a screen. A screen
|
||||
run uploads no train record, so the evidence is its plan: a superseded run
|
||||
skips `plan-slices` and uploads no `paid-plan` artifact, and a manifest with
|
||||
nothing planned counts the same. `ready --screen` dispatches a fresh one and
|
||||
`train build` prints `SCREEN: #<n> run <id> planned no cases (no paid-plan
|
||||
artifact; …)` or `… planned no cases (an empty plan manifest; …)`, each
|
||||
ending `; counted as not-run, the slot rides unscreened`. A plan it cannot
|
||||
read (`… paid-plan artifact has expired`, `… manifest.json is unreadable`, an
|
||||
unreadable run or artifact list, an unreadable repository slug, or a READY
|
||||
whose run id is not a number) keeps the slot screened and prints
|
||||
`SCREEN: #<n> <reason>; counted as screened, unverified`; `ready --screen`
|
||||
follows such a run. When `ready` cannot list the remote's refs or fetch the
|
||||
merge ref at all, it refuses with the same code and a `could not list …` or
|
||||
`could not fetch …` message whose NEXT line says to check the remote, not to
|
||||
resolve a conflict.
|
||||
|
||||
**Fix.** Resolve the conflict with the base branch (or wait for GitHub to
|
||||
recompute the merge ref), push, and run `gstack-ship-queue ready --pr <n>
|
||||
--screen` again. Under crew rule 14 a CHANGELOG-only conflict is left to the
|
||||
train, so a lane that cannot get a fresh merge ref READYs `--no-screen` only
|
||||
after its own focused paid cases passed (rule 6).
|
||||
|
||||
<a id="ready-screen-choice-required"></a>
|
||||
### `READY_SCREEN_CHOICE_REQUIRED`
|
||||
|
||||
|
||||
+40
-8
@@ -80,7 +80,36 @@ Not every workflow writes this line or a run directory today:
|
||||
record), not a run directory.
|
||||
- **A refused launch.** Any skill whose install check refuses it at startup
|
||||
prints `GSTACK_RESULT: skill=<name> status=refused run=-` and stops.
|
||||
- **Every other workflow skill** (`/ship`, `/review`, `/qa`, `/design-review`,
|
||||
- **`/ship`** ends with `gstack-ship-receipt result --pr <n>` (or `--body
|
||||
.gstack/tmp/ship-receipt.md` after the handoff section). It binds the
|
||||
receipt to the branch head, writes `<state root>/ship/runs/<utc>-<head12>/`
|
||||
(`ship-run.json` with `head`, `pr`, `base`, `version`, `queue_mode`,
|
||||
`session_kind`, `receipt` as the path of the copied `ship-receipt.md`, and
|
||||
`receipt_source`; it is a /ship record, not the `run` v1 manifest below, so
|
||||
read it directly: `gstack-artifact validate` does not apply) and prints
|
||||
`GSTACK_RESULT: skill=ship status=complete run=<dir>`. When gh cannot read
|
||||
the PR it prints `GSTACK_RESULT: skill=ship status=incomplete run=-` (exit 1). A receipt that is missing or names another head prints
|
||||
`GSTACK_RESULT: skill=ship status=refused run=-` with `QUEUE_RECEIPT_MISSING`
|
||||
or `QUEUE_RECEIPT_STALE` on stderr (exit 3); a run that stops before
|
||||
publication prints the same refused line itself. A parent's merge check is
|
||||
then mechanical: take `run=` from the last result line, read
|
||||
`ship-run.json`, and compare its `head` to the PR head. `gstack-ship-queue ready`,
|
||||
`train build` and `verify --train` make the same comparison from the PR body
|
||||
([ship-policy.md](ship-policy.md#ship-before-ready)), which is why an
|
||||
unattended /ship may update the body of a PR that already exists: when an
|
||||
open PR for the branch exists and HEAD is its pushed head, Step 18 takes the
|
||||
pr-body section's existing-PR path and posts the body (receipt first) and
|
||||
title through `gstack-post`. That is the one publication the unattended
|
||||
rules allow; it never runs `pr-create`, never pushes a commit, never runs
|
||||
`gh pr ready` (the existing-PR path's draft-state items concern an early PR
|
||||
an unattended run never opens), and a branch with no open PR, or whose PR
|
||||
head is not HEAD, takes the handoff section. `result --pr <n>` also refuses
|
||||
`QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head.
|
||||
When the run committed anything (a CHANGELOG entry, generated tests), the
|
||||
parent pushes those commits itself, then reruns the receipt write, the body
|
||||
post and `result` at the pushed head: a receipt for an unpushed head is
|
||||
`QUEUE_RECEIPT_STALE` to the queue.
|
||||
- **Every other workflow skill** (`/review`, `/qa`, `/design-review`,
|
||||
`/spec`, `/land-and-deploy` and the rest) gets the session rules above (no
|
||||
prompts, approvals stay pending, nothing published), but has no step that
|
||||
writes a run directory or prints the line, so a parent should not wait for
|
||||
@@ -88,14 +117,15 @@ Not every workflow writes this line or a run directory today:
|
||||
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
|
||||
list; it does not create one). `/cso` skips the shared startup, so these
|
||||
session rules do not reach it at all. Read each skill's own record instead:
|
||||
the review-log rows (`gstack-review-read`) for `/review`, the
|
||||
`qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When
|
||||
the run did not push (an unattended run must not), read the receipt file in
|
||||
the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
|
||||
the review-log rows (`gstack-review-read`) for `/review` and the
|
||||
`qa-report-*.md` file for `/qa`. For a `/ship` run from before the result
|
||||
line, read the receipt instead: when the run did not push (an unattended run
|
||||
must not), read the receipt file in the child's checkout with
|
||||
`gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
|
||||
use `gstack-ship-receipt read --pr <n> --json` only once a PR exists. In both
|
||||
cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in
|
||||
the child's checkout): `read` does not check that the receipt is current, so
|
||||
an older PR's receipt reads as valid.
|
||||
an older PR's receipt reads as valid; `result` does.
|
||||
|
||||
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
|
||||
`3` refused or needs a flag. Error lines end with a result code in parentheses,
|
||||
@@ -125,8 +155,10 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
|
||||
|
||||
## How a parent consumes a run
|
||||
|
||||
1. **Decide whether the run finished.** `gstack-artifact validate <dir>/run.json`
|
||||
is the deterministic completion check. It exits `0` and prints
|
||||
1. **Decide whether the run finished.** For an `/autoplan` run directory,
|
||||
`gstack-artifact validate <dir>/run.json`
|
||||
is the deterministic completion check (a `/ship` `run=` directory holds
|
||||
`ship-run.json`, read directly as described above). It exits `0` and prints
|
||||
`ARTIFACT_VALID: run=<id> status=<status>` only when the manifest parses and
|
||||
matches its schema, every required phase and each reviewer voice has a
|
||||
terminal outcome, every listed artifact exists inside the run directory with
|
||||
|
||||
@@ -152,8 +152,11 @@ export function directiveFor(kind: SessionKind): string {
|
||||
'SESSION_KIND=unattended — auto-choose the `(recommended)` option and append it to decisions.jsonl ' +
|
||||
'with kind "auto"; do not emit prose, do not BLOCK. A question with no recommendation, a consent, ' +
|
||||
'a destructive or irreversible option, or an approval gate is appended with kind "approval" and status ' +
|
||||
'"pending" — never chosen, never approved — and the skill ends at its next gate with ' +
|
||||
'GSTACK_RESULT status=gate_pending (Unattended session block).'
|
||||
'"pending" — never chosen, never approved — and the skill ends at its next gate. Only /autoplan and ' +
|
||||
'/eval-plan print `GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>` there, and /ship prints its ' +
|
||||
'own line at Step 22 (`gstack-ship-receipt result`); every other skill prints no GSTACK_RESULT line of its own ' +
|
||||
'(only a refused install check prints `status=refused run=-` for any skill), so do not invent one ' +
|
||||
'(Unattended session block; docs/unattended.md names each skill\'s record).'
|
||||
);
|
||||
case 'headless':
|
||||
// #2733 review (multi-specialist): a spawned-marked subagent under a
|
||||
|
||||
@@ -141,7 +141,11 @@ export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunP
|
||||
const file = findInstallVerdict(src, project, env);
|
||||
const v = readInstallVerdict(file);
|
||||
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
|
||||
if (!v) return problem(`no install verdict at ${file} for ${src}`);
|
||||
if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
|
||||
if (!v) {
|
||||
return { ...problem(`no install verdict at ${file} for ${src}`),
|
||||
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project ?? 'it'}; then launch with the GSTACK_LAUNCH line it prints` };
|
||||
}
|
||||
if (!v.ok) return problem(`the install verdict says: ${v.trailer}`);
|
||||
if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`);
|
||||
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
|
||||
|
||||
@@ -132,6 +132,22 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'a train stopped partway through publication, so main holds a prefix no gate covered on its own; ordinary trains are refused until a repair lands',
|
||||
fix: 'admit one repair PR at its exact head (train build --repair --prs <n>@<sha>); the freeze clears when verify finds main’s tree after that merge in a green record',
|
||||
},
|
||||
// CREW rule 22 (Garry, 2026-10-25): /ship runs before every READY; the queue refuses a PR whose body has no current receipt.
|
||||
QUEUE_RECEIPT_MISSING: {
|
||||
anchor: 'queue-receipt-missing',
|
||||
summary: 'the PR body carries no valid /ship receipt, so the PR was never shipped through gstack and cannot be READY’d, admitted to a train or merged',
|
||||
fix: 'run /ship on the branch (unattended it writes the receipt into an open PR at HEAD with gstack-ship-receipt write --pr <n> --body …, else leaves it in .gstack/tmp/ship-receipt.md for the parent to post), then gstack-ship-queue ready again; there is no flag to skip this',
|
||||
},
|
||||
QUEUE_RECEIPT_STALE: {
|
||||
anchor: 'queue-receipt-stale',
|
||||
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
|
||||
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
|
||||
},
|
||||
QUEUE_SCREEN_STALE_REF: {
|
||||
anchor: 'queue-screen-stale-ref',
|
||||
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
|
||||
fix: 'resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, then gstack-ship-queue ready --pr <n> --screen again; a lane never READYs on a screen that planned nothing',
|
||||
},
|
||||
READY_SCREEN_CHOICE_REQUIRED: {
|
||||
anchor: 'ready-screen-choice-required',
|
||||
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* full-gate check run. Dispatch itself goes through scripts/eval-bg.ts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { Exec, RunSummary } from '../scripts/eval-bg';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
|
||||
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string }
|
||||
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string }
|
||||
export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string }
|
||||
export interface CheckInfo { status: string; conclusion: string | null }
|
||||
|
||||
@@ -33,10 +34,10 @@ export function repoSlug(ctx: QueueContext): string | null {
|
||||
}
|
||||
|
||||
export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } {
|
||||
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName']);
|
||||
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body']);
|
||||
const p = json<Partial<PrInfo>>(r);
|
||||
if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` };
|
||||
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '' };
|
||||
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '' };
|
||||
}
|
||||
|
||||
const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle';
|
||||
@@ -56,18 +57,49 @@ export function asRun(summary: RunSummary): { id: number; url: string } {
|
||||
return { id: summary.databaseId, url: summary.url };
|
||||
}
|
||||
|
||||
/** One JSON artifact of a run (the first .json file it holds); null when it is missing or unreadable. */
|
||||
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string): unknown {
|
||||
/** One JSON artifact of a run (the named file, else the first .json file it holds); null when it is missing or unreadable. */
|
||||
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string, file?: string): unknown {
|
||||
const dir = path.join(scratch, `${run}-${name}`);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
if (!ctx.gh(['run', 'download', String(run), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
|
||||
const file = fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
return file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) : null;
|
||||
const pick = file ?? fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
return pick && fs.existsSync(path.join(dir, pick)) ? JSON.parse(fs.readFileSync(path.join(dir, pick), 'utf8')) : null;
|
||||
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished screen run that planned no cases is not a screen: when the merge
|
||||
* ref it was dispatched for was superseded, evals.yml skips plan-slices and
|
||||
* concludes success (GSTA-95 on #3431). A screen run uploads no train record,
|
||||
* so the evidence is the plan itself: no `paid-plan` artifact, or a manifest
|
||||
* with nothing planned. `hollow` carries the reason; `live` is a screen that
|
||||
* planned cases or is still running; `unknown` names what could not be read
|
||||
* (the caller decides, and says, how it counts).
|
||||
*/
|
||||
export type ScreenPlan = { kind: 'hollow'; reason: string } | { kind: 'live' } | { kind: 'unknown'; reason: string };
|
||||
export function hollowScreen(ctx: QueueContext, slug: string, run: number): ScreenPlan {
|
||||
if (!Number.isSafeInteger(run) || run <= 0) return { kind: 'unknown', reason: `screen run id ${String(run).slice(0, 40)} is not a run number` };
|
||||
const view = runView(ctx, slug, run);
|
||||
if (!view) return { kind: 'unknown', reason: `run ${run} is unreadable` };
|
||||
if (view.status !== 'completed') return { kind: 'live' };
|
||||
const list = json<{ artifacts?: Array<{ name?: string; expired?: boolean }> }>(ctx.gh(['api', `repos/${slug}/actions/runs/${run}/artifacts?name=${PLAN_ARTIFACT}&per_page=10`]));
|
||||
if (!Array.isArray(list?.artifacts)) return { kind: 'unknown', reason: `run ${run}'s artifact list is unreadable` };
|
||||
const plan = list.artifacts.find(a => a.name === PLAN_ARTIFACT);
|
||||
if (!plan) return { kind: 'hollow', reason: `run ${run} planned no cases (no ${PLAN_ARTIFACT} artifact; concluded ${view.conclusion ?? 'unknown'})` };
|
||||
if (plan.expired) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} artifact has expired` };
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-screen-'));
|
||||
let manifest: { entries?: Array<{ status?: unknown }> } | null;
|
||||
try { manifest = downloadJson(ctx, slug, run, PLAN_ARTIFACT, scratch, 'manifest.json') as typeof manifest; } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
||||
if (!Array.isArray(manifest?.entries)) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} manifest.json is unreadable` };
|
||||
const planned = manifest.entries.filter(e => e?.status === 'planned').length;
|
||||
return planned === 0 ? { kind: 'hollow', reason: `run ${run} planned no cases (an empty plan manifest; concluded ${view.conclusion ?? 'unknown'})` } : { kind: 'live' };
|
||||
}
|
||||
|
||||
/** The plan manifest every evals.yml run that planned anything uploads (plan-slices); a superseded screen never reaches that job. */
|
||||
const PLAN_ARTIFACT = 'paid-plan';
|
||||
|
||||
/** The named check run on a commit (the newest when several); null when absent or unreadable. */
|
||||
export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null {
|
||||
const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EXIT } from './headless-artifacts';
|
||||
import { ready, type QueueContext } from './ship-queue';
|
||||
import { classifyTrainHead } from './version-gate-train';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { execFor, hollowScreen, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
|
||||
@@ -85,10 +85,12 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string,
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const exec = execFor(env.ctx);
|
||||
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
|
||||
if (existing) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
|
||||
const plan = existing ? hollowScreen(env.ctx, slug, existing.databaseId) : null;
|
||||
const hollow = plan?.kind === 'hollow' ? plan.reason : null;
|
||||
if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
|
||||
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
|
||||
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}` };
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${hollow ? ` (not following ${hollow}: a screen that planned nothing is not a screen)` : ''}` };
|
||||
}
|
||||
|
||||
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
|
||||
@@ -113,8 +115,14 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
let choice: ScreenChoice = { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null };
|
||||
if (o.screen === 'screen') {
|
||||
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
const mergeSha = ls.ok ? ls.out.split(/\s/)[0] ?? '' : '';
|
||||
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_NOT_READY', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, `merge ${ctx.base} into the branch, push, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
if (!ls.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not list ${env.remote}'s refs: ${(ls.err || ls.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git ls-remote failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeSha = ls.out.split(/\s/)[0] ?? '';
|
||||
const again = `resolve the conflict with ${ctx.base} (or wait for GitHub to recompute refs/pull/${o.pr}/merge), push, then ${Q} ready --pr ${o.pr} --screen; a screen dispatched on a stale merge ref plans no cases`;
|
||||
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, again, 'none');
|
||||
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
if (!fetched.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not fetch refs/pull/${o.pr}/merge from ${env.remote}: ${(fetched.err || fetched.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git fetch failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeParent = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${mergeSha}^2`]).out;
|
||||
if (mergeParent !== head) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote}'s refs/pull/${o.pr}/merge (${mergeSha.slice(0, 12)}) merges ${mergeParent ? mergeParent.slice(0, 12) : 'no PR head'}, not the PR head ${head.slice(0, 12)}: GitHub has not recomputed it, which is what a conflicting PR looks like (CHANGELOG-only conflicts included)` }, lines, again, 'none');
|
||||
const d = await dispatchScreen(env, o, mergeSha, baseSha);
|
||||
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
|
||||
choice = d.choice;
|
||||
|
||||
@@ -17,7 +17,8 @@ import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, makeNonce } from '../scripts/eval-bg';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { checkRun, execFor, prInfo, repoSlug } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import {
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
@@ -132,7 +133,11 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
|
||||
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
|
||||
const info = prInfo(ctx, o.pr);
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
const shipped = bindReceipt(info.body, o.pr, [t.slots[i]!.head, head]);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [...lines, shipped.line], `#${o.pr}'s lane runs /ship and READYs again; then ${Q} train build --prs <pins>`, run);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`,
|
||||
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
|
||||
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
}
|
||||
|
||||
+14
-3
@@ -21,8 +21,9 @@ import type { ResultStatus } from './headless-artifacts';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
import { loadBasePolicy, type QueueContext } from './ship-queue';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
|
||||
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import { asRun, execFor, hollowScreen, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import {
|
||||
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
|
||||
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
|
||||
@@ -284,6 +285,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
|
||||
if (!f.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `git fetch of the PR heads failed: ${f.err.slice(0, 160)}` }, [], 'none', 'none');
|
||||
const slots: TrainSlot[] = [];
|
||||
const lines: string[] = [];
|
||||
let slug: string | null | undefined;
|
||||
for (const p of pins) {
|
||||
const info = prInfo(ctx, p.pr);
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, [], 'none', 'none');
|
||||
@@ -293,11 +296,18 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
|
||||
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
|
||||
}
|
||||
const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
|
||||
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
|
||||
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
|
||||
let screen = ready.screen;
|
||||
if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx);
|
||||
const plan = screen.choice === 'screen' && screen.run ? (slug ? hollowScreen(ctx, slug, screen.run) : { kind: 'unknown' as const, reason: 'the repository slug is unreadable' }) : null;
|
||||
if (plan?.kind === 'hollow') { lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
|
||||
else if (plan?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as screened, unverified`);
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen });
|
||||
}
|
||||
return { slots, lines: [] };
|
||||
return { slots, lines };
|
||||
}
|
||||
|
||||
/** Start a new train from --prs, or find the live train with the same pins and base. */
|
||||
@@ -315,6 +325,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
if ('exit' in admitted) return admitted;
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
|
||||
const lines = [
|
||||
...admitted.lines,
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
|
||||
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
|
||||
];
|
||||
|
||||
+38
-5
@@ -5,8 +5,9 @@
|
||||
* so every step ends in a line a coordinator can act on:
|
||||
*
|
||||
* ready READY #<pr> <head> STAMP_VERSION=<X|unstamped> plus head, base,
|
||||
* CI at that exact head, the PREGATE line and the gate state;
|
||||
* refused when the tree is not the pushed PR head.
|
||||
* CI at that exact head, the PREGATE line, the gate state and the
|
||||
* /ship receipt; refused when the tree is not the pushed PR head
|
||||
* or the PR body carries no receipt for it (QUEUE_RECEIPT_*).
|
||||
* stamp preflight (gated head equals HEAD modulo stamps, base, policy,
|
||||
* gate record under full_e2e), restamp, commit, tree receipt,
|
||||
* each stage journaled; QUEUE_STAMP: <pr> <version> head= gated=
|
||||
@@ -23,7 +24,7 @@ import * as path from 'node:path';
|
||||
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { extractReceipt, receiptSummaryLine } from './ship-receipt';
|
||||
import { bindReceipt, extractReceipt } from './ship-receipt';
|
||||
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
|
||||
import {
|
||||
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
|
||||
@@ -59,6 +60,37 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur
|
||||
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads a READY accepts a /ship receipt for: HEAD, plus the receipt's own
|
||||
* head when HEAD is that commit with only stamp files changed on top (the
|
||||
* slot's stamp commit, docs/ship-policy.md#ship-before-ready), or, from slot
|
||||
* 2 on, the stamp over a merge of the base into it: /ship ran on the pin, the
|
||||
* stamp changes nothing it checked, and verify --train binds the same pair
|
||||
* against the train's own prefix. A merge with no stamp on top is a lane's
|
||||
* own push (rule 14), so it reruns /ship like any other head. Any other
|
||||
* receipt head stays QUEUE_RECEIPT_STALE.
|
||||
*/
|
||||
function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: string, body: string): string[] {
|
||||
const ex = extractReceipt(body);
|
||||
const r = ex.receipt;
|
||||
if (!r || ex.errors.length || typeof r.head !== 'string' || !/^[0-9a-f]{12,64}$/i.test(r.head) || head.startsWith(r.head)) return [head];
|
||||
const root = ctx.repoRoot;
|
||||
const full = git(root, ['rev-parse', '--verify', '--quiet', `${r.head}^{commit}`]);
|
||||
if (!full.ok || !git(root, ['merge-base', '--is-ancestor', full.out, head]).ok) return [head];
|
||||
const stampOnly = (from: string, to: string) => { const t = from === to ? null : treeReceipt(ctx, policy, from, to); return !t || (!('message' in t) && t.tree === 'same-modulo-stamps'); };
|
||||
if (stampOnly(full.out, head)) return [full.out, head];
|
||||
const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out;
|
||||
const onBase = (sha: string) => !!sha && git(root, ['merge-base', '--is-ancestor', sha, baseSha]).ok;
|
||||
const merge = git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out;
|
||||
const stamp = merge && merge !== head ? treeReceipt(ctx, policy, merge, head) : null;
|
||||
if (!stamp || 'message' in stamp || stamp.tree !== 'same-modulo-stamps' || stamp.files.length === 0) return [head];
|
||||
const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1);
|
||||
if (parents.length !== 2) return [head];
|
||||
const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : '';
|
||||
if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head];
|
||||
return [head];
|
||||
}
|
||||
|
||||
const CI_PAGE = 100;
|
||||
const CI_MAX_PAGES = 10;
|
||||
|
||||
@@ -141,7 +173,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
|
||||
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
|
||||
}
|
||||
const receipt = extractReceipt(info.body ?? '');
|
||||
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
|
||||
const attempt = currentAttempt(ctx.repoRoot, o.pr);
|
||||
const lines = [
|
||||
`READY #${o.pr} ${head} STAMP_VERSION=${version}`,
|
||||
@@ -152,13 +184,14 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
`ci: ${ci.line}`,
|
||||
pregateLine,
|
||||
gateLine(policy, attempt),
|
||||
receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`,
|
||||
receipt.line,
|
||||
];
|
||||
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
|
||||
if (gate) lines.push(gate.line);
|
||||
if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run);
|
||||
if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run);
|
||||
if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run);
|
||||
if (!receipt.ok) return fail({ code: receipt.code, message: receipt.message }, lines, run);
|
||||
return { ok: true, lines, run };
|
||||
}
|
||||
|
||||
|
||||
+37
-1
@@ -49,6 +49,39 @@ export function extractReceipt(body: string): { found: boolean; receipt?: ShipRe
|
||||
}
|
||||
}
|
||||
|
||||
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
|
||||
export type ReceiptBinding =
|
||||
| { ok: true; receipt: ShipReceipt; head: string; line: string }
|
||||
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
|
||||
|
||||
/**
|
||||
* The /ship receipt a PR body must carry before READY, train build or a train
|
||||
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
|
||||
* whose `head` is one of the accepted heads. `accepted` is the head being
|
||||
* READY'd, or the slot's pinned head beside the current head when the current
|
||||
* head is the pin's stamp commit (the stamp changes only stamp files, so the
|
||||
* receipt written for the pin stays the binding one). `line` is the
|
||||
* informational `SHIP_RECEIPT:` line the READY block prints either way.
|
||||
*/
|
||||
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
|
||||
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
|
||||
const ex = extractReceipt(body);
|
||||
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
|
||||
if (!ex.receipt || ex.errors.length) {
|
||||
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
|
||||
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
|
||||
}
|
||||
const r = ex.receipt;
|
||||
const line = receiptSummaryLine(r);
|
||||
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
|
||||
if (!head) {
|
||||
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
|
||||
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 };
|
||||
return { ok: true, receipt: r, head, line };
|
||||
}
|
||||
|
||||
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
|
||||
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
|
||||
const block = renderReceiptBlock(receipt);
|
||||
@@ -70,6 +103,9 @@ export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt[
|
||||
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
|
||||
}
|
||||
|
||||
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
|
||||
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
|
||||
|
||||
/** The first lines of the completion message to the coordinator: identifiers only. */
|
||||
export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
|
||||
@@ -80,7 +116,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
|
||||
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
|
||||
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
||||
return `SHIP_RECEIPT: ${parts.join(' ')}`;
|
||||
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.235",
|
||||
"version": "1.91.236",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+24
-12
@@ -1154,7 +1154,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1172,13 +1174,12 @@ JSON validation, storage and sync and takes **no path argument**.
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1194,10 +1195,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1206,6 +1204,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
+24
-12
@@ -624,7 +624,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
{{SECTION:pr-body}}
|
||||
|
||||
@@ -640,13 +642,12 @@ JSON validation, storage and sync and takes **no path argument**.
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -662,10 +663,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -674,6 +672,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
@@ -20,7 +20,8 @@ automation lane, an unattended parent said so). Nothing here publishes.
|
||||
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
|
||||
`issues:` its lines, unpromoted candidates and lookup failures.
|
||||
Full thread ids (`jam_…`) cross-thread.
|
||||
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
|
||||
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied, then
|
||||
Step 22 with `--body .gstack/tmp/ship-receipt.md`.
|
||||
|
||||
### Multi-lane runs
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ automation lane, an unattended parent said so). Nothing here publishes.
|
||||
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
|
||||
`issues:` its lines, unpromoted candidates and lookup failures.
|
||||
Full thread ids (`jam_…`) cross-thread.
|
||||
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
|
||||
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied, then
|
||||
Step 22 with `--body .gstack/tmp/ship-receipt.md`.
|
||||
|
||||
### Multi-lane runs
|
||||
|
||||
|
||||
@@ -206,7 +206,10 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
|
||||
|
||||
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
|
||||
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
|
||||
Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad
|
||||
Its `SHIP_RECEIPT:` line opens the completion message. The posted receipt is what
|
||||
`gstack-ship-queue ready`, `train build` and `verify --train` require (`QUEUE_RECEIPT_MISSING`,
|
||||
`QUEUE_RECEIPT_STALE`, no flag around them), so /ship runs before every READY and after any
|
||||
push. `RECEIPT_INVALID` names the bad
|
||||
field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add
|
||||
the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun
|
||||
`gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs
|
||||
|
||||
@@ -189,7 +189,10 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
|
||||
|
||||
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
|
||||
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
|
||||
Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad
|
||||
Its `SHIP_RECEIPT:` line opens the completion message. The posted receipt is what
|
||||
`gstack-ship-queue ready`, `train build` and `verify --train` require (`QUEUE_RECEIPT_MISSING`,
|
||||
`QUEUE_RECEIPT_STALE`, no flag around them), so /ship runs before every READY and after any
|
||||
push. `RECEIPT_INVALID` names the bad
|
||||
field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add
|
||||
the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun
|
||||
`gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isErrorResponse, directiveFor } from '../hosts/claude/hooks/auq-error-fallback-hook.ts';
|
||||
import { SPAWNED_ESCAPE_SENTENCE } from '../hosts/claude/hooks/spawned-directive.ts';
|
||||
@@ -85,6 +86,18 @@ describe('directiveFor — per-session-kind instruction', () => {
|
||||
expect(d).not.toMatch(/BLOCKED/);
|
||||
});
|
||||
|
||||
test('unattended directive names the same GSTACK_RESULT writers as docs/unattended.md and gstack-skill-start (GSTA-91)', () => {
|
||||
const d = directiveFor('unattended');
|
||||
expect(d).toMatch(/Only \/autoplan and \/eval-plan print `GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>`/);
|
||||
expect(d).toMatch(/\/ship prints its own line at Step 22/);
|
||||
expect(d).toMatch(/every other skill prints no GSTACK_RESULT line/);
|
||||
const doc = readFileSync(path.join(import.meta.dir, '..', 'docs', 'unattended.md'), 'utf8');
|
||||
expect(doc).toContain('**`/autoplan`**');
|
||||
expect(doc).toContain('**`/ship`**');
|
||||
expect(doc).toMatch(/`\/eval-plan` asks for a `gate_pending` line/);
|
||||
expect(doc).toMatch(/\*\*Every other workflow skill\*\*[\s\S]*prints the line/);
|
||||
});
|
||||
|
||||
test('spawned directive carries a self-contained destructive carve-out (#2733 review)', () => {
|
||||
// The "Spawned session block" it defers to exists only when a gstack
|
||||
// preamble ran; an AUQ error outside a skill still needs the exception.
|
||||
|
||||
+24
-12
@@ -1149,7 +1149,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1167,13 +1169,12 @@ JSON validation, storage and sync and takes **no path argument**.
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1189,10 +1190,7 @@ GSTACK_STATE_ROOT=$(~/.gemini/antigravity-cli/skills/gstack/bin/gstack-paths --g
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$(~/.gemini/antigravity-cli/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1201,6 +1199,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
+24
-12
@@ -1154,7 +1154,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1172,13 +1174,12 @@ JSON validation, storage and sync and takes **no path argument**.
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1194,10 +1195,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1206,6 +1204,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
+25
-12
@@ -1177,7 +1177,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1196,13 +1198,12 @@ $GSTACK_ROOT/bin/gstack-review-log '{"skill":"ship","timestamp":"'"$(date -u +%Y
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1219,10 +1220,7 @@ GSTACK_STATE_ROOT=$($GSTACK_ROOT/bin/gstack-paths --get GSTACK_STATE_ROOT); : "$
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$($GSTACK_ROOT/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1231,6 +1229,21 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
$GSTACK_ROOT/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
+25
-12
@@ -1158,7 +1158,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1177,13 +1179,12 @@ $GSTACK_ROOT/bin/gstack-review-log '{"skill":"ship","timestamp":"'"$(date -u +%Y
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1200,10 +1201,7 @@ GSTACK_STATE_ROOT=$($GSTACK_ROOT/bin/gstack-paths --get GSTACK_STATE_ROOT); : "$
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$($GSTACK_ROOT/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1212,6 +1210,21 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
|
||||
$GSTACK_ROOT/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
+2
-1
@@ -118,7 +118,8 @@
|
||||
"## Step 17: Push",
|
||||
"## Step 18: Prepare publication metadata",
|
||||
"## Step 20: Persist ship metrics",
|
||||
"## Step 21: Plan-tune discoverability nudge (first-successful-ship only)"
|
||||
"## Step 21: Plan-tune discoverability nudge (first-successful-ship only)",
|
||||
"## Step 22: Terminal line (every run)"
|
||||
],
|
||||
"sections/adversarial.md": [
|
||||
"## Step 11: Adversarial review (always-on)"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { applyStampPatch } from '../lib/ship-queue-gate';
|
||||
import type { TreeReceipt } from '../lib/tree-receipt';
|
||||
import { BASE_POLICY, BIN, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone, ghQueueShim } from './helpers/ship-queue-fixture';
|
||||
import { shippedBody } from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (policy?: Record<string, unknown>, prefix?: string) => { const r = makeFixtureRepo({ ...(policy ? { policy } : {}), ...(prefix ? { prefix } : {}) }); repos.push(r); return r; };
|
||||
@@ -258,7 +259,7 @@ describe('ready', () => {
|
||||
const repo = fixture();
|
||||
const head = makePr(repo, 'pr-a');
|
||||
const pregate = pregateFor(repo, head);
|
||||
const shim = (prHead: string, checks: unknown) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body: 'no receipt' } }, checks: { [head]: checks } });
|
||||
const shim = (prHead: string, checks: unknown, body = shippedBody('1', head)) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body } }, checks: { [head]: checks } });
|
||||
const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(head, green));
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const lines = ok.out.split('\n');
|
||||
@@ -266,8 +267,20 @@ describe('ready', () => {
|
||||
expect(lines.slice(1, 9)).toEqual([
|
||||
'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped', `base: origin/main@${repo.git('rev-parse', 'origin/main')}`,
|
||||
`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=1)`, expect.stringMatching(/^PREGATE: pass tree=[0-9a-f]{12} head=[0-9a-f]{12} checks=1$/),
|
||||
'gate: command=none full_e2e=no gate-ahead=none', 'SHIP_RECEIPT: none in the PR body',
|
||||
'gate: command=none full_e2e=no gate-ahead=none', `SHIP_RECEIPT: pr=1 head=${head.slice(0, 12)} version=- base=main`,
|
||||
]);
|
||||
const unshipped = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(head, green, 'no receipt'));
|
||||
expect(unshipped.status).toBe(EXIT.refused);
|
||||
expect(unshipped.out).toContain('SHIP_RECEIPT: none in the PR body');
|
||||
expect(unshipped.err).toMatch(/PR #1's body carries no ship receipt; fix: run \/ship on the branch .*\(QUEUE_RECEIPT_MISSING\)/);
|
||||
const broken = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(head, green, '```gstack-ship-receipt\n{"schema_version":1}\n```\n'));
|
||||
expect(broken.status).toBe(EXIT.refused);
|
||||
expect(broken.out).toContain('SHIP_RECEIPT: invalid in the PR body');
|
||||
expect(broken.err).toMatch(/ship receipt is invalid \(.*head.*\).*\(QUEUE_RECEIPT_MISSING\)/);
|
||||
const stale = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim(head, green, shippedBody('1', 'e'.repeat(40))));
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.out).toContain(`SHIP_RECEIPT: pr=1 head=eeeeeeeeeeee version=- base=main`);
|
||||
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for eeeeeeeeeeee, not the head ${head.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
|
||||
const unpushed = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], shim('f'.repeat(40), green));
|
||||
expect(unpushed.status).toBe(EXIT.refused);
|
||||
expect(unpushed.err).toMatch(/is not PR #1's pushed head ffffffffffff.*\(QUEUE_NOT_READY\)/);
|
||||
@@ -276,12 +289,70 @@ describe('ready', () => {
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'more\n');
|
||||
repo.git('commit', '-qam', 'more');
|
||||
const moved = repo.git('rev-parse', 'HEAD');
|
||||
const stale = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: '' } }, checks: { [moved]: green } }));
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
const stalePregate = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } }));
|
||||
expect(stalePregate.status).toBe(EXIT.refused);
|
||||
expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n');
|
||||
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
|
||||
}, 180_000);
|
||||
|
||||
// Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then;
|
||||
// fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt;
|
||||
// why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only)
|
||||
test('ready at the stamp commit accepts the receipt written for the pinned head; a real edit on top of the pin, a bare merge of main, or an edit on the pin side under the slot-2 stamp is stale', () => {
|
||||
const repo = fixture();
|
||||
const pin = makePr(repo, 'pr-s');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
const shim = (prHead: string, body: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body } }, checks: { [prHead]: green } });
|
||||
const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped, shippedBody('1', pin)));
|
||||
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
|
||||
expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.4.0`);
|
||||
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${pin.slice(0, 12)} version=- base=main`);
|
||||
repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves');
|
||||
fs.writeFileSync(path.join(repo.work, 'other.txt'), 'another PR landed\n');
|
||||
repo.git('add', 'other.txt');
|
||||
repo.git('commit', '-qm', 'other PR');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', 'pr-s');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.5.0 stamp');
|
||||
const slot2 = repo.git('rev-parse', 'HEAD');
|
||||
const merged = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, slot2)], shim(slot2, shippedBody('1', pin)));
|
||||
expect([merged.status, merged.err]).toEqual([EXIT.ok, '']);
|
||||
expect(merged.out).toContain(`SHIP_RECEIPT: pr=1 head=${pin.slice(0, 12)} version=- base=main`);
|
||||
const staleRe = (h: string) => new RegExp(`PR #1's ship receipt is for ${pin.slice(0, 12)}, not the head ${h.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`);
|
||||
const bareMerge = repo.git('rev-parse', 'HEAD^1');
|
||||
repo.git('reset', '-q', '--hard', bareMerge);
|
||||
const bare = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, bareMerge)], shim(bareMerge, shippedBody('1', pin)));
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.err).toMatch(staleRe(bareMerge));
|
||||
repo.git('commit', '-q', '--allow-empty', '-m', 'empty commit is not a stamp');
|
||||
const emptyOnMerge = repo.git('rev-parse', 'HEAD');
|
||||
const empty = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, emptyOnMerge)], shim(emptyOnMerge, shippedBody('1', pin)));
|
||||
expect(empty.status).toBe(EXIT.refused);
|
||||
expect(empty.err).toMatch(staleRe(emptyOnMerge));
|
||||
repo.git('reset', '-q', '--hard', pin);
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change before the merge\n');
|
||||
repo.git('commit', '-qam', 'edit on the pin side');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.5.0 stamp');
|
||||
const editedPin = repo.git('rev-parse', 'HEAD');
|
||||
const pinEdit = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, editedPin)], shim(editedPin, shippedBody('1', pin)));
|
||||
expect(pinEdit.status).toBe(EXIT.refused);
|
||||
expect(pinEdit.err).toMatch(staleRe(editedPin));
|
||||
repo.git('reset', '-q', '--hard', stamped);
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change after /ship\n');
|
||||
repo.git('commit', '-qam', 'more');
|
||||
const edited = repo.git('rev-parse', 'HEAD');
|
||||
const stale = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, edited)], shim(edited, shippedBody('1', pin)));
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for ${pin.slice(0, 12)}, not the head ${edited.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('receipt digest', () => {
|
||||
@@ -330,7 +401,7 @@ describe('full_e2e gate evidence (review #3235 P1-1)', () => {
|
||||
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: '' } }, checks: { [head]: { check_runs: [{ status: 'completed', conclusion: 'success' }] } } });
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: { check_runs: [{ status: 'completed', conclusion: 'success' }] } } });
|
||||
const missing = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.out).toContain(`gate-evidence: missing for ${head.slice(0, 12)}`);
|
||||
@@ -352,7 +423,7 @@ describe('ready reads all of CI at the head (review #3235 P1-5)', () => {
|
||||
const tree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: '' } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}) });
|
||||
const gh = ghQueueShim(repo, { prs: { '1': { headRefOid: head, body: shippedBody('1', head) } }, checks: { [head]: checks }, ...(statuses ? { statuses: { [head]: statuses } } : {}) });
|
||||
return { r: queue(repo, ['ready', '--pr', '1', '--pregate', pregate], gh), at: head.slice(0, 12) };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { bindReceipt, computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
@@ -147,6 +148,174 @@ describe('gstack-ship-receipt read', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
test('binds the receipt to HEAD, writes the run record with the head and the receipt path, and ends with GSTACK_RESULT: skill=ship; a missing or stale receipt is status=refused run=-', () => {
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const state = path.join(repo.root, 'state');
|
||||
const env = { ...ghShim(repo, {
|
||||
5: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) },
|
||||
6: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) },
|
||||
7: { headRefOid: head, body: 'No receipt here.\n' },
|
||||
9: { headRefOid: 'd'.repeat(40), body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) },
|
||||
10: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 10, head, base: 'main' } as any) },
|
||||
}), GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' };
|
||||
const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const last = ok.out.trim().split('\n').at(-1)!;
|
||||
const m = /^GSTACK_RESULT: skill=ship status=complete run=(.+)$/.exec(last);
|
||||
expect(m).not.toBeNull();
|
||||
const dir = m![1]!;
|
||||
expect(dir.startsWith(path.join(state, 'ship', 'runs'))).toBe(true);
|
||||
expect(dir.endsWith(`-${head.slice(0, 12)}`)).toBe(true);
|
||||
const run = JSON.parse(fs.readFileSync(path.join(dir, 'ship-run.json'), 'utf8'));
|
||||
expect(fs.existsSync(path.join(dir, 'run.json'))).toBe(false);
|
||||
expect(run).toMatchObject({ schema_version: 1, skill: 'ship', status: 'complete', head, pr: 5, base: 'main', queue_mode: 'stamp-at-merge', receipt: path.join(dir, 'ship-receipt.md'), receipt_source: 'PR #5' });
|
||||
expect(extractReceipt(fs.readFileSync(run.receipt, 'utf8')).receipt).toMatchObject({ pr: 5, head });
|
||||
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main`);
|
||||
|
||||
const stale = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '6'], env);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.out.trim()).toBe('GSTACK_RESULT: skill=ship status=refused run=-');
|
||||
expect(stale.err).toMatch(new RegExp(`PR #6's ship receipt is for bbbbbbbbbbbb, not the head ${head.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#queue-receipt-stale\\)`));
|
||||
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '7', '--json'], env);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(JSON.parse(missing.out)).toMatchObject({ skill: 'ship', status: 'refused', run: '-', code: 'QUEUE_RECEIPT_MISSING' });
|
||||
const unpushed = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
|
||||
expect([unpushed.status, unpushed.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
|
||||
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #9's pushed head dddddddddddd; push it, then rerun gstack-ship-receipt result`);
|
||||
const noOid = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '10'], env);
|
||||
expect([noOid.status, noOid.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
|
||||
expect(noOid.err).toContain('gh pr view returned no headRefOid for PR #10');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '--repo'], env).status).toBe(EXIT.usage);
|
||||
const bothFlags = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5', '--body', path.join(repo.work, 'any.md')], env);
|
||||
expect([bothFlags.status, bothFlags.out]).toEqual([EXIT.usage, '']);
|
||||
expect(bothFlags.err).toContain('not both');
|
||||
const dirBody = runBin(repo, 'gstack-ship-receipt', ['result', '--body', repo.work], env);
|
||||
expect([dirBody.status, dirBody.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
|
||||
expect(dirBody.err).toContain(`cannot read ${repo.work}`);
|
||||
|
||||
const bodyFile = path.join(repo.work, '.gstack/tmp/ship-receipt.md');
|
||||
fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
|
||||
fs.writeFileSync(bodyFile, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
|
||||
const handoff = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(repo.root, 'run-out')], env);
|
||||
expect(handoff.status).toBe(EXIT.ok);
|
||||
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'run-out')}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
|
||||
const relative = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', 'rel-out'], env);
|
||||
expect(relative.status).toBe(EXIT.ok);
|
||||
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.work, 'rel-out')}`);
|
||||
expect(fs.existsSync(path.join(repo.work, 'rel-out', 'ship-run.json'))).toBe(true);
|
||||
const blocker = path.join(repo.root, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'file\n');
|
||||
const unwritable = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(blocker, 'sub')], env);
|
||||
expect([unwritable.status, unwritable.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
|
||||
expect(unwritable.err).toContain(`cannot write the run record under ${path.join(blocker, 'sub')}`);
|
||||
expect(fs.readdirSync(path.join(state, 'ship', 'runs')).length).toBe(1);
|
||||
});
|
||||
|
||||
// Value: protects=a --body handoff refusal names "the receipt", never "PR #-", and a gh failure is EXIT.fail, not a refusal;
|
||||
// fails_when=the PR #-'s rewrite stops matching or gh errors are mapped to status=refused; why_new=the result test covers
|
||||
// only the --pr refusals, an absent file and a bound handoff; seam=none; no_claim=real gh (the shim stands in; unit only)
|
||||
test('a --body file with no block or a stale head is refused in the receipt\'s own words, and a gh failure is a plain failure, not a refusal', () => {
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const env = { ...ghShim(repo, {}), GSTACK_STATE_ROOT: path.join(repo.root, 'state') };
|
||||
const dir = path.join(repo.work, '.gstack/tmp');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const noBlock = path.join(dir, 'no-block.md');
|
||||
fs.writeFileSync(noBlock, 'Handoff prose without a receipt.\n');
|
||||
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--body', noBlock], env);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.out.trim()).toBe('GSTACK_RESULT: skill=ship status=refused run=-');
|
||||
expect(missing.err).toContain("the receipt's body carries no ship receipt");
|
||||
expect(missing.err).not.toContain('PR #-');
|
||||
const staleFile = path.join(dir, 'stale.md');
|
||||
fs.writeFileSync(staleFile, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
|
||||
const stale = runBin(repo, 'gstack-ship-receipt', ['result', '--body', staleFile], env);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toContain(`the receipt's ship receipt is for cccccccccccc, not the head ${head.slice(0, 12)} being merged`);
|
||||
const invalidFile = path.join(dir, 'invalid.md');
|
||||
fs.writeFileSync(invalidFile, '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
|
||||
const invalid = runBin(repo, 'gstack-ship-receipt', ['result', '--body', invalidFile], env);
|
||||
expect([invalid.status, invalid.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
|
||||
expect(invalid.err).toMatch(/the receipt's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING/);
|
||||
const otherPrEnv = { ...env, ...ghShim(repo, { 8: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) };
|
||||
const otherPr = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '8'], otherPrEnv);
|
||||
expect([otherPr.status, otherPr.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
|
||||
expect(otherPr.err).toContain("PR #8's ship receipt names pr=99, not this PR");
|
||||
const pinned = path.join(dir, 'pinned.md');
|
||||
fs.writeFileSync(pinned, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
|
||||
const override = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'c'.repeat(40), '--out', path.join(repo.root, 'pinned-out')], env);
|
||||
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'pinned-out')}`]);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
|
||||
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
|
||||
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
|
||||
expect(badHead.err).toContain('--head must be a full hex commit id');
|
||||
const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', head.slice(0, 12)], env);
|
||||
expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']);
|
||||
expect(shortHead.err).toContain('--head must be a full hex commit id');
|
||||
const upper = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'C'.repeat(40), '--out', path.join(repo.root, 'upper-out')], env);
|
||||
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'upper-out')}`]);
|
||||
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
|
||||
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
|
||||
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
|
||||
fs.rmSync(noGit, { recursive: true, force: true });
|
||||
const ghDown = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
|
||||
expect(ghDown.status).toBe(EXIT.fail);
|
||||
expect(ghDown.err).toContain('gh pr view failed');
|
||||
expect(ghDown.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
|
||||
expect(JSON.parse(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9', '--json'], env).out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
|
||||
expect(fs.existsSync(path.join(repo.root, 'state', 'ship', 'runs'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindReceipt head matching (rule 22)', () => {
|
||||
// Value: protects=a receipt binds only to a full or 12+ character prefix of an accepted head, and names the PR it was written for;
|
||||
// fails_when=the 12-character floor is dropped (head "a" would bind any head) or the pr check is removed;
|
||||
// why_new=the queue, train and result tests bind full 40-character heads only; seam=none; no_claim=gh and CLI wiring (pure function)
|
||||
test('binds a full head or a 12+ character prefix, refuses shorter prefixes, other heads and another PR\'s receipt', () => {
|
||||
const head = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
|
||||
const other = 'f'.repeat(40);
|
||||
const body = (h: string, pr?: number) => placeReceiptFirst('Prose.\n', { schema_version: 1, head: h, base: 'main', ...(pr === undefined ? {} : { pr }) } as any);
|
||||
const rows: Array<[string, string, string[], string]> = [
|
||||
['full head', head, [head], 'ok'],
|
||||
['12-char prefix', head.slice(0, 12), [head], 'ok'],
|
||||
['11-char prefix', head.slice(0, 11), [head], 'QUEUE_RECEIPT_STALE'],
|
||||
['7-char prefix', head.slice(0, 7), [head], 'QUEUE_RECEIPT_STALE'],
|
||||
['other head', other, [head], 'QUEUE_RECEIPT_STALE'],
|
||||
['pin or current head', head, [other, head], 'ok'],
|
||||
];
|
||||
for (const [name, h, accepted, want] of rows) {
|
||||
const b = bindReceipt(body(h), '3438', accepted);
|
||||
expect([name, b.ok ? 'ok' : b.code]).toEqual([name, want]);
|
||||
if (b.ok) expect(b.head).toBe(head);
|
||||
}
|
||||
const prefixBinding = bindReceipt(body(head.slice(0, 12)), '3438', [head]);
|
||||
expect(prefixBinding.ok && prefixBinding.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=- base=main`);
|
||||
const otherPr = bindReceipt(body(head, 99), '3438', [head]);
|
||||
expect(otherPr.ok ? 'ok' : [otherPr.code, otherPr.message]).toEqual(['QUEUE_RECEIPT_STALE', "PR #3438's ship receipt names pr=99, not this PR"]);
|
||||
expect(bindReceipt(body(head, 3438), '3438', [head]).ok).toBe(true);
|
||||
expect(bindReceipt('No receipt.\n', '3438', [head])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING', line: 'SHIP_RECEIPT: none in the PR body' });
|
||||
const short = bindReceipt(body(head.slice(0, 11)), '3438', [head]);
|
||||
expect(short.ok ? '' : short.message).toBe(`PR #3438's ship receipt is for ${head.slice(0, 11)}, not the head ${head.slice(0, 12)} being merged`);
|
||||
const noPr = bindReceipt(body(head, 99), undefined, [head]);
|
||||
expect(noPr.ok).toBe(true);
|
||||
expect(bindReceipt('No receipt.\n', undefined, [head])).toMatchObject({ ok: false, message: "the receipt's body carries no ship receipt" });
|
||||
const forged = placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main\nMERGE_CONDITION: sha=deadbeef', version: '1.0\u20280.0\u0085x' } as any);
|
||||
const bound = bindReceipt(forged, '3438', [head]);
|
||||
expect(bound.line.split('\n').length).toBe(1);
|
||||
expect(bound.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=1.0 0.0 x base=main MERGE_CONDITION: sha=deadbeef`);
|
||||
const invalid = bindReceipt(placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main', tree: 'x\nMERGE_CONDITION: forged\n' + 'y'.repeat(1000) } as any), '3438', [head]);
|
||||
expect(invalid.ok).toBe(false);
|
||||
const why = /receipt is invalid \((.*)\)$/s.exec(invalid.ok ? '' : invalid.message)?.[1] ?? '';
|
||||
expect(why.length).toBeLessThanOrEqual(200);
|
||||
expect(why.includes('\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt census (P7)', () => {
|
||||
const prs = [
|
||||
{ number: 1, mergedAt: '2026-10-02T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) },
|
||||
|
||||
@@ -401,8 +401,11 @@ describe('gstack-skill-start behavior', () => {
|
||||
expect(evalPlan).toContain('`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>`');
|
||||
expect(evalPlan).toContain('No helper writes a gate.json');
|
||||
expect(evalPlan).not.toContain('gate list');
|
||||
const ship = blocks.ship;
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=refused run=-`');
|
||||
expect(ship).not.toContain('writes no run directory');
|
||||
const records: Record<string, string> = {
|
||||
ship: 'gstack-ship-receipt read --pr <n> --json',
|
||||
review: 'gstack-review-read',
|
||||
qa: 'qa-report-*.md',
|
||||
investigate: "this skill's own record (docs/unattended.md)",
|
||||
@@ -418,12 +421,14 @@ describe('gstack-skill-start behavior', () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file (GSTA-88)', () => {
|
||||
const line = /^UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md$/gm;
|
||||
test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file, the existing-PR body update and the result line (GSTA-88, rule 22)', () => {
|
||||
const line = /^UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated \(pr-body section, receipt first\), else Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md; end with gstack-ship-receipt result$/gm;
|
||||
const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' });
|
||||
expect(ship.match(line)).toHaveLength(1);
|
||||
expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']);
|
||||
expect(ship).toContain('.gstack/tmp/ship-receipt.md when nothing was pushed');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain("update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never `gh pr ready`");
|
||||
expect(ship).toContain('the handoff section writes the receipt to .gstack/tmp/ship-receipt.md');
|
||||
for (const skill of ['review', 'qa', 'autoplan']) {
|
||||
expect(runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' })).not.toContain('UNATTENDED_SHIP');
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
maxSkeletonBytes: 81_000, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10).
|
||||
maxSkeletonBytes: 81_600, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535.
|
||||
minUnionBytes: 181_000, // token-reduction Phases 1-2 (v1.69.x branch); measured union 201,464
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.6690, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra
Line truncated
|
||||
maxSizeRatio: 1.6740, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra
Line truncated
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
|
||||
@@ -13,7 +13,9 @@ import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { ShipReceipt } from '../../lib/headless-artifacts';
|
||||
import { TRAIN_KEYS_VERSION } from '../../lib/ship-policy';
|
||||
import { orderReceipt, renderReceiptBlock } from '../../lib/ship-receipt';
|
||||
import { BASE_POLICY, makeFixtureRepo, runBin, type FixtureRepo } from './restamp-fixture';
|
||||
|
||||
export const TRAIN_BLOCK = {
|
||||
@@ -25,6 +27,10 @@ export const TRAIN_POLICY = { ...BASE_POLICY, train: TRAIN_BLOCK, min_gstack_ver
|
||||
export interface Rule {
|
||||
fail?: boolean; silent?: boolean; failButCreate?: boolean; status?: string; conclusion?: string | null; event?: string; headSha?: string;
|
||||
record?: { panels: unknown[]; census?: unknown; verdict?: 'GREEN' | 'RED'; nonce?: string };
|
||||
/** The run's paid-plan manifest: planned entries (default 1), or a raw manifest object; null uploads no paid-plan artifact, as a superseded screen does. */
|
||||
plan?: { planned: number } | { manifest: unknown } | null;
|
||||
/** Marks every artifact of runs dispatched under this rule expired in the artifacts listing. */
|
||||
expired?: boolean;
|
||||
check?: string | null;
|
||||
bisect?: { prefixes: string[]; patterns: Record<string, string>; costUsd?: number };
|
||||
}
|
||||
@@ -58,8 +64,24 @@ export function repoKey(fx: TrainFixture): string {
|
||||
return createHash('sha256').update(norm).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge), known to the gh shim. */
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string } = {}): string {
|
||||
/** The PR body /ship leaves behind: the receipt block first (lib/ship-receipt.ts), bound to `head`, then prose. */
|
||||
export function shippedBody(pr: string, head: string, extra: Partial<ShipReceipt> = {}): string {
|
||||
return renderReceiptBlock(orderReceipt({ schema_version: 1, pr: Number(pr), head, base: 'main', queue_mode: 'stamp-at-merge', ...extra })) + `\n## What changed\n\n#${pr} change\n`;
|
||||
}
|
||||
|
||||
/** Set a shimmed PR's body (`shippedBody` for a receipt bound to another head, '' for none). */
|
||||
export function setPrBody(fx: TrainFixture, pr: string, body: string): void {
|
||||
fx.write(d => { d.prs[pr] = { ...(d.prs[pr] as Record<string, unknown>), body }; });
|
||||
}
|
||||
|
||||
/** What GitHub keeps at refs/pull/<n>/merge: a merge commit whose first parent is the base and whose second parent is the PR head. */
|
||||
export function mergeRefCommit(fx: TrainFixture, head: string): string {
|
||||
const { repo } = fx;
|
||||
return repo.git('commit-tree', `${head}^{tree}`, '-p', 'origin/main', '-p', head, '-m', `Merge ${head.slice(0, 12)} into main`);
|
||||
}
|
||||
|
||||
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge, as GitHub's merge commit), known to the gh shim with a /ship receipt for its head (`shipped: false` leaves the body empty). */
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string; shipped?: boolean } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`);
|
||||
const file = opts.file ?? `pr-${pr}.txt`;
|
||||
@@ -72,8 +94,8 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', `feat #${pr}`);
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${pr}/head`, `HEAD:refs/pull/${pr}/merge`);
|
||||
fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: !!opts.cross, state: 'OPEN', headRefName: `pr-${pr}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: '' }; });
|
||||
repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${pr}/head`, `${mergeRefCommit(fx, head)}:refs/pull/${pr}/merge`);
|
||||
fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: !!opts.cross, state: 'OPEN', headRefName: `pr-${pr}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: opts.shipped === false ? '' : shippedBody(pr, head) }; });
|
||||
return head;
|
||||
}
|
||||
|
||||
@@ -136,7 +158,8 @@ function shimSource(): string {
|
||||
" const id = d.nextId++; const head = rule.headSha || inputs.expected_sha || rev('refs/heads/' + ref) || rev(ref);",
|
||||
" const run = { databaseId: id, workflow: w, headBranch: ref, headSha: head, status: rule.status || 'completed', conclusion: rule.conclusion === undefined ? 'success' : rule.conclusion,",
|
||||
" url: 'https://example.invalid/runs/' + id, event: rule.event || 'workflow_dispatch', displayTitle: inputs.nonce ? 'E2E Evals [eval-bg ' + inputs.nonce + ']' : w, inputs };",
|
||||
" d.runs.push(run); d.artifacts[id] = {};",
|
||||
" d.runs.push(run); d.artifacts[id] = {}; if (rule.expired) (d.expired = d.expired || {})[id] = true;",
|
||||
" if (rule.plan !== null) d.artifacts[id]['paid-plan'] = rule.plan && rule.plan.manifest ? rule.plan.manifest : { tier: 'gate', sliceCount: 1, entries: Array.from({ length: rule.plan ? rule.plan.planned : 1 }, (_, i) => ({ file: 'test/case-' + i + '.test.ts', status: 'planned' })) };",
|
||||
" if (rule.record && !inputs.prefix_shas) d.artifacts[id][d.artifact] = { schema: 1, kind: 'train-gate', nonce: rule.record.nonce || inputs.nonce, head_sha: head, tree: rev(head + '^{tree}'),",
|
||||
" verdict: rule.record.verdict || 'GREEN', census: rule.record.census, panels: rule.record.panels };",
|
||||
" if (rule.check && !inputs.prefix_shas) (d.checks[head] = d.checks[head] || []).push({ name: 'gstack/full-gate', status: 'completed', conclusion: rule.check });",
|
||||
@@ -155,9 +178,12 @@ function shimSource(): string {
|
||||
"if (a[0] === 'run' && a[1] === 'view') { const r = d.runs.find(x => String(x.databaseId) === a[2]); if (!r) fail('run not found'); out(r); }",
|
||||
"if (a[0] === 'run' && a[1] === 'download') {",
|
||||
" const art = (d.artifacts[a[2]] || {})[opt('-n')]; if (!art) fail('no artifact matches');",
|
||||
" fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), 'record.json'), JSON.stringify(art)); save(); process.exit(0);",
|
||||
" fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), opt('-n') === 'paid-plan' ? 'manifest.json' : 'record.json'), JSON.stringify(art));",
|
||||
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
|
||||
"}",
|
||||
"if (a[0] === 'api') {",
|
||||
" const arts = /actions\\/runs\\/(\\d+)\\/artifacts(?:\\?name=([^&]+))?/.exec(a[1] || '');",
|
||||
" if (arts) { const names = Object.keys(d.artifacts[arts[1]] || {}).filter(n => !arts[2] || n === decodeURIComponent(arts[2])); out({ total_count: names.length, artifacts: names.map(name => ({ name, expired: !!(d.expired || {})[arts[1]] })) }); }",
|
||||
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
|
||||
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
|
||||
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
|
||||
|
||||
@@ -110,6 +110,30 @@ describe('install verdict keyed by (checkout, project): both twins', () => {
|
||||
expect(both('review', b, { GSTACK_STATE_ROOT: legacySr })).toMatchObject({ refuse: false, status: 'ok' });
|
||||
});
|
||||
|
||||
test("GSTA-85: an installer run from the skill's own folder (no project) leaves /ship from the project with no verdict; both twins name the project directory to run the installer from", () => {
|
||||
const sr = tmp();
|
||||
const env = { GSTACK_STATE_ROOT: sr };
|
||||
const proj = project('lane');
|
||||
writeVerdict(sr, { project: '', bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship' });
|
||||
const r = both('ship', proj, env);
|
||||
expect(r).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' });
|
||||
expect(r.detail).toBe(`no install verdict at ${installVerdictPath(REAL, sr, proj)} for ${REAL}`);
|
||||
const fix = `fix: run the Capy skill installer (scripts/install.sh --for ship) from ${proj}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${proj}; then launch with the GSTACK_LAUNCH line it prints (INSTALL_UNVERIFIED)`;
|
||||
expect(r.line).toContain(fix);
|
||||
expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toContain(fix);
|
||||
const file = installVerdictPath(REAL, sr, proj);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, '{not json\n');
|
||||
const corrupt = both('ship', proj, env);
|
||||
expect(corrupt).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED' });
|
||||
expect(corrupt.detail).toBe(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
|
||||
expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toContain(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
|
||||
writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 3, for: 'ship' });
|
||||
const future = both('ship', proj, env);
|
||||
expect(future).toMatchObject({ refuse: true, status: 'verdict' });
|
||||
expect(future.detail).toContain('unsupported schema_version');
|
||||
});
|
||||
|
||||
test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => {
|
||||
const sr = tmp();
|
||||
const a = project('a');
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* prune, ready's screen choice, and the policy gate.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
@@ -19,7 +20,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
|
||||
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, urlKey,
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, mergeRefCommit, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey,
|
||||
type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
@@ -151,11 +152,37 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('rule 22: READY needs the /ship receipt for this head in the PR body; none or another head refuses before any receipt is written, with no flag around it', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
setPrBody(fx, '1', '## What changed\n\nno /ship ran\n');
|
||||
const none = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(none.status).toBe(EXIT.refused);
|
||||
expect(none.out).toContain('SHIP_RECEIPT: none in the PR body');
|
||||
expect(none.err).toMatch(/PR #1's body carries no ship receipt; fix: run \/ship on the branch .*there is no flag to skip this \(QUEUE_RECEIPT_MISSING\)/);
|
||||
expect(next(none.out)).toBe('NEXT: fix the refusal, then gstack-ship-queue ready --pr 1 --screen|--no-screen');
|
||||
const other = 'd'.repeat(40);
|
||||
setPrBody(fx, '1', shippedBody('1', other));
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.out).toContain(`SHIP_RECEIPT: pr=1 head=dddddddddddd version=- base=main`);
|
||||
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for dddddddddddd, not the head ${head.slice(0, 12)} being merged; fix: rerun /ship .*\\(QUEUE_RECEIPT_STALE\\)`));
|
||||
setPrBody(fx, '1', shippedBody('2', head));
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']).err).toMatch(/PR #1's ship receipt names pr=2, not this PR.*\(QUEUE_RECEIPT_STALE\)/);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
setPrBody(fx, '1', shippedBody('1', head, { version: '1.2.4.0', session_kind: 'unattended' }));
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(line(ok.out, 'SHIP_RECEIPT:')).toBe(`SHIP_RECEIPT: pr=1 head=${head.slice(0, 12)} version=1.2.4.0 base=main session_kind=unattended`);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `1-${head}.json`))).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const mergeSha = fx.repo.git('ls-remote', 'origin', 'refs/pull/1/merge').split(/\s/)[0]!;
|
||||
expect(mergeSha).not.toBe(head);
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(line(dry.out, 'SCREEN_DISPATCH:')).toMatch(new RegExp(`^SCREEN_DISPATCH: evals\\.yml --ref main evals_all=false expected_sha=${head} nonce=pr\\.screen\\.narrow-\\S+ base_ref=main base_sha=[0-9a-f]{40} screen_pr=1 screen_merge_sha=${head} screen_policy=narrow \\(dry run: not dispatched\\)$`));
|
||||
expect(line(dry.out, 'SCREEN_DISPATCH:')).toMatch(new RegExp(`^SCREEN_DISPATCH: evals\\.yml --ref main evals_all=false expected_sha=${mergeSha} nonce=pr\\.screen\\.narrow-\\S+ base_ref=main base_sha=[0-9a-f]{40} screen_pr=1 screen_merge_sha=${mergeSha} screen_policy=narrow \\(dry run: not dispatched\\)$`));
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
@@ -168,6 +195,99 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(dispatches(fx).map(d => (d.inputs as any).screen_policy)).toEqual(['narrow', 'broad']);
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--broad']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success);
|
||||
// fails_when=the merge ref's second parent is not compared with the PR head, or a missing merge ref keeps the old QUEUE_NOT_READY code; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
|
||||
test('--screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge lags the PR head or is missing, dispatching nothing; a recomputed merge ref admits it', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const { repo } = fx;
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-1.txt'), 'pushed after the merge ref was computed\n');
|
||||
repo.git('commit', '-qam', 'more');
|
||||
const moved = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/pull/1/head');
|
||||
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const pregate2 = path.join(repo.root, 'pregate2.json');
|
||||
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`refs/pull/1/merge \\([0-9a-f]{12}\\) merges ${head.slice(0, 12)}, not the PR head ${moved.slice(0, 12)}.*\\(QUEUE_SCREEN_STALE_REF\\)`));
|
||||
expect(line(stale.out, 'NEXT:')).toBe('NEXT: resolve the conflict with main (or wait for GitHub to recompute refs/pull/1/merge), push, then gstack-ship-queue ready --pr 1 --screen; a screen dispatched on a stale merge ref plans no cases');
|
||||
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
const tree = repo.git('rev-parse', `${moved}^{tree}`);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
const badMerge = spawnSync('git', ['-C', repo.origin, 'hash-object', '-t', 'commit', '-w', '--stdin', '--literally'], { input: `tree ${tree}\nparent ${main}\nparent ${moved}\nauthor A <a@example.com> 1700000000 +0000\ncommitter B 1700000000 +0000\n\nmerge without a committer email\n`, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
|
||||
spawnSync('git', ['-C', repo.origin, 'update-ref', 'refs/pull/1/merge', badMerge], { timeout: 30_000 });
|
||||
repo.git('config', 'fetch.fsckObjects', 'true');
|
||||
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(unfetchable.status).toBe(EXIT.refused);
|
||||
expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen');
|
||||
repo.git('config', '--unset', 'fetch.fsckObjects');
|
||||
const originUrl = repo.git('remote', 'get-url', 'origin');
|
||||
repo.git('remote', 'set-url', 'origin', path.join(repo.root, 'no-such-origin.git'));
|
||||
const unlisted = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
repo.git('remote', 'set-url', 'origin', originUrl);
|
||||
expect(unlisted.status).toBe(EXIT.refused);
|
||||
expect(unlisted.err).toMatch(/could not list origin's refs: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(unlisted.err.split('\n').filter(Boolean).length).toBe(1);
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
|
||||
const fresh = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([fresh.status, fresh.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(fresh.out, 'SCREEN_DISPATCH:')).toContain('(dry run: not dispatched)');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a finished screen that planned 0 cases never counts as a screen: ready dispatches a fresh one instead of adopting it and train build seats the slot unscreened with a SCREEN: line;
|
||||
// fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
|
||||
test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
|
||||
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(first.status).toBe(EXIT.ok);
|
||||
expect(line(first.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 100 /);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .* \(not following run 100 planned no cases \(no paid-plan artifact; concluded success\): a screen that planned nothing is not a screen\)$/);
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(2);
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect(build.status).toBe(EXIT.ok);
|
||||
expect(line(build.out, 'SCREEN:')).toBe('SCREEN: #1 run 101 planned no cases (no paid-plan artifact; concluded success); counted as not-run, the slot rides unscreened');
|
||||
expect(line(build.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened)');
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 0 } }; });
|
||||
const emptyPlan = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(line(emptyPlan.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned no cases/);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 } }; });
|
||||
const live = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(live.status).toBe(EXIT.ok);
|
||||
expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 103 .*not following run 102 planned no cases \(an empty plan manifest; concluded success\)/);
|
||||
const followed = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(followed.status).toBe(EXIT.ok);
|
||||
expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(4);
|
||||
const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
const runningBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success' }; d.artifacts['103']['paid-plan'] = { reuse: 'confirmed' }; });
|
||||
const unreadable = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([unreadable.status, line(unreadable.out, 'ORDER:'), line(unreadable.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan manifest.json is unreadable; counted as screened, unverified"]);
|
||||
const stillFollowed = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(line(stillFollowed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)');
|
||||
fx.write(d => { (d as any).expired = { '103': true }; });
|
||||
const expired = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([expired.status, line(expired.out, 'ORDER:'), line(expired.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan artifact has expired; counted as screened, unverified"]);
|
||||
fx.write(d => { (d as any).expired = {}; d.runs = d.runs.filter(r => (r as any).databaseId !== 103); });
|
||||
const gone = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([gone.status, line(gone.out, 'ORDER:'), line(gone.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 is unreadable; counted as screened, unverified']);
|
||||
fx.write(d => { (d as any).repo = ''; });
|
||||
const noSlug = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([noSlug.status, line(noSlug.out, 'ORDER:'), line(noSlug.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 the repository slug is unreadable; counted as screened, unverified']);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('train build', () => {
|
||||
@@ -309,6 +429,39 @@ describe('train build', () => {
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
}, 180_000);
|
||||
|
||||
test('rule 22: a pin whose PR body has no current /ship receipt is refused by name before any write, push or dispatch, dry run included; the pinned-head receipt admits it', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2', { shipped: false });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const refsBefore = remoteRefs(fx);
|
||||
for (const args of [['--dry-run'], []]) {
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, ...args]);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/PR #2's body carries no ship receipt; fix: run \/ship on the branch .*\(QUEUE_RECEIPT_MISSING\)/);
|
||||
expect(r.out).toContain('SHIP_RECEIPT: none in the PR body');
|
||||
expect(next(r.out)).toBe("NEXT: #2's lane runs /ship, then gstack-ship-queue ready --pr 2 --screen|--no-screen; then gstack-ship-queue train build --prs <pins>");
|
||||
expect(result(r.out)).toBe('GSTACK_RESULT: skill=ship-queue status=refused run=none');
|
||||
}
|
||||
setPrBody(fx, '2', shippedBody('2', 'a'.repeat(40)));
|
||||
const stale = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`PR #2's ship receipt is for aaaaaaaaaaaa, not the head ${h2.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
|
||||
setPrBody(fx, '2', '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
|
||||
expect(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']).err).toMatch(/PR #2's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/);
|
||||
setPrBody(fx, '2', shippedBody('1', h2));
|
||||
expect(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']).err).toMatch(/PR #2's ship receipt names pr=1, not this PR.*\(QUEUE_RECEIPT_STALE\)/);
|
||||
expect(remoteRefs(fx)).toBe(refsBefore);
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains'))).toBe(false);
|
||||
setPrBody(fx, '2', shippedBody('2', h2));
|
||||
const ok = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(dispatches(fx).length).toBe(2);
|
||||
}, 180_000);
|
||||
|
||||
test('free tests red, cancelled, missing or on another SHA refuse with QUEUE_TRAIN_FREE_RED before any paid dispatch', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -596,6 +749,53 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8')).cleared_at).toBe('2026-10-18T12:00:00.000Z');
|
||||
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
|
||||
}, 300_000);
|
||||
test("rule 22: verify --train binds the receipt to the pinned head when the current head is its stamp commit; a receipt for another head refuses the slot and merges nothing", () => {
|
||||
const fx = make();
|
||||
const { h1, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'lane1', h1);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`);
|
||||
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
|
||||
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
|
||||
const stale = v();
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for bbbbbbbbbbbb, not the head ${h1.slice(0, 12)} or ${stamped.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`));
|
||||
expect(stale.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(next(stale.out)).toBe("NEXT: #1's lane runs /ship and READYs again; then gstack-ship-queue train build --prs <pins>");
|
||||
expect(trainJson(fx, nonce).merged).toEqual([]);
|
||||
setPrBody(fx, '1', '');
|
||||
expect(v().err).toContain('(QUEUE_RECEIPT_MISSING)');
|
||||
setPrBody(fx, '1', '```gstack-ship-receipt\n{"schema_version":1}\n```\n');
|
||||
const invalid = v();
|
||||
expect(invalid.err).toMatch(/PR #1's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/);
|
||||
expect(invalid.out).not.toContain('MERGE_CONDITION:');
|
||||
setPrBody(fx, '1', shippedBody('2', h1));
|
||||
const otherPr = v();
|
||||
expect(otherPr.err).toMatch(/PR #1's ship receipt names pr=2, not this PR.*\(QUEUE_RECEIPT_STALE\)/);
|
||||
expect(otherPr.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(trainJson(fx, nonce).merged).toEqual([]);
|
||||
setPrBody(fx, '1', shippedBody('1', stamped));
|
||||
expect(v().out).toContain(`SHIP_RECEIPT: pr=1 head=${stamped.slice(0, 12)} version=- base=main (binds the current head)`);
|
||||
const saved = fx.read().prs['1'];
|
||||
fx.write(d => { delete d.prs['1']; });
|
||||
const unreadable = v();
|
||||
expect(unreadable.status).toBe(EXIT.refused);
|
||||
expect(unreadable.err).toContain('(QUEUE_PROOF_MISSING)');
|
||||
expect(unreadable.out).not.toContain('MERGE_CONDITION:');
|
||||
fx.write(d => { d.prs['1'] = saved; });
|
||||
expect(trainJson(fx, nonce).merged).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
test('from slot 2 on, a head that merges main into the pin verifies when its tree is the slot\'s prefix modulo stamps', () => {
|
||||
const fx = make();
|
||||
const { h2, nonce } = twoPrTrain(fx);
|
||||
|
||||
Reference in new issue
Block a user