The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)

* Let an owner recorded approval close a gate finding in unattended ship (GST-2491)

An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it.

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

* Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491)

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

* Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491)

A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: s11/adversarial-native#2 reader=adversarial-native
Review-Finding: s11/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491)

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

* Require the lander to accept each owner gate disposition before merging (GST-2491)

Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299.

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

* Regenerate the result-code anchors for the gate-disposition refusal (GST-2491)

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

* Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491)

The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied.

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

* Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491)

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

* Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491)

Review-Finding: s11/outside-voice#3 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-12 01:49:08 +00:00
1 parent 842fef1f95
commit b1f78301db
28 files changed
+1050 -38

No files matched your search

+35 -2
View File
@@ -10,6 +10,8 @@
* gstack-approvals plan-check --plan <the brief's Plan line>
* gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled> [--json]
* gstack-approvals rulings [--branch <name>]
* gstack-approvals gate --finding <fingerprint> --gate-id <gate_id> --head <stop head> --reason <the gate change> --words <exact words> --by <who recorded> [--json]
* gstack-approvals dispositions [--branch <name>]
* gstack-approvals lane-exception --head <pushed head> --files <f1,f2> --reason <why> --words <exact words> --by <who granted> [--json]
* gstack-approvals list [--json] [--branch <name>]
* gstack-approvals sync [--branch <name>]
@@ -30,6 +32,7 @@ import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes,
import { resultLine } from '../lib/result-codes';
import { parsePlanLine, publicPlanLine, resolvePlan } from '../lib/plan-record';
import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception';
import { GATE_DISPOSITION_SCOPE, GATE_SOURCE, gateDispositionRow, gateDispositions, gateDispositionText, ONE_LINE, recordGateDisposition, validGateId } from '../lib/gate-dispositions';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
@@ -43,6 +46,14 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body
list it; refused inside this checkout's open /ship run
rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry
gate --finding <the gate finding's fingerprint> --gate-id <its gate_id> --head <the stop head the owner answered at>
--reason <the gate change, one line> --words <the owner's exact words, one line> --by <who recorded them>
the owner's disposition of a gate finding an unattended /ship logged open (docs/ship-policy.md#gate-dispositions);
it closes that fingerprint and gate_id on a review row whose range_base..commit holds the head (a first pass: its
commit), and gstack-review-read shows it kept;
finish prints it, and the receipt, PR body and READY carry it; refused while any worktree holds an open /ship run on
the branch; the lander accepts each one with gstack-ship-queue land|verify --accept-gate-disposition <id>
dispositions the "Gate disposition: …" lines the Pre-Landing Review and the PR body carry
lane-exception --head <the pushed head the owner granted at> --files <the uncovered inventory files, comma-separated>
--reason <why, one line> --words <the owner's exact words> --by <who granted>
the owner's grant that a CI-carried lane counts although exactly those files are in no run's tree.json
@@ -69,7 +80,7 @@ Example:
${exitTableHelp()}`;
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files', '--plan']);
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files', '--plan', '--gate-id']);
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
const flags: Record<string, string> = {};
@@ -146,7 +157,7 @@ function main(argv: string[]): number {
const json = bools.has('--json');
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception') {
if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception' || cmd === 'gate') {
let rows;
if (cmd === 'lane-exception') {
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
@@ -174,8 +185,25 @@ function main(argv: string[]): number {
const built = buildRow({ ...ruledNoteRow(finding, head.stdout.trim(), reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
} else if (cmd === 'gate') {
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
const finding = (flags['--finding'] ?? '').trim(), gateId = (flags['--gate-id'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim(), words = (flags['--words'] ?? '').trim(), by = (flags['--by'] ?? '').trim();
if (!finding || /\s/.test(finding) || !validGateId(gateId) || !reason) return invalid('gate needs --finding <the gate finding\'s fingerprint>, --gate-id <its gate_id from the review row>, both without spaces (a finding with no gate_id, or a literal undefined, cannot be dispositioned), and --reason <the gate change, one line>');
if (!words) return invalid('gate needs --words <the owner\'s exact words>: a gate disposition is the owner\'s, in the owner\'s words');
if (!ONE_LINE.test(reason) || !ONE_LINE.test(words) || !ONE_LINE.test(by)) return invalid('--reason, --words and --by are one line each: no newline or control character');
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
if (!flags['--head'] || head.status !== 0) return invalid('gate needs --head <the stop head the owner answered at, a commit in this repository>');
if (GSTACK_ACTOR.test(by)) return invalid('--by names gstack; a gate disposition is the owner\'s, so --by names who recorded it');
const built = buildRow({ ...gateDispositionRow(finding, gateId, head.stdout.trim(), reason), words, by, date: flags['--date'], source: GATE_SOURCE }, loc.branch);
if ('code' in built) return fail(cmd, built);
const res = recordGateDisposition(process.cwd(), loc, built);
if ('code' in res) return fail(cmd, res);
if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n');
else process.stdout.write(`APPROVAL_RECORDED: ${built.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
return res.mirrorError ? fail(cmd, res.mirrorError) : EXIT.ok;
} else if (cmd === 'add') {
if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' });
if (GATE_DISPOSITION_SCOPE.test(flags['--scope'] ?? '') || (flags['--source'] ?? '').trim() === GATE_SOURCE) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a gate_disposition scope is recorded with gstack-approvals gate' });
if (TEST_LANE_EXCEPTION_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a test_lane_exception scope is recorded with gstack-approvals lane-exception' });
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
@@ -230,6 +258,11 @@ function main(argv: string[]): number {
process.stdout.write(notes.length ? notes.map(n => `Ruled note: ${ruledNoteText(n)}\n`).join('') : 'RULED_NOTES: none\n');
return EXIT.ok;
}
if (cmd === 'dispositions') {
const ds = gateDispositions(readRecord(loc).rows, loc.branch);
process.stdout.write(ds.length ? ds.map(d => `Gate disposition: ${gateDispositionText(d)}\n`).join('') : 'GATE_DISPOSITIONS: none\n');
return EXIT.ok;
}
if (cmd === 'sync') {
const s = syncRecord(loc);
process.stdout.write(`APPROVAL_SYNCED: rows=${s.rows} ${recorded(loc)}\n`);
+1
View File
@@ -355,6 +355,7 @@ gstack_result_anchor() {
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
QUEUE_LAND_STAMPED) printf '%s' 'queue-land-stamped' ;;
QUEUE_LAND_PIN_MOVED) printf '%s' 'queue-land-pin-moved' ;;
QUEUE_GATE_DISPOSITION) printf '%s' 'queue-gate-disposition' ;;
QUEUE_LAND_CHECKS) printf '%s' 'queue-land-checks' ;;
QUEUE_LAND_MERGED_CHECKS) printf '%s' 'queue-land-merged-checks' ;;
QUEUE_LAND_NOT_CLEAN) printf '%s' 'queue-land-not-clean' ;;
+13 -3
View File
@@ -7,7 +7,9 @@
# bin/gstack-wtree --review, or "unknown"), ---TREE--- (HEAD tree, informational) and
# ---DIRTY--- (tracked-file dirty flag). Consumers grade diff-scoped review
# rows using review_freshness; legacy or uncaptured diff evidence cannot fall
# back to HEAD equality. Plan rows are unchanged.
# back to HEAD equality. Plan rows are unchanged. A gate finding the owner's gstack-approvals
# gate row closes reads `kept` with its `disposition`, and the row's `gate_dispositions` lists
# those approval ids (lib/gate-dispositions.ts); values the row itself carried are discarded.
set -euo pipefail
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -21,14 +23,22 @@ gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WTREE=$("$SCRIPT_DIR/gstack-wtree" --review 2>/dev/null || echo "unknown")
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_GATE_LIB="$SCRIPT_DIR/../lib/gate-dispositions.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB);
const { dispositionedOn, gateKey, readGateDispositions, showDispositioned } = await import(process.env.GSTACK_GATE_LIB);
let dispositions = [];
try { dispositions = readGateDispositions(process.cwd()); }
catch (e) { console.error("gstack-review-read: gate dispositions unreadable, none applied: " + e.message); }
for (const line of (await Bun.stdin.text()).split("\n").filter(Boolean)) {
try {
const rec = JSON.parse(line);
delete rec.review_freshness;
const freshness = reviewFreshness(rec, process.env.GSTACK_REVIEW_WTREE);
delete rec.gate_dispositions;
for (const f of Array.isArray(rec.findings) ? rec.findings : []) if (f && typeof f === "object") delete f.disposition;
const closed = dispositionedOn(process.cwd(), rec, dispositions);
const freshness = reviewFreshness(rec, process.env.GSTACK_REVIEW_WTREE, new Set(), new Set(closed.map(d => gateKey(d.finding, d.gate_id))));
if (freshness) rec.review_freshness = freshness;
showDispositioned(rec, closed);
console.log(JSON.stringify(rec));
} catch { console.error("gstack-review-read: skipping malformed review row"); }
}
+25 -4
View File
@@ -14,7 +14,8 @@
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
* gstack-ship-queue land --pr <n> --pin <sha> [--repair] [--dry-run] [--allow-repo-commands] [--break-lock] (merge: direct; lib/ship-queue-land-direct.ts)
* gstack-ship-queue land --pr <n> --pin <sha> [--accept-gate-disposition <id>]... [--repair] [--dry-run] [--allow-repo-commands] [--break-lock] (merge: direct; lib/ship-queue-land-direct.ts)
* gstack-ship-queue verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]... (land's receipt and gate-disposition acceptance, read-only, for a lander that merges another way)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
@@ -59,15 +60,20 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
bind the gate result to that attempt's gated tree; removes its worktree
gate-ahead --list --pr <n> attempts for a PR, newest last (the current one)
verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]...
land --pin's receipt and gate-disposition check, read-only, for a lander that merges
another way: prints each GATE_DISPOSITION line and exits 0 only when every one is accepted
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
land --pr <n> --pin <sha> [--repair] [--dry-run] [--allow-repo-commands] [--break-lock]
land --pr <n> --pin <sha> [--accept-gate-disposition <id>]... [--repair] [--dry-run] [--allow-repo-commands] [--break-lock]
merge: direct: merge the PR at its own green head (receipt and READY at the pin, no stamp
files, checks green, mergeable clean), verify the tree, write a land note; a stale base
runs land.merged_checks, repo commands, only with --allow-repo-commands (never --dry-run);
it holds the land lock from the re-fetch to the merge (--break-lock: a stale one);
--repair: while frozen, land the one repair PR the freeze admits (recorded under the lock)
--repair: while frozen, land the one repair PR the freeze admits (recorded under the lock);
an owner gate disposition the approval store applies at the pin merges only when the
lander passes --accept-gate-disposition <its approval id> (docs/ship-policy.md#gate-dispositions)
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
@@ -89,6 +95,12 @@ function flag(args: string[], name: string): string | undefined {
return i >= 0 ? args[i + 1] : undefined;
}
/** Every --accept-gate-disposition value, or the first one that is not an approval id. */
function acceptedIds(args: string[]): { ids: string[]; bad?: string } {
const ids = args.flatMap((a, i) => a === '--accept-gate-disposition' ? [args[i + 1] ?? ''] : []);
return { ids, bad: ids.find(id => !/^a-[0-9a-f]{12}$/.test(id)) };
}
function finish(cmd: string, out: StepOutput): number {
for (const line of out.lines) process.stdout.write(line + '\n');
if (out.failure) {
@@ -187,7 +199,9 @@ async function main(argv: string[]): Promise<number> {
if (args.includes('--pin')) {
const pin = flag(args, '--pin') ?? '';
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, dryRun: args.includes('--dry-run'), allowRepoCommands: args.includes('--allow-repo-commands'), breakLock: args.includes('--break-lock'), repair: args.includes('--repair') }));
const accept = acceptedIds(args);
if (accept.bad !== undefined) return usage(`--accept-gate-disposition needs an approval id (a-<12 hex>), not ${JSON.stringify(accept.bad)}`);
return finish('land', (await import('../lib/ship-queue-land-direct')).landDirect(ctx, { pr, pin, acceptGateDispositions: accept.ids, dryRun: args.includes('--dry-run'), allowRepoCommands: args.includes('--allow-repo-commands'), breakLock: args.includes('--break-lock'), repair: args.includes('--repair') }));
}
const timeout = flag(args, '--timeout');
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
@@ -215,6 +229,13 @@ async function main(argv: string[]): Promise<number> {
failAfter: process.env.GSTACK_SHIP_QUEUE_FAIL_AFTER,
}));
}
if (cmd === 'verify' && args.includes('--pin')) {
const pin = flag(args, '--pin') ?? '';
if (!/^[0-9a-f]{40,64}$/.test(pin)) return usage('--pin needs the full head sha the READY names');
const accept = acceptedIds(args);
if (accept.bad !== undefined) return usage(`--accept-gate-disposition needs an approval id (a-<12 hex>), not ${JSON.stringify(accept.bad)}`);
return finish('verify', (await import('../lib/ship-queue-land-direct')).verifyPin(ctx, { pr, pin, acceptGateDispositions: accept.ids }));
}
if (cmd === 'verify') {
if (flag(args, '--train') || args.includes('--screen')) return (await import('../crew/lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
const record = flag(args, '--record');
+8 -1
View File
@@ -58,6 +58,7 @@ import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes';
import { appliedGateDispositions, gateDispositionText } from '../lib/gate-dispositions';
import { laneExceptionBodyLines } from '../lib/test-lane-exception';
import { readMechanicalLines } from '../lib/ship-mechanical';
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
@@ -270,6 +271,12 @@ function cmdWrite(rest: string[], json: boolean): number {
const ruledNotes = readRuledNotes(repoRoot);
const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
let gateDispositions: ReturnType<typeof appliedGateDispositions>['applied'];
try { gateDispositions = appliedGateDispositions(repoRoot, { pin: 'HEAD' }).applied; }
catch (e: any) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `the owner gate dispositions at HEAD cannot be derived (${e.message}); the receipt always records them, so an unreadable approval store or a git error writes nothing`) + '\n'); return EXIT.fail; }
if (gateDispositions.length && !bodyFile) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `the approval store applies ${gateDispositions.length} owner gate disposition(s) at HEAD (${gateDispositions.map(d => d.approval).join(', ')}), so write needs --body <the PR body> carrying each "Gate disposition: …" line gstack-approvals dispositions prints; nothing written`) + '\n'); return EXIT.fail; }
const undisclosed = bodyFile ? gateDispositions.map(d => `Gate disposition: ${gateDispositionText(d)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (undisclosed) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list gate disposition ${JSON.stringify(undisclosed)} (gstack-approvals dispositions prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
const carried = ciCarried(base);
const unlistedException = bodyFile ? laneExceptionBodyLines(carried).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlistedException) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedException)} (accept's TEST_LANE_EXCEPTION: line; the body carries it as one line); nothing written`) + '\n'); return EXIT.fail; }
@@ -331,7 +338,7 @@ function cmdWrite(rest: string[], json: boolean): number {
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, ruled_notes: ruledNotes.length ? ruledNotes : undefined, mechanical: mechanical.length ? mechanical : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, ruled_notes: ruledNotes.length ? ruledNotes : undefined, gate_dispositions: gateDispositions, mechanical: mechanical.length ? mechanical : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
design: review.frontend ? { mode: 'static', reason: 'no preview declared' } : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
review_exclude: receiptExclude(review.exclude),
@@ -0,0 +1,4 @@
---
important: blocker
---
- **An unattended /ship can now finish a PR that carries an owner-approved gate change, and the lander has to accept each one before it merges.** When the review finds a loosened gate (a test threshold raised, a check skipped), an unattended run logs it as an open gate finding, because nobody is there to answer the gate question, and until now nothing could close it, so the run stopped for good. That blocked founderstar#906, whose gate change Garry had approved. The owner's answer can now be recorded from outside the run with `gstack-approvals gate --finding <fingerprint> --gate-id <gate_id> --head <stop head> --reason '<the gate change>' --words '<the owner's exact words>' --by '<who recorded>'`. It closes only that finding, only on a review whose range holds that head, and the review reader then shows it as kept so the rest of /ship carries on. It is refused while a /ship run is open on that branch in any worktree, without the owner's words, for a literal `undefined` gate id, or when `--by` names gstack. A row that is missing a field the writer writes, or was edited after it was written, is ignored. The lane runs as the same user, so it could still write a row that looks right. That's why the lander decides: `gstack-ship-queue land --pin` works the dispositions out again from the approval store at the pin, prints each one, and merges only with `--accept-gate-disposition <approval id>` for every one. It also refuses when the receipt or the PR body leaves one out or invents one. `gstack-ship-queue verify --pr <n> --pin <sha>` runs the same check without merging, for a lander that merges another way, and `gstack-ship-receipt write` no longer skips the disclosure check when `--body` is left off. Gate findings are logged exactly as before, and a ruling or anything the lane writes on its own review row still closes nothing. A disposition recorded at a commit that a later rewrite removed from the clone just stops applying instead of blocking the branch, and a shallow landing clone is told to deepen itself.
+98
View File
@@ -1718,6 +1718,103 @@ Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
line word for word, and the READY block prints each as `RULED_NOTE:`.
<a id="gate-dispositions"></a>
**Gate dispositions (GST-2491).** A ruling never closes a gate finding (a loosened
gate the review logged with a `gate` tag and the hunk identity `gate_id`). In an
interactive session the human answers `review-gate-disposition`; in an unattended
one `gstack-review-log` logs every gate finding `open`, and the log stays so. When
the owner approved the gate change, the owner's answer goes through the approval
record instead, recorded outside the lane's /ship run at the stop head, one finding
per row:
```bash
gstack-approvals gate --finding '<the gate finding's fingerprint>' --gate-id <its gate_id> --head <stop head> \
--reason '<the gate change, one line>' --words '<the owner's exact words>' --by '<who recorded>'
```
The row is an approved `gstack-approvals` row with scope `gate_disposition
<fingerprint> <gate_id> <head>`, ask `gate_disposition <fingerprint> <gate_id> at
<head>: <reason>` and the owner's words in `words` (`lib/gate-dispositions.ts`).
`gate` refuses a missing `--gate-id` (or a literal `undefined` or `null`), empty
`--words`, a reason, words or `--by` with a newline or control character (each is
trimmed first), a `--head` that is not a commit, a `--by` naming gstack, a row that
`dispositions` would not list (a head that is not 40 hex, for one), and any call
while a /ship run is open on the branch in any worktree of the repository: its
marker counts while its lease is open, the same test preflight uses, so a run parked
before finish stops counting once its 120-minute lease ends. It finds those worktrees
through the git common dir, and it checks and appends under a lock there that
/ship's preflight also takes to write its marker. `add` refuses the scope and
`--source gate`, so `gate` is the only writer. A row counts only when it has every
field `gate` writes: an approval-shaped id that its hash reproduces, `source: gate`,
`pending: none`, the store's own branch, `date`, `recorded_at`, `by`, one-line
`words` and a `digest` over those fields. The digest catches a row that was edited
or typed by hand. It isn't authentication: anyone who can read the code can compute
it. A gate finding with no `gate_id` is never closed, and a row gstack wrote is
ignored. A
disposition closes a gate finding only on a review row that carries that exact
fingerprint and `gate_id` and whose `range_base..commit` holds the head (a first
pass, with no `range_base`: its own commit), so a later, different loosening of the
same line (a new `gate_id`) stays open. Anything else closes nothing, and a
review-log field (`kept`, `reason`, `disposition`, `gate_dispositions` or any claim
the lane writes on its row) never makes one. `gstack-review-read` shows each closed
finding as `kept` with `reason: owner disposition <id>: <reason>` and
`disposition: <id>`, lists the ids in the row's `gate_dispositions` and grades the
row as if the findings were kept, so /ship's gate rules (`--blocking-open`,
`--gate-open`, the draft state, the `Gate edits:` counts) read them as kept once it
reads the row again. As with rulings, a row derived from a findings file
(`status_source: derived`) is not cleared. Nothing is hidden: finish prints each as
`GATE_DISPOSITION: <fingerprint> [gate <gate_id>] at <head12> by <who> at <time>:
<reason> — "<words>" (approval <id>)`, ending `(… not applied)` when no review row's
range holds it; `gstack-approvals dispositions` prints one `Gate disposition: …`
line each for the PR body; `gstack-ship-receipt write` records them as
`gate_dispositions` and refuses a `--body` that does not carry each line word for
word; and the READY block prints each as `GATE_DISPOSITION:`
([troubleshooting](troubleshooting.md#gate-finding-open-unattended)).
<a id="gate-disposition-acceptance"></a>
**Who authorizes a disposition.** A lane is a same-user agent, so it can still forge
a well-formed disposition row in its own approval store. What authorizes a
disposition is the lander's explicit acceptance: `gstack-ship-queue land --pin`
merges one only when the lander passes `--accept-gate-disposition <approval id>`
for it.
Land works the dispositions out again at the pin from the PR branch's approval
store: the local record and its mirror. A lander on another machine reads the
mirror the owner recorded into, so set `GSTACK_APPROVALS_DIR` to that directory
when it isn't the default. Land counts every disposition whose head the pin
contains. In a full clone, a row whose head commit is gone (the branch was
rewritten and garbage-collected, or the name reused) doesn't apply, because the
pin's history is all there and can't contain it. A pin missing from the clone
refuses, and so does a shallow clone that can't prove a row's ancestry: deepen it
with `git fetch --unshallow` and rerun. It never trusts the receipt or the PR body for this, because the lane
writes both. It prints each one as a `GATE_DISPOSITION:` line, then
`GATE_DISPOSITIONS: applied=<ids> accepted=<ids> store=<file>`. It refuses with
[`QUEUE_GATE_DISPOSITION`](troubleshooting.md#queue-gate-disposition), and merges
nothing, when an applied disposition isn't accepted, an accepted id isn't applied,
the receipt's `gate_dispositions` leave one out, change one or add one, or the PR
body lacks its `Gate disposition:` line. The review rows stay on the lane's
machine, so land can't tell which finding a disposition closed. It asks the lander
to accept every disposition the store holds for the pin, which is never fewer.
`gstack-ship-queue verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]...`
runs the same check read-only (`pinAcceptance`, the function land calls), for a
lander that merges some other way, such as a REST merge pinned to the head. It
exits 0 only when every disposition is accepted. `ready` prints the same lines and
refuses the same receipt and body mismatches, but asks for no acceptance.
`gstack-ship-receipt write` refuses to write a receipt while a disposition applies
at HEAD unless `--body` carries each `Gate disposition:` line.
The receipt always records `gate_dispositions`, as an empty list when none
applies, and write refuses, writing nothing, when the store or git can't be read.
Land, verify and ready refuse when the receipt's set and the store's set at the pin
differ either way. A receipt id the store lacks usually means the lander reads
another `GSTACK_STATE_ROOT` or approvals mirror than the owner recorded into, or
that the row was removed. A git error while checking a head refuses too. A receipt
with no `gate_dispositions` field was written before receipts recorded it, and it
lands only when the store applies nothing. The receipt is written by the lane, like
every other gate signal it carries. The lander's acceptance guards against a
mistaken or missing store, not against a lane that falsifies its own receipt.
<a id="test-lane-exceptions"></a>
**Test-lane exceptions (GST-1595).** A lane `accept` carries from CI refuses with
`inventory_uncovered` when an inventory file is in no run's `tree.json`; when bun
@@ -2512,6 +2609,7 @@ release-file rows):
| the PR head is the pin | [`QUEUE_LAND_PIN_MOVED`](troubleshooting.md#queue-land-pin-moved) |
| the PR body's /ship receipt is bound to the pin | `QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE` |
| the receipt's `base` is the queue's base | [`QUEUE_RECEIPT_BASE_MISMATCH`](troubleshooting.md#queue-receipt-base-mismatch) |
| every owner gate disposition the approval store applies at the pin is accepted with `--accept-gate-disposition <id>`, every accepted id is applied, and the receipt and PR body list exactly those ([gate dispositions](#gate-disposition-acceptance)) | [`QUEUE_GATE_DISPOSITION`](troubleshooting.md#queue-gate-disposition) |
| a READY note from `ready` exists at the pin | `QUEUE_NOT_READY` |
| no freeze is active (with `--repair`: one is, and its repair slot is free or already this pin; a backstop freeze left from before `release_gate` only warns; see [direct freeze](#direct-freeze)) and no land lock is held | [`QUEUE_TRAIN_FROZEN`](troubleshooting.md#queue-train-frozen), `QUEUE_NOT_READY`, `QUEUE_LAND_LOCKED` |
| VERSION, the `package.json` version, the CHANGELOG and `.gstack/version-path` are unchanged since the merge base | [`QUEUE_LAND_STAMPED`](troubleshooting.md#queue-land-stamped) |
+61 -1
View File
@@ -3727,9 +3727,12 @@ results, prereg or eval path, and names no money, security, secret or auth topic
needs the reader again (GST-1195). With `--body`, write also refuses, writing
nothing, a body that does not carry each line the receipt requires as one line,
word for word: every `Ruled note: …` line `gstack-approvals rulings` prints,
every `Gate disposition: …` line `gstack-approvals dispositions` prints (and while
a gate disposition applies at HEAD, write refuses to run without `--body`),
finish's `MECHANICAL:` lines as `Mechanical retake: …`, and accept's
`TEST_LANE_EXCEPTION:` lines as `Test-lane exception: …`
([ruled notes](ship-policy.md#ruled-notes),
[gate dispositions](ship-policy.md#gate-dispositions),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
@@ -5777,6 +5780,33 @@ doesn't describe what would merge. Nothing was merged.
**Fix.** The lane runs /ship and `ready --pr <n> --no-screen` at the new head
and sends a new READY; land that pin.
<a id="queue-gate-disposition"></a>
### `QUEUE_GATE_DISPOSITION`
**Meaning.** The approval store holds an owner gate disposition that applies at the
pin, and `land --pin` or `verify --pin` wasn't told to accept it. Or an
`--accept-gate-disposition` id names no disposition the store applies there. Or the
receipt's `gate_dispositions`, or the PR body's `Gate disposition:` lines, differ
from the store (`ready` refuses those too). Nothing was merged. The
`GATE_DISPOSITION:` lines above the refusal are what the store holds at the pin;
the receipt and the body aren't trusted for this, because the lane writes them.
A receipt id the store lacks usually means this lander reads another
`GSTACK_STATE_ROOT` or approvals mirror than the owner recorded into, or that the row
was removed after the receipt was written. A pin missing from the clone, a shallow
clone that can't prove whether a row's head is in the pin's history, and any other
git error while checking a head also refuse here. In a full clone, a row whose head
commit is gone after a rewrite and gc just doesn't apply.
**Fix.** Read each `GATE_DISPOSITION:` line: the fingerprint, gate id, head, who
recorded it, the reason and the owner's words. If the owner did approve that gate
change, rerun with `--accept-gate-disposition <approval id>` for each one. If not,
don't accept it; the lane restores the gate. When the store can't be read, or holds
none while the receipt lists some, point `GSTACK_APPROVALS_DIR` at the directory the
owner recorded into. In a shallow clone, run `git fetch --unshallow` and rerun.
When the receipt or the body is what differs, the lane reruns
/ship and `ready` at the pin. See
[gate dispositions](ship-policy.md#gate-disposition-acceptance).
<a id="queue-land-checks"></a>
### `QUEUE_LAND_CHECKS`
@@ -6860,6 +6890,36 @@ PR whose older receipt lost its parent branch, or a depth, targeted or exception
refusal, run /ship on the new head. When `--head` is not the checked-out HEAD,
check it out and carry there.
<a id="gate-finding-open-unattended"></a>
### An unattended /ship stops on an open gate finding
**Meaning.** The review logged a loosened gate as a gate finding, and an unattended
session cannot answer `review-gate-disposition`, so `gstack-review-log` stored it
`"action":"open","actor":"unattended"` and the repeat decision printed `NEXT: stop
(a blocking finding is still open …)`. A ruling (`gstack-approvals rule`) never
closes a gate finding, and nothing the lane writes on its review row does either.
**Fix.** When the owner approved the gate change, record the owner's answer at the
stop head the review read, with the `fingerprint` and `gate_id` from the review row,
while no /ship run is open on that branch in any worktree (a parked run's marker stops
counting once its 120-minute lease ends, as it does for preflight):
```bash
gstack-approvals gate --finding '<fingerprint>' --gate-id <gate_id> --head <stop head> \
--reason '<the gate change, one line>' --words '<the owner's exact words>' --by '<who recorded>'
```
Then resume the parked /ship at the same head with no push, starting by reading
`gstack-review-read` again: the finding now reads `kept` with its `disposition`, the
row reads `CURRENT`, so the repeat decision has no `--blocking-open` for it, Step 17
leaves it out of `--gate-open` and the PR is no draft on its account. finish prints
`GATE_DISPOSITION: …`, and the PR body carries the `Gate disposition: …` line
`gstack-approvals dispositions` prints. When the owner did not approve it, restore
the gate. The lander then accepts each disposition by its approval id:
`gstack-ship-queue land --pr <n> --pin <sha> --accept-gate-disposition <id>`, or
`gstack-ship-queue verify` with the same flags before a merge that doesn't go
through land. See [gate dispositions](ship-policy.md#gate-dispositions).
<a id="fix-check-open"></a>
### `FIX_CHECK_OPEN`
@@ -7027,7 +7087,7 @@ against `gate.passed: 41`) with a different value. The receipt is written from
the run; the prose was typed, so a reader of the body would learn a number the
run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`,
`Documentation exception: …`, `Ruled note: …`, `Mechanical retake: …` and
`Documentation exception: …`, `Ruled note: …`, `Gate disposition: …`, `Mechanical retake: …` and
`Test-lane exception: …` lines the receipt requires, and the inside of a
closed code fence are never read as claims.
+2
View File
@@ -45,6 +45,8 @@ export interface ApprovalRow {
scope: string; pending: string; outcome: Outcome; source: string; branch: string; recorded_at: string;
/** Set by import when the gate was answered in an agent's own words (--words-by); lib/plan-record.ts refuses such a row as a plan approval. */
words_by?: string;
/** Set by gstack-approvals gate only: lib/gate-dispositions.ts's checksum over the row's canonical fields (catches an edit; not authentication). */
digest?: string;
}
export type MirrorSource = 'GSTACK_APPROVALS_DIR' | 'captain drive' | 'project-user drive';
/** `mirror` is written and read; `read_also` are the other mirror roots' copies, read so a row mirrored under an earlier rule stays visible. */
+222
View File
@@ -0,0 +1,222 @@
/**
* Owner gate dispositions (GST-2491, docs/ship-policy.md#gate-dispositions): the owner's recorded answer to a gate finding
* (a loosened gate the review logged with a `gate` tag and the hunk identity `gate_id`). An unattended session cannot answer
* `review-gate-disposition`, so gstack-review-log logs its gate findings `open`; the owner closes one through the approval
* record instead, with `gstack-approvals gate`, outside the lane's /ship run. A disposition is one approved row with scope
* `gate_disposition <fingerprint> <gate_id> <head>` and ask `gate_disposition <fingerprint> <gate_id> at <head>: <reason>`,
* the owner's exact words in `words`, the recorder in `by` and the time in `recorded_at`. A review-log field never makes one.
*
* A row counts only with every field the writer writes (`source: gate`, the branch of the store it is in, an approval-shaped id
* the writer's hash reproduces) and its `digest` over those fields, so a hand-typed or edited row is ignored. The digest catches
* edits; it is not authentication: a same-user lane can compute it too. What authorizes a disposition is the lander's explicit
* `--accept-gate-disposition <id>` (`gateAcceptance`, run by `gstack-ship-queue land --pin` and `verify --pin`), re-derived from
* the store at the pin, never from the receipt or the PR body (docs/ship-policy.md#gate-dispositions).
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { appendRows, locate, readRecord, type ApprovalRow, type Env, type RecordError, type StoreLocation } from './approval-record';
import type { ShipReceipt } from './headless-artifacts';
import type { ResultCodeName } from './result-codes';
import { GSTACK_ACTOR } from './ruled-notes';
import { BIN_DIR, runScriptSync } from './script-invocation';
export interface GateDisposition { finding: string; gate_id: string; head: string; by: string; reason: string; words: string; at: string; approval: string }
const SCOPE = /^gate_disposition (\S+) (\S+) ([0-9a-f]{40})$/;
const ASK = /^gate_disposition (\S+) (\S+) at ([0-9a-f]{40}): (\S(?:.*\S)?)$/;
export const GATE_DISPOSITION_SCOPE = /^\s*gate_disposition\b/;
export const ONE_LINE = /^[^\x00-\x1f\x7f]*$/;
/** The `source` only `gstack-approvals gate` writes. */
export const GATE_SOURCE = 'gate';
const APPROVAL_ID = /^a-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
/** A gate_id a gate finding can carry: one token, never a stringified missing value. */
export const validGateId = (id: unknown): id is string => typeof id === 'string' && /^\S+$/.test(id) && !/^(?:undefined|null)$/i.test(id);
/** The checksum over a disposition row's canonical fields: it catches an edited or hand-built row, and anyone can compute it. */
export const gateDispositionDigest = (r: Record<string, unknown>) => 'sha256:' + createHash('sha256')
.update(JSON.stringify(['schema_version', 'id', 'date', 'ask', 'words', 'by', 'scope', 'pending', 'outcome', 'source', 'branch', 'recorded_at'].map(k => r[k] ?? null))).digest('hex');
/** Whether a row has every field the writer writes, in the writer's shape, for the store's own branch. */
function writerShaped(r: Record<string, any>, branch: string): boolean {
const str = (v: unknown) => typeof v === 'string' && v.trim() === v && v !== '';
return r.schema_version === 1 && typeof r.id === 'string' && APPROVAL_ID.test(r.id) && r.source === GATE_SOURCE && r.outcome === 'approved' &&
r.pending === 'none' && str(r.by) && ONE_LINE.test(r.by) && !GSTACK_ACTOR.test(r.by) && str(r.words) && ONE_LINE.test(r.words) && typeof r.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(r.date) &&
typeof r.recorded_at === 'string' && ISO_TIME.test(r.recorded_at) && r.branch === branch && typeof r.ask === 'string' && typeof r.scope === 'string' &&
r.id === 'a-' + createHash('sha256').update([r.source, r.date, r.ask, r.words, r.by].join('\u0000')).digest('hex').slice(0, 12) &&
r.digest === gateDispositionDigest(r);
}
/** The key a disposition and a gate finding share: the fingerprint and the hunk identity the gate scan gave it. */
export const gateKey = (finding: unknown, gateId: unknown) => `${finding} ${gateId}`;
export const gateDispositionRow = (finding: string, gateId: string, head: string, reason: string) => ({ scope: `gate_disposition ${finding} ${gateId} ${head}`, ask: `gate_disposition ${finding} ${gateId} at ${head}: ${reason}` });
/**
* The approved disposition rows of `branch`'s store, one per finding, gate_id and head (the newest wins). A row counts only in
* the writer's full shape with its digest (`writerShaped`) and a real gate_id; a row gstack wrote is none.
*/
export function gateDispositions(rows: readonly ApprovalRow[], branch: string): GateDisposition[] {
const byFinding = new Map<string, GateDisposition>();
for (const r of rows) {
const scope = SCOPE.exec(r.scope), ask = ASK.exec(r.ask);
if (!scope || !ask || ask[1] !== scope[1] || ask[2] !== scope[2] || ask[3] !== scope[3] || !validGateId(scope[2]) || !writerShaped(r as Record<string, any>, branch)) continue;
const [, finding, gate_id, head] = scope as unknown as [string, string, string, string], key = `${finding} ${gate_id} ${head}`;
byFinding.delete(key);
byFinding.set(key, { finding, gate_id, head, by: r.by, reason: ask[4]!, words: r.words, at: r.recorded_at, approval: r.id });
}
return [...byFinding.values()];
}
/** The dispositions in the checked-out branch's approval record (and its mirror). */
export function readGateDispositions(cwd: string, env: Env = process.env): GateDisposition[] {
const loc = locate({ cwd, env });
return 'code' in loc ? [] : gateDispositions(readRecord(loc).rows, loc.branch);
}
/**
* The dispositions `branch`'s store applies at `pin`: every counted row whose head the pin contains. Throws when the store is
* unreadable, when the pin is not in this clone, or when a shallow clone cannot prove a row's head is or is not an ancestor. In a
* full clone a row whose head commit is gone (a rewritten and collected branch, a reused name) is not applied: the pin's history
* is all present, so a missing commit cannot be in it.
*/
export function appliedGateDispositions(cwd: string, o: { branch?: string; pin: string; env?: Env }): { applied: GateDisposition[]; loc: StoreLocation } {
const loc = locate({ cwd, env: o.env ?? process.env, branch: o.branch });
if ('code' in loc) throw new Error(loc.message);
const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
if (git('cat-file', '-e', `${o.pin}^{commit}`).status !== 0) throw new Error(`the pin ${o.pin.slice(0, 12)} is not a commit in this clone; fetch it and rerun`);
let shallow: boolean | undefined;
const contains = (head: string) => {
const r = git('merge-base', '--is-ancestor', head, o.pin);
if (r.status === 0) return true;
shallow ??= git('rev-parse', '--is-shallow-repository').stdout?.trim() !== 'false';
if (shallow) throw new Error(`this clone is shallow, so whether disposition head ${head.slice(0, 12)} is in the pin's history cannot be proved; deepen the clone (git fetch --unshallow) and rerun`);
if (r.status === 1 || git('cat-file', '-e', `${head}^{commit}`).status !== 0) return false;
throw new Error(`git merge-base --is-ancestor ${head.slice(0, 12)} ${o.pin.slice(0, 12)} failed (${(r.stderr ?? r.error?.message ?? '').trim().slice(0, 120) || `exit ${r.status}`})`);
};
return { applied: gateDispositions(readRecord(loc).rows, loc.branch).filter(d => contains(d.head)), loc };
}
/**
* The /ship runs open on `branch` in any worktree of this repository (found through the git common dir); a detached worktree's run
* counts too. A marker counts while its run's lease is open (`gstack-lease open`, the test preflight uses), so a run parked before
* finish stops counting once its 120-minute lease ends; a lease check that cannot run counts the marker as open.
*/
export function openShipRunsOn(cwd: string, branch: string): string[] {
const list = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd, encoding: 'utf8', timeout: 10_000 });
if (list.status !== 0) return [`unknown (git worktree list failed: ${(list.stderr ?? '').trim().slice(0, 120)})`];
const here = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return p; } };
const open: string[] = [];
for (const block of list.stdout.split(/\n\n+/).filter(b => b.trim())) {
const dir = /^worktree (.+)$/m.exec(block)?.[1];
const on = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
if (!dir || /^bare$/m.test(block) || (on !== undefined && on !== branch)) continue;
const marker = spawnSync('git', ['rev-parse', '--git-path', 'gstack-ship-run'], { cwd: dir, encoding: 'utf8', timeout: 10_000 });
if (marker.status !== 0 || !marker.stdout.trim()) continue;
let run = '';
try { run = fs.readFileSync(path.resolve(dir, marker.stdout.trim()), 'utf8').trim(); } catch { continue; }
if (!run || runScriptSync(path.join(BIN_DIR, 'gstack-lease'), ['open', '--root', dir, '--run', run], { cwd: dir, encoding: 'utf8', timeout: 60_000 }).status === 1) continue;
open.push(real(dir) === real(here) ? `${run} is open on this checkout` : `${run} is open in the worktree ${dir}`);
}
return open;
}
/** Run `fn` holding the repository's gate-disposition lock (a directory in the git common dir; one older than a minute is stale). */
export function withGateLock<T>(cwd: string, fn: () => T, waitMs = 15_000): T | RecordError {
const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', timeout: 10_000 });
if (common.status !== 0 || !common.stdout.trim()) return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `no git common dir at ${cwd}, so the gate-disposition lock cannot be taken` };
const lock = path.join(path.resolve(cwd, common.stdout.trim()), 'gstack-gate-disposition.lock');
const until = Date.now() + waitMs;
for (;;) {
try { fs.mkdirSync(lock); break; } catch (e: any) {
if (e.code !== 'EEXIST') return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${lock}: ${e.code ?? e.message}` };
try { if (Date.now() - fs.statSync(lock).mtimeMs > 60_000) { fs.rmSync(lock, { recursive: true, force: true }); continue; } } catch { continue; }
if (Date.now() > until) return { code: 'APPROVAL_RECORD_WRITE_FAILED', message: `${lock} is held; another gate disposition or /ship preflight is writing (rerun in a moment)` };
Bun.sleepSync(100);
}
}
try { return fn(); } finally { fs.rmSync(lock, { recursive: true, force: true }); }
}
/** Append one disposition row under the lock, refused while any /ship run is open on its branch in any worktree (the check and the append are one step). */
export function recordGateDisposition(cwd: string, loc: StoreLocation, row: ApprovalRow): ReturnType<typeof appendRows> {
const signed = { ...row, digest: gateDispositionDigest(row as unknown as Record<string, unknown>) } as ApprovalRow;
if (!gateDispositions([signed], loc.branch).length) return { code: 'APPROVAL_RECORD_INVALID', message: 'the row would not count as a gate disposition (gstack-approvals dispositions would not list it: a 40-hex --head and one-line fields are required), so it would close nothing; nothing was written' };
const held = withGateLock(cwd, () => {
const open = openShipRunsOn(cwd, loc.branch);
if (open.length) return { code: 'APPROVAL_RECORD_INVALID' as const, message: `/ship run ${open.join('; /ship run ')} (branch ${loc.branch}): a gate disposition is the owner's, never the lane's own runner's; record it from the owner's session once no /ship run is open on the branch` };
return appendRows(loc, [signed]);
});
return held;
}
export type GateAcceptanceCode = Extract<ResultCodeName, 'QUEUE_GATE_DISPOSITION'>;
/**
* The lander's check (docs/ship-policy.md#gate-dispositions), one function for `land --pin`, `verify --pin` and `ready`: the
* dispositions `branch`'s store applies at `pin`, re-derived here, each printed as GATE_DISPOSITION. It refuses when the
* receipt's gate_dispositions or the PR body's `Gate disposition:` lines differ from them, and, with `accepted` (land and
* verify; ready passes none), when an applied disposition is not accepted or an accepted id is not applied.
*/
export function gateAcceptance(o: { cwd: string; branch: string; pin: string; body: string; receipt?: ShipReceipt; accepted?: readonly string[]; env?: Env }):
{ lines: string[]; applied: GateDisposition[]; failure?: { code: GateAcceptanceCode; message: string; fix: string } } {
const fix = 'the lander reads each GATE_DISPOSITION line and reruns with --accept-gate-disposition <approval id> for each one it accepts; a receipt or body that differs from the store is rewritten by /ship at the pin (docs/troubleshooting.md#queue-gate-disposition)';
let got: ReturnType<typeof appliedGateDispositions>;
try { got = appliedGateDispositions(o.cwd, o); }
catch (e: any) { return { lines: [], applied: [], failure: { code: 'QUEUE_GATE_DISPOSITION', message: `the gate dispositions for ${o.branch} at the pin cannot be derived (${e.message}); an unreadable store or a git error refuses, never reads as none`, fix } }; }
const { applied, loc } = got;
const recorded = o.receipt?.gate_dispositions;
const listed = recorded ?? [];
const where = `the store at ${loc.mirror ?? loc.file}`;
const lines = applied.map(d => `GATE_DISPOSITION: ${gateDispositionText(d)}`);
if (!applied.length && !listed.length && !o.accepted?.length) return { lines, applied };
const problems: string[] = [];
for (const d of applied) {
const r = listed.find(l => l.approval === d.approval);
if (!r) problems.push(recorded ? `the receipt omits applied disposition ${d.approval}` : `the receipt has no gate_dispositions field (written before receipts always recorded it, or edited), but ${where} applies disposition ${d.approval}`);
else if (gateDispositionText(r as GateDisposition) !== gateDispositionText(d)) problems.push(`the receipt's disposition ${d.approval} differs from the store's row`);
if (!o.body.split(/\r?\n/).some(b => b.trim() === `Gate disposition: ${gateDispositionText(d)}`)) problems.push(`the PR body lacks the line "Gate disposition: ${gateDispositionText(d)}"`);
}
for (const l of listed) if (!applied.some(d => d.approval === l.approval)) problems.push(`the receipt lists disposition ${l.approval}, which ${where} does not apply at ${o.pin.slice(0, 12)}: this lander may read another GSTACK_STATE_ROOT or approvals mirror than the one the owner recorded into (set GSTACK_APPROVALS_DIR to it), or the row was removed after the receipt was written`);
if (o.accepted) {
for (const d of applied) if (!o.accepted.includes(d.approval)) problems.push(`disposition ${d.approval} is not accepted (--accept-gate-disposition ${d.approval})`);
for (const id of o.accepted) if (!applied.some(d => d.approval === id)) problems.push(`--accept-gate-disposition ${id} names no disposition the store applies at ${o.pin.slice(0, 12)}`);
}
lines.push(`GATE_DISPOSITIONS: applied=${applied.map(d => d.approval).join(',') || 'none'}${o.accepted ? ` accepted=${o.accepted.join(',') || 'none'}` : ''} store=${loc.mirror ?? loc.file}`);
return problems.length ? { lines, applied, failure: { code: 'QUEUE_GATE_DISPOSITION', message: problems.join('; '), fix } } : { lines, applied };
}
/** The one-line form finish prints after `GATE_DISPOSITION: ` and the PR body carries after `Gate disposition: `. */
export const gateDispositionText = (d: GateDisposition) => `${d.finding} [gate ${d.gate_id}] at ${d.head.slice(0, 12)} by ${d.by} at ${d.at}: ${d.reason} — "${d.words}" (approval ${d.approval})`;
/**
* The dispositions that close a review row's gate findings: the same fingerprint and gate_id on a gate finding the logger left
* `open` (or that gstack-review-read already shows kept for a non-interactive actor, which only a disposition produces), recorded
* at a head inside the row's `range_base..commit_full` (both ends included, the test rulings and review_loop_grant use), or at the
* row's own commit when the row has no range_base (a first pass). The approval row decides; nothing on the review row counts.
*/
export function dispositionedOn(cwd: string, row: Record<string, any>, dispositions: readonly GateDisposition[]): GateDisposition[] {
const open = new Set((Array.isArray(row?.findings) ? row.findings : [])
.filter((f: any) => !!f && typeof f.gate === 'string' && f.gate && typeof f.fingerprint === 'string' && validGateId(f.gate_id) && (f.action === 'open' || (f.action === 'kept' && f.actor !== 'interactive')))
.map((f: any) => gateKey(f.fingerprint, f.gate_id)));
const mine = dispositions.filter(d => open.has(gateKey(d.finding, d.gate_id)));
if (!mine.length) return [];
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,40}$/.test(c) ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${c}^{commit}`], { cwd, encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '' : '';
const ancestor = (a: string, d: string) => spawnSync('git', ['merge-base', '--is-ancestor', a, d], { cwd, timeout: 10_000 }).status === 0;
const to = commitOf(row.commit_full), from = row.range_base == null ? '' : commitOf(row.range_base);
if (!to || (row.range_base != null && !from)) return [];
return mine.filter(d => from ? ancestor(from, d.head) && ancestor(d.head, to) : d.head === to);
}
/**
* gstack-review-read's view of a row the owner's dispositions close: each such gate finding reads `kept` with the disposition's
* reason and approval id, so /ship's existing gate rules (`--blocking-open`, `--gate-open`, the draft state, the Gate edits
* counts) treat it as kept. The review log itself keeps `open`.
*/
export function showDispositioned(row: Record<string, any>, closed: readonly GateDisposition[]): void {
if (!closed.length || !Array.isArray(row.findings)) return;
row.gate_dispositions = closed.map(d => d.approval);
for (const f of row.findings) {
const d = f && typeof f.gate === 'string' && f.gate ? closed.find(c => gateKey(c.finding, c.gate_id) === gateKey(f.fingerprint, f.gate_id)) : undefined;
if (d) Object.assign(f, { action: 'kept', reason: `owner disposition ${d.approval}: ${d.reason}`, disposition: d.approval });
}
}
+3
View File
@@ -128,6 +128,8 @@ export interface ShipReceipt {
review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check?: { head: string; findings: number; files?: string[] }; second_pass?: { head: string; deferred: string[] } };
/** The owner's rulings that a finding ships as a disclosed note (gstack-approvals rule, lib/ruled-notes.ts); the PR body lists each one word for word (GST-1537). */
ruled_notes?: Array<{ finding: string; head: string; by: string; reason: string; at: string; approval: string }>;
/** The owner's dispositions of gate findings (gstack-approvals gate, lib/gate-dispositions.ts); the PR body lists each one word for word (GST-2491). */
gate_dispositions?: Array<{ finding: string; gate_id: string; head: string; by: string; reason: string; words: string; at: string; approval: string }>;
/** The `Mechanical retake: <path> (<what>)` lines /ship finish proved for this head's tree (lib/ship-mechanical.ts); the PR body lists each one word for word (GST-1560). */
mechanical?: string[];
/** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */
@@ -277,6 +279,7 @@ export const ARTIFACT_SCHEMAS = {
review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } }, second_pass: { type: 'object', required: ['head', 'deferred'], properties: { head: str, deferred: strList } } } },
ruled_notes: { type: 'array', items: { type: 'object', required: ['finding', 'head', 'by', 'reason', 'at', 'approval'], properties: { finding: str, head: str, by: str, reason: str, at: str, approval: str } } },
gate_dispositions: { type: 'array', items: { type: 'object', required: ['finding', 'gate_id', 'head', 'by', 'reason', 'words', 'at', 'approval'], properties: { finding: str, gate_id: str, head: str, by: str, reason: str, words: str, at: str, approval: str } } },
mechanical: strList,
design: { type: 'object', required: ['mode', 'reason'], properties: { mode: { enum: ['static'] }, reason: str } },
no_stop: { type: 'array', items: { type: 'object', required: ['class', 'reason', 'evidence'], properties: { class: { enum: [...NO_STOP_CLASSES] }, reason: str, evidence: str } } },
+1 -1
View File
@@ -42,7 +42,7 @@ export interface PlanRecord { line: string; state: 'approved' | 'exempt'; ref?:
export interface PlanFailure { code: ResultCodeName; message: string; fix: string }
export type PlanResolution = { ok: true; record: PlanRecord; summary: string } | { ok: false; failure: PlanFailure };
const NOT_PLAN_ROW = /\b(ruled_note|review_loop_grant|review_loop_exception|test_lane_exception)\b/;
const NOT_PLAN_ROW = /\b(ruled_note|gate_disposition|review_loop_grant|review_loop_exception|test_lane_exception)\b/;
const ROW_ID = /^a-[0-9a-f]{12}$/;
const RECORD_ID = /^r-[0-9a-f]{12}$/;
const LOCAL_SOURCE = 'state-root config';
+5
View File
@@ -513,6 +513,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'the PR head is not the pin land was given, so the READY it was given does not describe what would merge',
fix: 'the lane runs /ship and gstack-ship-queue ready --pr <n> --no-screen at the new head and reports READY with it; land that pin',
},
QUEUE_GATE_DISPOSITION: {
anchor: 'queue-gate-disposition',
summary: 'the owner gate dispositions the approval store applies at the pin are not all accepted by the lander, an accepted id is not applied, or the receipt or PR body differs from the store',
fix: 'read each GATE_DISPOSITION line, then rerun land (or verify) with --accept-gate-disposition <approval id> for each one you accept; when the receipt or body differs from the store, the lane reruns /ship and ready at the pin',
},
QUEUE_LAND_CHECKS: {
anchor: 'queue-land-checks',
summary: 'a check at the pin is red or pending, or a required check never ran there',
+8 -6
View File
@@ -348,18 +348,20 @@ export const holdsOpen = (f: unknown) => !record(f) || unresolvedFinding(f) || f
* rest leave nothing unresolved and the findings account for every claimed issue and critical; otherwise unchanged. An open
* finding is rulable too: a fix-check that finds a CRITICAL stops for the owner (review-army decision 3.5 sends it to decision 2)
* with the finding open and the row converged:false, bound `incomplete` on unchanged content, and the ruling is the owner's
* answer to that stop, so a row the rulings clear binds at the tree it read (GST-2251, harness-evals#361).
* answer to that stop, so a row the rulings clear binds at the tree it read (GST-2251, harness-evals#361). A gate finding is
* taken out the same way only when `gated` holds its `<fingerprint> <gate_id>` key (the owner's gate_disposition rows whose
* head lies in this row's range, lib/gate-dispositions.ts, GST-2491); a ruling never closes a gate finding.
*/
function withoutRuledNotes(rec: Record<string, any>, ruled: ReadonlySet<string>): Record<string, any> {
const isRuled = (f: unknown) => record(f) && !f.gate && RULABLE.has(f.action) && typeof f.fingerprint === 'string' && ruled.has(f.fingerprint);
if (!ruled.size || rec.status_source === 'derived' || rec.completed !== true || !Array.isArray(rec.findings) ||
function withoutRuledNotes(rec: Record<string, any>, ruled: ReadonlySet<string>, gated: ReadonlySet<string>): Record<string, any> {
const isRuled = (f: unknown) => record(f) && typeof f.fingerprint === 'string' && (f.gate ? (f.action === 'open' || f.action === 'kept') && gated.has(`${f.fingerprint} ${f.gate_id}`) : RULABLE.has(f.action) && ruled.has(f.fingerprint));
if ((!ruled.size && !gated.size) || rec.status_source === 'derived' || rec.completed !== true || !Array.isArray(rec.findings) ||
!rec.findings.some(isRuled) || rec.findings.length < Number(rec.issues_found ?? 0) ||
rec.findings.filter(f => record(f) && isCriticalFinding(f)).length < Number(rec.critical ?? 0) ||
rec.findings.some(f => !isRuled(f) && holdsOpen(f))) return rec;
return { ...rec, status: 'clean', issues_found: 0, critical: 0, informational: 0, findings: rec.findings.filter(f => !isRuled(f)) };
}
export function reviewFreshness(rec: Record<string, any>, currentWtree: string, ruled: ReadonlySet<string> = new Set()): { status: string; reason: string } | undefined {
export function reviewFreshness(rec: Record<string, any>, currentWtree: string, ruled: ReadonlySet<string> = new Set(), gated: ReadonlySet<string> = new Set()): { status: string; reason: string } | undefined {
if (!DIFF_REVIEWS.has(rec.skill)) return;
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
const binding = rec.review_binding;
@@ -368,7 +370,7 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string,
return { status: 'STALE', reason: `content changed during review: the tree moved from ${short(binding.start_wtree)} to ${short(binding.end_wtree)} while ${rec.skill} ran (an edit, commit or merge landed before its row was logged), so the record cannot bind. Repair: freeze the checkout until every reviewer returns, then rerun the steps 11.5 names on the frozen tree; when the edit was your own fix, log it as a fix so gstack-review-plan picks the repeat depth (docs/troubleshooting.md#review-content-changed)` };
}
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
const cleared = withoutRuledNotes(rec, ruled);
const cleared = withoutRuledNotes(rec, ruled, gated);
const ruledStop = cleared !== rec && binding?.state === 'incomplete' && rec.converged === false;
const wtree = ruledStop ? binding.end_wtree : rec.wtree;
if ((binding?.state !== 'verified' && !ruledStop) || rec.completed !== true || (rec.converged !== true && !ruledStop) ||
+5 -1
View File
@@ -15,6 +15,7 @@ import { buildRunCheck } from './pregate/run-check';
import { classifyDiff } from './review-depth';
import { wtreeFingerprint } from './script-invocation';
import { audit } from './ship-audit';
import { withGateLock } from './gate-dispositions';
import { bin, carryNoneLines, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
@@ -116,7 +117,10 @@ function integrate(cwd: string, base: string, run: string | undefined, r: Report
else r.add(`SCOPE: frontend=${/SCOPE_FRONTEND=true/.test(scope)}${/SCOPE_FRONTEND=true/.test(scope) ? ' (Step 9 runs the static design lite check only, no preview declared; mention /design-review when no design review exists)' : ''}`);
if (!merge(cwd, base, r)) return false;
const marker = run && shipRunMarker(cwd);
try { if (marker) fs.writeFileSync(marker, `${run}\n`); } catch (e) { r.stop(`cannot record /ship run ${run} in ${marker} (${firstLine((e as Error).message)}): the base is merged, but a later full preflight could merge again under this run's reviews; fix that, then rerun`); return false; }
try {
const locked = marker ? withGateLock(cwd, () => fs.writeFileSync(marker, `${run}\n`)) : undefined;
if (locked && 'code' in locked) throw new Error(locked.message);
} catch (e) { r.stop(`cannot record /ship run ${run} in ${marker} (${firstLine((e as Error).message)}): the base is merged, but a later full preflight could merge again under this run's reviews; fix that, then rerun`); return false; }
return true;
}
+40 -4
View File
@@ -27,9 +27,45 @@ import { acquireLandLock, lockLabel, ownsLock, readLock, type HeldLandLock, type
import * as os from 'node:os';
import { activeFreeze, loadReady, persist, rebuildFromNotes, repoIdentity, type FreezeRecord } from '../crew/lib/ship-queue-train-record';
import { bindShippedReceipt } from './ship-receipt-depth';
import { gateAcceptance } from './gate-dispositions';
interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; title?: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null }
export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; allowRepoCommands?: boolean; breakLock?: boolean; repair?: boolean; now?: () => Date }
export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; allowRepoCommands?: boolean; breakLock?: boolean; repair?: boolean; now?: () => Date; acceptGateDispositions?: readonly string[] }
/**
* The pin's /ship receipt bound to it, and every owner gate disposition the approval store applies at the pin accepted by the
* lander (lib/gate-dispositions.ts gateAcceptance, re-derived from the store, never from the receipt or the body): the one
* check land --pin and verify --pin share, so the two cannot drift (docs/ship-policy.md#gate-dispositions).
*/
export function pinAcceptance(ctx: QueueContext, info: Pick<DirectPr, 'body' | 'headRefName'>, pr: string, pin: string, accepted: readonly string[]): { lines: string[]; failure?: QueueFailure } {
const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', pr, [pin]);
const lines = [receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : [])];
if (!receipt.ok) return { lines, failure: { code: receipt.code, message: receipt.message, fix: `run /ship at the pin, then gstack-ship-queue ready --pr ${pr}` } };
const gates = gateAcceptance({ cwd: ctx.repoRoot, branch: info.headRefName, pin, body: info.body ?? '', receipt: receipt.receipt, accepted });
return { lines: [...lines, ...gates.lines], ...(gates.failure ? { failure: gates.failure } : {}) };
}
/**
* `gstack-ship-queue verify --pr <n> --pin <sha> [--accept-gate-disposition <id>]...`: land's pre-merge acceptance for a lander
* that merges some other way (a pinned REST merge). Read-only: it fetches, binds the receipt and checks the gate dispositions
* with pinAcceptance, prints each GATE_DISPOSITION line, and exits 0 only when every one is accepted. It merges nothing.
*/
export function verifyPin(ctx: QueueContext, o: { pr: string; pin: string; acceptGateDispositions: readonly string[] }): StepOutput {
const root = ctx.repoRoot, run = 'none';
const fail = (lines: string[], failure: QueueFailure): StepOutput => ({ ok: false, lines, failure, run });
if (!git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return fail([], { code: 'QUEUE_NOT_READY', message: `cannot fetch origin/${ctx.base}`, fix: 'fix the fetch, then rerun verify' });
const info = view(ctx, o.pr);
if ('code' in info) return fail([], info);
const lines = [`VERIFY_PIN: #${o.pr} pin=${o.pin} (read-only; merges nothing)`];
if (info.isCrossRepository) return fail(lines, { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head` });
if (info.state !== 'OPEN') return fail(lines, { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` });
if (info.headRefOid !== o.pin) return fail(lines, { code: 'QUEUE_LAND_PIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the pin ${o.pin.slice(0, 12)}` });
if (!havePin(root, o.pr, o.pin, info.headRefName)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot fetch the pin ${o.pin.slice(0, 12)} from ${info.headRefName} or refs/pull/${o.pr}/head`, fix: 'fix the fetch, then rerun verify' });
const accepted = pinAcceptance(ctx, info, o.pr, o.pin, o.acceptGateDispositions);
lines.push(...accepted.lines);
if (accepted.failure) return fail(lines, accepted.failure);
return { ok: true, run, lines: [...lines, `VERIFY_PIN: ok #${o.pr} at ${o.pin.slice(0, 12)}: the receipt is bound to the pin and every gate disposition the store applies there is accepted`] };
}
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
@@ -161,9 +197,9 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput {
rebuildFromNotes(root, ctx.remote ?? 'origin');
if (release) lines.push(`RELEASE: v${release.version} folds ${release.fragments.length} fragment(s) and changes nothing but the stamp, so it needs no /ship receipt or READY note`);
else {
const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [o.pin]);
lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), ...(receipt.targetedLine ? [receipt.targetedLine] : []));
if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: `run /ship at the pin, then gstack-ship-queue ready --pr ${o.pr}` });
const accepted = pinAcceptance(ctx, info, o.pr, o.pin, o.acceptGateDispositions ?? []);
lines.push(...accepted.lines);
if (accepted.failure) return fail(lines, accepted.failure);
const readyNote = loadReady(keys, o.pr, o.pin);
if (!readyNote) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY note for #${o.pr} at the pin ${o.pin.slice(0, 12)}`, fix: `the lane runs gstack-ship-queue ready --pr ${o.pr} --no-screen at the pin` });
const plan = landPlan({ env: process.env, policyValue: policy.plan_record, record: readyNote.plan, body: info.body ?? '' });
+5 -1
View File
@@ -43,6 +43,7 @@ import {
import { wtreeFingerprint } from './script-invocation';
import { nowIso, persist, readyNoteUnpushed, readyPath, repoIdentity } from '../crew/lib/ship-queue-train-record';
import { buildReceipt, type TreeReceipt } from './tree-receipt';
import { gateAcceptance } from './gate-dispositions';
export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string };
export interface QueueContext { repoRoot: string; base: string; gh: Gh; remote?: string }
@@ -344,6 +345,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
const foreign = accepted && exceptionConsumerProblem(accepted, head, o.pr);
if (foreign) { pregateOk = false; accepted = null; pregateLine += ` — ${foreign} (PREGATE_REMOTE_UNCLEARED)`; }
const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const gates = receipt.ok ? gateAcceptance({ cwd: ctx.repoRoot, branch: info.headRefName ?? '', pin: head, body: info.body ?? '', receipt: receipt.receipt }) : null;
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
const lines = [
@@ -361,7 +363,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
receipt.line,
...(receipt.depthLine ? [receipt.depthLine] : []),
...(receipt.targetedLine ? [receipt.targetedLine] : []),
...readyTailLines(receipt.ok ? receipt.receipt : undefined),
...(gates?.lines ?? []),
...readyTailLines(receipt.ok ? { ...receipt.receipt, gate_dispositions: undefined } : undefined),
];
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
if (gate) lines.push(gate.line);
@@ -369,6 +372,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
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);
if (gates?.failure) return fail(gates.failure, lines, run);
const changed = git(ctx.repoRoot, ['diff', '--name-only', `${baseSha}...${head}`]).out.split('\n').filter(Boolean);
const plan: ReturnType<typeof readyPlan> = policy.policy.train ? {} : readyPlan({ cwd: ctx.repoRoot, env: process.env, body: info.body ?? '', policyValue: policy.policy.plan_record, changed, fragmentsDir: policy.policy.changelog_fragments?.dir });
if (plan.line) lines.push(plan.line);
+3 -1
View File
@@ -10,6 +10,7 @@ import { RECEIPT_FENCE } from './ship-receipt';
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
import { ruledNoteText, type RuledNote } from './ruled-notes';
import { gateDispositionText, type GateDisposition } from './gate-dispositions';
import { laneExceptionBodyLines } from './test-lane-exception';
export interface ProseClaim { field: string; prose: number; receipt: number }
@@ -97,7 +98,7 @@ function withoutApprovalsBlocks(body: string): string {
/**
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …`, `Mechanical retake: …` and `Test-lane exception: …` line (GST-1537, GST-1560, GST-1595). write and the receipt check want each word for word,
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …`, `Gate disposition: …`, `Mechanical retake: …` and `Test-lane exception: …` line (GST-1537, GST-2491, GST-1560, GST-1595). write and the receipt check want each word for word,
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
* prose. A marker line takes its place so the text on either side never joins into a claim.
@@ -110,6 +111,7 @@ function withoutExceptionLines(body: string, receipt: Record<string, unknown>):
const docsLine = docs?.ok ? docsExceptionLine(docs.docs) : null;
if (docsLine) lines.add(docsLine);
if (Array.isArray(receipt.ruled_notes)) for (const n of receipt.ruled_notes) lines.add(`Ruled note: ${ruledNoteText(n as RuledNote)}`);
if (Array.isArray(receipt.gate_dispositions)) for (const d of receipt.gate_dispositions) lines.add(`Gate disposition: ${gateDispositionText(d as GateDisposition)}`);
if (Array.isArray(receipt.mechanical)) for (const l of receipt.mechanical) if (typeof l === 'string') lines.add(l);
for (const l of laneExceptionBodyLines(receipt.ci_carried)) lines.add(l);
if (!lines.size) return body;
+3 -1
View File
@@ -14,6 +14,7 @@ import { FRAGMENT_README } from './changelog-fragments';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { ruledNoteText } from './ruled-notes';
import { gateDispositionText } from './gate-dispositions';
import { laneExceptionBodyLines } from './test-lane-exception';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine, type BranchSide } from './stamp-only';
@@ -23,7 +24,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'ruled_notes', 'mechanical', 'review_targeted', 'design', 'review_exclude', 'carry', 'base_red', 'flaky_exception', 'no_stop',
'review_loop_exception', 'ruled_notes', 'gate_dispositions', 'mechanical', 'review_targeted', 'design', 'review_exclude', 'carry', 'base_red', 'flaky_exception', 'no_stop',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -215,6 +216,7 @@ export function readyTailLines(r: ShipReceipt | undefined): string[] {
return [
loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt',
...(r?.ruled_notes ?? []).map(n => `RULED_NOTE: ${oneLine(ruledNoteText(n))}`),
...(r?.gate_dispositions ?? []).map(d => `GATE_DISPOSITION: ${oneLine(gateDispositionText(d))}`),
...laneExceptionBodyLines(r?.ci_carried).map(l => `TEST_LANE_EXCEPTION: ${oneLine(l.slice('Test-lane exception: '.length))}`),
'TRIAL_MERGES: <one line per open sibling pin that shares a file: its git merge-tree result and the resolution, or "none share a file">',
'Attention:',
+12 -5
View File
@@ -26,6 +26,7 @@ import { isCriticalFinding, isOpenByAction } from './headless-artifacts';
import { fingerprintPath, normalizePath } from './review-scope';
import { forbidSpecs } from './review-fetch';
import { readRuledNotes, ruledNoteText } from './ruled-notes';
import { dispositionedOn, gateDispositionText, gateKey, readGateDispositions } from './gate-dispositions';
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
@@ -210,7 +211,9 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
* row, chained through earlier rows back to the reviewed tree, each under its own approved review_loop_grant): the owner's
* granted fix-check after Step 11 is a review (GST-1130, GST-1627). A finding on a link the owner ruled a disclosed note (an
* approved gstack-approvals rule row whose stop head lies inside that link's range_base..commit, never the row's own claim) is closed, and every ruling
* prints as RULED_NOTE (GST-1537). Authored docs keep the stage 2.2 recheck.
* prints as RULED_NOTE (GST-1537). An open gate finding the owner dispositioned (an approved gstack-approvals gate row at its
* fingerprint, head in range, lib/gate-dispositions.ts) is closed the same way, and each prints as GATE_DISPOSITION (GST-2491).
* Authored docs keep the stage 2.2 recheck.
*/
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'bounded' | 'mechanical' | 'accepted' | 'fix_checked' | 'sidecar', string[]>;
@@ -288,8 +291,9 @@ function docsBridge(cwd: string, from: string, o: BridgeOpts): { reviewedAt: str
* or its patch against the base is still the one the link read.
*/
function fixChecked(cwd: string, o: BridgeOpts & { now: string; parent?: string; currentBase?: string; candidates: string[] }, r: Report): { counted: boolean; paths: string[] } | null {
const rows: Record<string, any>[] = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n')
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } });
const reviewRows: Record<string, any>[] = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n')
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review' || v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } });
const rows = reviewRows.filter(v => v.skill === 'review-fix-check');
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : '';
const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
const onBranch = (c: string) => !!c && ancestor(c, 'HEAD');
@@ -308,6 +312,9 @@ function fixChecked(cwd: string, o: BridgeOpts & { now: string; parent?: string;
const inRange = (l: FixLink, c: string) => !!l.from && !!l.commit && ancestor(l.from, c) && ancestor(c, l.commit);
const rulings = readRuledNotes(cwd).map(n => ({ ...n, links: chain.filter(l => inRange(l, n.head)) }));
for (const n of rulings) r.add(`RULED_NOTE: ${ruledNoteText(n)}${chain.length && !n.links.length ? ' (outside this fix-check\'s range; not applied)' : ''}`);
const gates = readGateDispositions(cwd);
for (const d of gates) r.add(`GATE_DISPOSITION: ${gateDispositionText(d)}${reviewRows.some(v => dispositionedOn(cwd, v, [d]).length) ? '' : ' (no open gate finding at this fingerprint on a review row whose range holds this head; not applied)'}`);
const gatedOn = (l: FixLink) => new Set(dispositionedOn(cwd, l.row, gates).map(d => gateKey(d.finding, d.gate_id)));
const grants = chain.length ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })).filter(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant')) : [];
const granted: (typeof grants[number] | undefined)[] = [];
const used = new Set<string>();
@@ -322,7 +329,7 @@ function fixChecked(cwd: string, o: BridgeOpts & { now: string; parent?: string;
const ruledOn = (l: FixLink) => new Set(rulings.filter(n => n.links.includes(l)).map(n => n.finding));
const why = (l: FixLink) => {
const read = l.row.wtree ?? l.row.review_binding?.end_wtree;
const fresh = reviewFreshness(l.row, read, ruledOn(l));
const fresh = reviewFreshness(l.row, read, ruledOn(l), gatedOn(l));
return fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row'
: l.row.tree !== read || !onBranch(l.commit) ? 'the row was logged on content no branch commit holds'
: !onBranch(l.from) ? 'the row names no range_base commit on this branch' : '';
@@ -332,7 +339,7 @@ function fixChecked(cwd: string, o: BridgeOpts & { now: string; parent?: string;
const clean = new Set(chain.filter(l => !whys.get(l)));
const flagged = (l: FixLink): string[] | null => {
const b = l.row.review_binding, findings: Record<string, any>[] = Array.isArray(l.row.findings) ? l.row.findings.filter((f: unknown) => !!f && typeof f === 'object') : [];
const paths = findings.filter(f => (f.blocking === true || isCriticalFinding(f) || isOpenByAction(f)) && !ruledOn(l).has(f.fingerprint))
const paths = findings.filter(f => (f.blocking === true || isCriticalFinding(f) || isOpenByAction(f)) && !ruledOn(l).has(f.fingerprint) && !gatedOn(l).has(gateKey(f.fingerprint, f.gate_id)))
.map(f => typeof f.path === 'string' ? f.path : fingerprintPath(f.fingerprint)).map(p => p && normalizePath(p));
const bound = l.row.completed === true && l.row.scope !== 'fix-range' && (b?.state === 'incomplete' || b?.state === 'verified') && !!l.row.tree && b.start_wtree === l.row.tree && b.end_wtree === l.row.tree;
const accounted = Array.isArray(l.row.findings) && findings.length >= Number(l.row.issues_found ?? 0) && findings.filter(isCriticalFinding).length >= Number(l.row.critical ?? 0);
+4
View File
@@ -419,6 +419,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/ship-finish-ruled-notes.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-approvals, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42139467391)',
},
{
file: 'test/ship-gate-dispositions.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-approvals, gstack-review-log, gstack-review-read, gstack-ship-receipt, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-finish-ruled-notes.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 42139467391)',
},
{
file: 'test/ship-accept-head-policy.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
+2 -2
View File
@@ -116,7 +116,7 @@ ZERO-RUN lane never reaches this body unresolved.>
## Pre-Landing Review
<findings from Step 9 code review, or "No issues found."; then Step 9.4's `REVIEW_LOOP:` line.>
<Each `Ruled note: …` line `gstack-approvals rulings` prints, verbatim: a finding the owner ruled a disclosed note,
with its ruler and reason; finish's `MECHANICAL: x` as `Mechanical retake: x` and its `UID_SIDECAR: x (beside y)` as `Godot UID sidecar: x (beside y)`. `gstack-ship-receipt write --body` refuses a body missing one.>
with its ruler and reason; each `Gate disposition: …` line `gstack-approvals dispositions` prints, verbatim; finish's `MECHANICAL: x` as `Mechanical retake: x` and its `UID_SIDECAR: x (beside y)` as `Godot UID sidecar: x (beside y)`. `gstack-ship-receipt write --body` refuses a body missing one.>
<Gate edits: Step 9's latest `Gate edits:` line verbatim — `none detected (...)`,
`N listed, M read, K findings — J kept, R restored, O open`, `partial (...)` or
`UNAVAILABLE — ...`; never `none detected` for partial or UNAVAILABLE. Under it, each
@@ -194,7 +194,7 @@ the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.>
<details><summary>Agent notes</summary>
<Machine-read lines, last: `Refs <issue codes>`, the `## Approvals` output (Capy) with its Plan line,
and the Pre-Landing Review section's `REVIEW_LOOP:`, `Gate edits:`, `Ruled note:`, `Review-loop exception:`
and the Pre-Landing Review section's `REVIEW_LOOP:`, `Gate edits:`, `Ruled note:`, `Gate disposition:`, `Review-loop exception:`
and exception lines. `gstack-ship-receipt write` adds the receipt here; never type it.>
</details>
+2 -2
View File
@@ -107,7 +107,7 @@ ZERO-RUN lane never reaches this body unresolved.>
## Pre-Landing Review
<findings from Step 9 code review, or "No issues found."; then Step 9.4's `REVIEW_LOOP:` line.>
<Each `Ruled note: …` line `gstack-approvals rulings` prints, verbatim: a finding the owner ruled a disclosed note,
with its ruler and reason; finish's `MECHANICAL: x` as `Mechanical retake: x` and its `UID_SIDECAR: x (beside y)` as `Godot UID sidecar: x (beside y)`. `gstack-ship-receipt write --body` refuses a body missing one.>
with its ruler and reason; each `Gate disposition: …` line `gstack-approvals dispositions` prints, verbatim; finish's `MECHANICAL: x` as `Mechanical retake: x` and its `UID_SIDECAR: x (beside y)` as `Godot UID sidecar: x (beside y)`. `gstack-ship-receipt write --body` refuses a body missing one.>
<Gate edits: Step 9's latest `Gate edits:` line verbatim — `none detected (...)`,
`N listed, M read, K findings — J kept, R restored, O open`, `partial (...)` or
`UNAVAILABLE — ...`; never `none detected` for partial or UNAVAILABLE. Under it, each
@@ -185,7 +185,7 @@ the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.>
<details><summary>Agent notes</summary>
<Machine-read lines, last: `Refs <issue codes>`, the `## Approvals` output (Capy) with its Plan line,
and the Pre-Landing Review section's `REVIEW_LOOP:`, `Gate edits:`, `Ruled note:`, `Review-loop exception:`
and the Pre-Landing Review section's `REVIEW_LOOP:`, `Gate edits:`, `Ruled note:`, `Gate disposition:`, `Review-loop exception:`
and exception lines. `gstack-ship-receipt write` adds the receipt here; never type it.>
</details>
+1 -1
View File
@@ -145,7 +145,7 @@ describe('E11: the documentation audit record and the doc caps', () => {
const receipt = extractReceipt(fs.readFileSync(body, 'utf8'));
expect(receipt.errors).toEqual([]);
expect((receipt.receipt as any).docs).toEqual({ status: 'blocked', scope: 'docs/', exception: { accepted_by: 'owner', date: '2026-10-26', via: 'PR #9 review', reason: 'removed page republished in the next release' } });
expect(Object.keys(receipt.receipt!).slice(-2)).toEqual(['docs', 'review_loop']);
expect(Object.keys(receipt.receipt!).slice(-3)).toEqual(['docs', 'review_loop', 'gate_dispositions']);
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--body', body]).status).toBe(EXIT.ok);
const malformed = runBin(repo, 'gstack-ship-receipt', [...common, '--docs', '{"status":"done","scope":"docs/"}']);
expect(malformed.status).toBe(EXIT.fail);
+2 -2
View File
@@ -48,7 +48,7 @@ describe('gstack-ship-receipt write', () => {
const parsed = extractReceipt(ok.out);
expect(parsed.found).toBe(true);
expect(parsed.errors).toEqual([]);
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']);
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'gate_dispositions']);
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}'));
expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/);
@@ -714,7 +714,7 @@ describe('review_loop receipt input (harness lane E: the factory scorecard count
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ pr: 42, review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
const written = extractReceipt(fs.readFileSync(loopBody, 'utf8'));
expect(written.errors).toEqual([]);
expect(Object.keys(written.receipt!).slice(-1)).toEqual(['review_loop']);
expect(Object.keys(written.receipt!).slice(-2)).toEqual(['review_loop', 'gate_dispositions']);
});
test('parseReviewLoop keeps integer counters and drops the rest; write --review-loop refuses a malformed record and writes nothing', () => {
+289
View File
@@ -0,0 +1,289 @@
/**
* Owner gate dispositions (GST-2491, founderstar#906 at 3a273935b): an unattended /ship logs every gate finding `open`
* (gstack-review-log), and only the owner's `gstack-approvals gate` row, at that fingerprint and a head inside the review
* row's range, closes it. Rulings, the lane's own review-log claims and rows recorded inside the open /ship run close nothing,
* and every disposition is printed by finish and carried by the receipt, the PR body and READY.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt, readyTailLines } from '../lib/ship-receipt';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
import { spawnSync } from 'node:child_process';
import { buildRow, locate, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
import { GATE_SOURCE, gateDispositionDigest, gateDispositionRow } from '../lib/gate-dispositions';
setDefaultTimeout(90_000);
const BIN = path.dirname(RELEASE);
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
const ships: ShipFx[] = [];
afterEach(() => { while (ships.length) ships.pop()!.cleanup(); });
const GATE = 'tests/balance.test.ts:3:gate-integrity';
const OTHER = 'tests/balance.test.ts:9:gate-integrity';
const GATE_ID = '69c7c3432501';
const WORDS = 'Do it';
const REASON = 'fed-careless gate allows 6 of 48 seeds';
const BY = 'Founderstar Captain (FOUN-1)';
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(4).toBeLessThanOrEqual(4); });\n";
const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(4).toBeLessThanOrEqual(6); });\n";
/** The founderstar#906 review row: one RH-15 gate finding an unattended session logs open, and a below-floor note. */
const gateRow = (extra: Record<string, unknown> = {}) => ({
completed: true, converged: true, status: 'issues_found', issues_found: 1, critical: 1, informational: 0,
findings: [
{ fingerprint: GATE, severity: 'CRITICAL', locus: 'diff', kind: 'defect', blocking: true, raised_by: 'core', gate: 'RH-15', gate_id: GATE_ID, action: 'kept', reason: 'owner approved', ...extra },
{ fingerprint: 'src/x.ts:1:naming', severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by: 'red-team' },
],
});
const NOT_CLEAN = 'FIX_CHECK: not counted (review has unresolved findings or did not finish clean)';
const run = (f: ShipFx, bin: string, args: string[]) => f.run(path.join(BIN, bin), args, { GSTACK_APPROVALS_MIRROR: '0' });
const gate = (f: ShipFx, finding: string, head: string, o: { words?: string; by?: string; reason?: string; gateId?: string } = {}) =>
run(f, 'gstack-approvals', ['gate', '--finding', finding, '--gate-id', o.gateId ?? GATE_ID, '--head', head, '--reason', o.reason ?? REASON, '--words', o.words ?? WORDS, '--by', o.by ?? BY]);
const log = (f: ShipFx, skill: string, row: Record<string, unknown>) => {
const env = { GSTACK_APPROVALS_MIRROR: '0', GSTACK_SESSION_KIND: 'unattended' };
const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', skill], env);
expect(start.status, start.stderr).toBe(0);
const logged = f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill, timestamp: '2026-11-11T20:30:13Z', via: 'ship', review_scope: 'frozen', ...row }), '--finish', start.stdout.trim()], env);
expect(logged.status, logged.stderr).toBe(0);
};
/** A /ship run open on `dir` as preflight leaves it: the run marker plus an open lease held by this live test process. */
const openRun = (f: ShipFx, dir = f.work) => {
const marker = path.resolve(dir, spawnSync('git', ['rev-parse', '--git-path', 'gstack-ship-run'], { cwd: dir, encoding: 'utf8', timeout: 60_000 }).stdout.trim());
fs.writeFileSync(marker, 'ship-906-3a27393\n');
const lease = spawnSync(path.join(BIN, 'gstack-lease'), ['touch', '--root', dir, '--pid', String(process.pid), '--run', 'ship-906-3a27393'], { encoding: 'utf8', timeout: 60_000 });
expect(lease.status, lease.stderr).toBe(0);
return marker;
};
/** The newest row of a skill as gstack-review-read prints it. */
const read = (f: ShipFx, skill = 'review') => run(f, 'gstack-review-read', []).stdout.split('\n---CONFIG---')[0]!.split('\n')
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === skill ? [v] : []; } catch { return []; } }).at(-1);
/** Pass 1 read the gate change at its commit and logged the gate finding open (unattended). */
function firstPass(extra: Record<string, unknown> = {}, rowExtra: Record<string, unknown> = {}) {
const f = shipFixture({ 'tests/balance.test.ts': TEST_V1 }, { 'tests/balance.test.ts': TEST_V2 });
ships.push(f);
log(f, 'review', { pass: 1, range_base: null, ...gateRow(extra), ...rowExtra });
return { f, head: f.git('rev-parse', 'HEAD').stdout.trim(), mergeBase: f.git('merge-base', 'main', 'HEAD').stdout.trim() };
}
// Value: protects=founderstar#906: an owner-approved gate change finishes unattended once the owner's disposition is recorded,
// and only then; fails_when=the row still reads UNVERIFIED with a matching gate_disposition row, or reads CURRENT without one;
// seam=gstack-approvals gate, gstack-review-read and lib/gate-dispositions.ts on the stored review row; no_claim=the review
// plan's flags, which /ship derives from this row
describe('a recorded owner disposition closes an unattended gate finding (GST-2491)', () => {
test('the logger keeps it open, and a matching gate row closes it on the review row', () => {
const { f, head } = firstPass();
const before = read(f);
expect(before.findings[0]).toMatchObject({ action: 'open', actor: 'unattended' });
expect([before.review_freshness.status, before.gate_dispositions]).toEqual(['UNVERIFIED', undefined]);
const rec = gate(f, GATE, head);
expect([rec.status, rec.stdout]).toEqual([0, expect.stringMatching(/^APPROVAL_RECORDED: a-[0-9a-f]{12} /)]);
const approval = rec.stdout.split(' ')[1]!;
const after = read(f);
expect([after.review_freshness.status, after.gate_dispositions]).toEqual(['CURRENT', [approval]]);
expect(after.findings[0]).toMatchObject({ action: 'kept', actor: 'unattended', disposition: approval, reason: `owner disposition ${approval}: ${REASON}` });
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toMatch(new RegExp(`^Gate disposition: ${GATE} \\[gate ${GATE_ID}\\] at ${head.slice(0, 12)} by Founderstar Captain \\(FOUN-1\\) at \\d{4}-\\d\\d-\\d\\dT[^ ]+Z: ${REASON} — "Do it" \\(approval a-[0-9a-f]{12}\\)\\n$`));
});
test('negative controls stay open: no row, another fingerprint or gate_id, a head outside the range, a ruling, a gstack row', () => {
const { f, head, mergeBase } = firstPass();
const open = () => [read(f).review_freshness.status, read(f).gate_dispositions, read(f).findings[0].action];
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
expect(gate(f, OTHER, head).status).toBe(0);
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
expect(gate(f, GATE, head, { gateId: '0123456789ab' }).status).toBe(0);
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
expect(gate(f, GATE, mergeBase).status).toBe(0);
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
const ruling = run(f, 'gstack-approvals', ['rule', '--finding', GATE, '--head', head, '--reason', REASON, '--words', WORDS, '--by', BY]);
expect(ruling.status).toBe(0);
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
const self = gate(f, GATE, head, { by: 'gstack no-stop default (K1)' });
expect([self.status, self.stderr]).toEqual([EXIT.usage, expect.stringContaining('--by names gstack')]);
expect(open()).toEqual(['UNVERIFIED', undefined, 'open']);
});
test('a later head is outside a first pass too: the owner answers at the commit the review read', () => {
const { f } = firstPass();
f.write({ 'README.md': '# Fx later\n' }); f.commit('later');
expect(gate(f, GATE, f.git('rev-parse', 'HEAD').stdout.trim()).status).toBe(0);
expect(read(f).review_freshness.status).not.toBe('CURRENT');
expect(read(f).gate_dispositions).toBeUndefined();
});
test('gate refuses a call inside the open /ship run, empty or multi-line words, and add with the scope', () => {
const { f, head } = firstPass();
const marker = openRun(f);
const inRun = gate(f, GATE, head);
expect([inRun.status, inRun.stderr]).toEqual([EXIT.usage, expect.stringContaining('/ship run ship-906-3a27393 is open on this checkout')]);
fs.rmSync(marker);
for (const words of ['', ' ']) {
const empty = gate(f, GATE, head, { words });
expect([empty.status, empty.stderr]).toEqual([EXIT.usage, expect.stringContaining('--words')]);
}
const noId = gate(f, GATE, head, { gateId: '' });
expect([noId.status, noId.stderr]).toEqual([EXIT.usage, expect.stringContaining('--gate-id')]);
const lines = gate(f, GATE, head, { words: 'Do it\nand more' });
expect([lines.status, lines.stderr]).toEqual([EXIT.usage, expect.stringContaining('one line')]);
const add = run(f, 'gstack-approvals', ['add', '--ask', `gate_disposition ${GATE} ${GATE_ID} at ${head}: ${REASON}`, '--words', WORDS, '--by', BY, '--scope', `gate_disposition ${GATE} ${GATE_ID} ${head}`]);
expect([add.status, add.stderr]).toEqual([EXIT.usage, expect.stringContaining('gstack-approvals gate')]);
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toBe('GATE_DISPOSITIONS: none\n');
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
});
test('the lane\'s own review-log claim closes nothing', () => {
const { f, head } = firstPass({ disposition: 'a-6dcd9c1d8fad', approval: 'a-6dcd9c1d8fad', disposition_by: BY }, { gate_dispositions: ['a-6dcd9c1d8fad'] });
const row = read(f);
expect([row.findings[0].action, row.findings[0].disposition, row.review_freshness.status, row.gate_dispositions]).toEqual(['open', undefined, 'UNVERIFIED', undefined]);
expect(run(f, 'gstack-approvals', ['add', '--ask', `Owner approved the gate change at ${head}`, '--words', WORDS, '--by', BY, '--scope', 'this gate edit']).status).toBe(0);
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
});
});
/** Round 2 stopped at passHead, the owner granted one scoped fix-check, and the fix-check logged the gate finding open. */
function grantedFixCheck() {
const f = shipFixture({ 'tests/balance.test.ts': TEST_V1 }, { 'src/x.ts': 'export const x = 2;\n', 'tests/balance.test.ts': TEST_V1 });
ships.push(f);
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
f.write({ 'src/x.ts': 'export const x = 3;\n', 'tests/balance.test.ts': TEST_V2 }); f.commit('raise the fed-careless gate');
const post = path.join(f.root, 'post.json');
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
expect(run(f, 'gstack-approvals', ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${passHead.slice(0, 12)})`, '--words', 'one scoped fix-check', '--by', BY, '--scope', 'this /ship invocation']).status).toBe(0);
log(f, 'review-fix-check', { range_base: passHead, ...gateRow() });
const fin = () => f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), fin };
}
const dispositionLines = (out: string) => out.split('\n').filter(l => l.startsWith('GATE_DISPOSITION: '));
// Value: protects=finish counts a fix-check whose only blocker is an owner-dispositioned gate finding and prints the disposition
// word for word; a ruling or an out-of-range disposition leaves it uncounted and says not applied; fails_when=finish ignores
// gate dispositions, honours a ruling for a gate finding, or hides a disposition; seam=finish's fixChecked route
describe('finish with a gate disposition (GST-2491)', () => {
test('a disposition at the stop head closes the fix-check\'s gate finding, and finish prints it', () => {
const { f, passHead, fixHead, fin } = grantedFixCheck();
expect(lineOf(fin().stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
expect(run(f, 'gstack-approvals', ['rule', '--finding', GATE, '--head', passHead, '--reason', REASON, '--words', WORDS, '--by', BY]).status).toBe(0);
expect(lineOf(fin().stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
expect(gate(f, GATE, f.git('merge-base', 'main', 'HEAD').stdout.trim()).status).toBe(0);
const early = fin();
expect(lineOf(early.stdout, 'FIX_CHECK')).toBe(NOT_CLEAN);
expect(dispositionLines(early.stdout)).toEqual([expect.stringMatching(/; not applied\)$/)]);
expect(gate(f, GATE, passHead).status).toBe(0);
const r = fin();
expect(lineOf(r.stdout, 'FIX_CHECK')).toStartWith(`FIX_CHECK: reviewed src/x.ts,tests/balance.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}`);
expect(dispositionLines(r.stdout)).toEqual([
expect.stringMatching(/; not applied\)$/),
expect.stringMatching(new RegExp(`^GATE_DISPOSITION: ${GATE} \\[gate ${GATE_ID}\\] at ${passHead.slice(0, 12)} by Founderstar Captain \\(FOUN-1\\) at [^ ]+Z: ${REASON} — "Do it" \\(approval a-[0-9a-f]{12}\\)$`)),
]);
});
// Value: protects=nothing dispositioned is hidden (R73): the receipt records every gate disposition, write refuses a PR body
// without each line word for word, and READY prints it; fails_when=write drops it or accepts a body without it;
// seam=gstack-ship-receipt write and readyTailLines
test('the receipt records every disposition, write refuses a body without its line, and READY prints it', () => {
const { f, head } = firstPass();
expect(gate(f, GATE, head).status).toBe(0);
const line = run(f, 'gstack-approvals', ['dispositions']).stdout.trim();
const body = path.join(f.root, 'body.md');
const write = () => f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], { GSTACK_APPROVALS_MIRROR: '0' });
fs.writeFileSync(body, '## Pre-Landing Review\n\nGate edits: 1 listed, 1 read, 1 findings — 1 kept, 0 restored, 0 open\n');
const short = write();
expect([short.status, short.stderr]).toEqual([EXIT.fail, expect.stringContaining(`does not list gate disposition ${JSON.stringify(line)}`)]);
fs.writeFileSync(body, `## Pre-Landing Review\n\n${line}\n`);
const ok = write();
expect(ok.status, ok.stderr).toBe(EXIT.ok);
const receipt = extractReceipt(ok.stdout);
expect(receipt.errors).toEqual([]);
expect(receipt.receipt?.gate_dispositions).toEqual([{ finding: GATE, gate_id: GATE_ID, head, by: BY, reason: REASON, words: WORDS, at: expect.stringMatching(/Z$/), approval: expect.stringMatching(/^a-[0-9a-f]{12}$/) }]);
expect(readyTailLines(receipt.receipt).filter(l => l.startsWith('GATE_DISPOSITION: '))).toEqual([`GATE_DISPOSITION: ${line.slice('Gate disposition: '.length)}`]);
});
});
// Value: protects=the GSTA-518 audit's lane-side paths (B1 sibling writer, N1 lock, N2 undefined gate id, N4 malformed rows, N5
// skipped --body) are refused or ignored; fails_when=a sibling worktree records while the branch's /ship run is open, a row
// missing a writer field or edited after writing closes the finding, --gate-id undefined records, or write skips the body check;
// seam=gstack-approvals gate, gstack-review-read and gstack-ship-receipt write; no_claim=a well-formed row a same-user lane
// forges (the lander's --accept-gate-disposition is that boundary, test/ship-queue-land-direct.test.ts)
describe('a disposition the lane makes for itself is refused or ignored (GST-2491)', () => {
const store = (f: ShipFx) => locate({ cwd: f.work, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, GSTACK_APPROVALS_MIRROR: '0' }, branch: 'feat' }) as StoreLocation;
test('a recorder name is trimmed and one line, and a parked run whose lease ended no longer refuses', () => {
const { f, head } = firstPass();
const lines = gate(f, GATE, head, { by: 'Founderstar\nCaptain' });
expect([lines.status, lines.stderr]).toEqual([EXIT.usage, expect.stringContaining('one line')]);
const marker = path.resolve(f.work, f.git('rev-parse', '--git-path', 'gstack-ship-run').stdout.trim());
fs.writeFileSync(marker, 'ship-906-parked\n');
const spaced = gate(f, GATE, head, { by: ` ${BY} ` });
expect(spaced.status, spaced.stderr).toBe(0);
expect(run(f, 'gstack-approvals', ['dispositions']).stdout).toContain(`by ${BY} at`);
expect(read(f).review_freshness.status).toBe('CURRENT');
});
test('a sibling worktree cannot record while the branch has an open /ship run, and records once it closes', () => {
const { f, head } = firstPass();
const marker = openRun(f);
const sib = path.join(f.root, 'sib');
expect(f.git('worktree', 'add', '-q', '-b', 'owner-side', sib, 'HEAD').status).toBe(0);
const fromSib = () => spawnSync(path.join(BIN, 'gstack-approvals'), ['gate', '--branch', 'feat', '--finding', GATE, '--gate-id', GATE_ID, '--head', head, '--reason', REASON, '--words', WORDS, '--by', BY],
{ cwd: sib, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, GSTACK_APPROVALS_MIRROR: '0', GIT_CONFIG_GLOBAL: path.join(f.root, 'gitconfig') } });
const refused = fromSib();
expect([refused.status, refused.stderr]).toEqual([EXIT.usage, expect.stringContaining(`/ship run ship-906-3a27393 is open in the worktree ${f.work} (branch feat)`)]);
expect(read(f).review_freshness.status).toBe('UNVERIFIED');
fs.rmSync(marker);
const recorded = fromSib();
expect(recorded.status, recorded.stderr).toBe(0);
expect(read(f).review_freshness.status).toBe('CURRENT');
});
test('a raw row missing a writer field, edited after writing, or for another branch closes nothing; the written row does', () => {
const { f, head } = firstPass();
expect(gate(f, GATE, head).status).toBe(0);
const file = store(f).file;
const good = JSON.parse(fs.readFileSync(file, 'utf8').trim().split('\n').at(-1)!);
expect([good.source, good.digest]).toEqual(['gate', expect.stringMatching(/^sha256:[0-9a-f]{64}$/)]);
const { digest: _d, ...noDigest } = good, { by: _b, ...noBy } = good, { recorded_at: _r, ...noTime } = good;
for (const bad of [noDigest, noBy, noTime, { ...good, source: 'manual' }, { ...good, words: 'Do it all' }, { ...good, branch: 'other' }, { ...good, id: 'gate-1' }, { ...good, ask: good.ask.replace(REASON, 'any gate change') }]) {
fs.writeFileSync(file, JSON.stringify(bad) + '\n');
expect([read(f).review_freshness.status, run(f, 'gstack-approvals', ['dispositions']).stdout]).toEqual(['UNVERIFIED', 'GATE_DISPOSITIONS: none\n']);
}
fs.writeFileSync(file, JSON.stringify(good) + '\n');
expect(read(f).review_freshness.status).toBe('CURRENT');
});
test('a literal undefined gate id is refused, and an id-less gate finding stays open even under a digest-correct row', () => {
const { f, head } = firstPass({ gate_id: undefined });
for (const gateId of ['undefined', 'null']) {
const lit = gate(f, GATE, head, { gateId });
expect([lit.status, lit.stderr]).toEqual([EXIT.usage, expect.stringContaining('--gate-id')]);
}
const row = buildRow({ ...gateDispositionRow(GATE, 'undefined', head, REASON), words: WORDS, by: BY, source: GATE_SOURCE }, 'feat') as ApprovalRow;
fs.mkdirSync(path.dirname(store(f).file), { recursive: true });
fs.appendFileSync(store(f).file, JSON.stringify({ ...row, digest: gateDispositionDigest(row as unknown as Record<string, unknown>) }) + '\n');
expect([read(f).review_freshness.status, read(f).findings[0].action]).toEqual(['UNVERIFIED', 'open']);
});
test('write refuses a receipt without --body while a disposition applies at HEAD', () => {
const { f, head } = firstPass();
expect(gate(f, GATE, head).status).toBe(0);
const w = f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended'], { GSTACK_APPROVALS_MIRROR: '0' });
expect([w.status, w.stderr]).toEqual([EXIT.fail, expect.stringContaining('so write needs --body <the PR body>')]);
});
// Value: protects=a branch whose disposition row names a commit a rewrite and gc removed still ships (the 0a9a0c4 review's
// blocker): in a full clone that row is not applied; fails_when=git's exit 128 on the missing head strands write for good
test('write records an explicit empty gate_dispositions, and a row whose head a rewrite and gc removed is not applied', () => {
const { f, head } = firstPass();
expect(gate(f, GATE, head).status).toBe(0);
for (const args of [['commit', '-q', '--amend', '-m', 'feat rewritten'], ['push', '-q', '-f', 'origin', 'HEAD:refs/heads/feat'], ['reflog', 'expire', '--expire=now', '--all'], ['gc', '-q', '--prune=now']]) expect(f.git(...args).status).toBe(0);
for (const args of [['reflog', 'expire', '--expire=now', '--all'], ['gc', '-q', '--prune=now']]) expect(f.git('--git-dir', f.origin, ...args).status).toBe(0);
expect([f.git('cat-file', '-e', `${head}^{commit}`).status, f.git('--git-dir', f.origin, 'cat-file', '-e', `${head}^{commit}`).status]).not.toContain(0);
const body = path.join(f.root, 'body.md');
fs.writeFileSync(body, '## Pre-Landing Review\n\nNo issues found.\n');
const ok = f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '906', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], { GSTACK_APPROVALS_MIRROR: '0' });
expect(ok.status, ok.stderr).toBe(EXIT.ok);
expect(extractReceipt(ok.stdout).receipt?.gate_dispositions).toEqual([]);
});
});
+192
View File
@@ -28,6 +28,8 @@ import { gitIn } from '../crew/scripts/train-gate';
import type { GhRunner } from '../crew/scripts/recover-receipts';
import { TRAIN_BLOCK } from './helpers/ship-queue-train-fixture';
import { CHECK_IDS } from '../lib/pregate/index';
import { appliedGateDispositions, gateAcceptance, GATE_SOURCE, gateDispositionDigest, gateDispositionRow, gateDispositionText, type GateDisposition } from '../lib/gate-dispositions';
import { buildRow, locate, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
const REQUIRED = ['free-tests', 'Check VERSION is not stale vs queue'];
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d', required_checks: REQUIRED }, merge: 'direct', min_gstack_version: MERGE_KEY_VERSION };
@@ -1234,3 +1236,193 @@ describe('the backstop rewrites a freeze by compare-and-swap (GST-1354)', () =>
expect(freezeOf(fx).cleared_by).toBe(`repair #7; backstop 703 green on ${green.sha}`);
}, 180_000);
});
// Value: protects=the authority for an owner gate disposition is the lander's explicit acceptance, re-derived from the approval
// store at the pin (GSTA-518 audit B1, N5): a row the lane wrote itself, a receipt or PR body that hides or invents one, and an
// accepted id that names nothing all refuse, and verify --pin agrees with land line for line; fails_when=land trusts the
// receipt, merges an unaccepted disposition, or verify and land print different GATE_DISPOSITION lines; seam=pinAcceptance
// (lib/ship-queue-land-direct.ts) over gateAcceptance (lib/gate-dispositions.ts); no_claim=who wrote a well-formed row
describe('land --pin and verify --pin accept owner gate dispositions (GST-2491)', () => {
const F = 'tests/balance.test.ts:3:gate-integrity', G = '69c7c3432501', REASON = 'fed-careless gate allows 6 of 48 seeds';
const prep = () => { const fx = setup(); fx.env.GSTACK_APPROVALS_MIRROR = '0'; return fx; };
const storeEnv = (fx: Fx) => ({ ...process.env, ...fx.repo.env, ...fx.env });
const record = (fx: Fx, pr: string, head: string) => runBin(fx.repo, 'gstack-approvals', ['gate', '--branch', 'pr-' + pr, '--finding', F, '--gate-id', G, '--head', head, '--reason', REASON, '--words', 'Do it', '--by', 'Founderstar Captain (FOUN-1)'], fx.env);
const applied = (fx: Fx, pr: string, head: string) => appliedGateDispositions(fx.repo.work, { branch: 'pr-' + pr, pin: head, env: storeEnv(fx) }).applied;
const bodyWith = (pr: string, head: string, ds: GateDisposition[], lines = ds) => shippedBody(pr, head, ds.length ? { gate_dispositions: ds } : {}) + lines.map(d => `\nGate disposition: ${gateDispositionText(d)}\n`).join('');
const setBody = (fx: Fx, pr: string, body: string) => fx.write(d => { d.prs[pr].body = body; });
const verify = (fx: Fx, pr: string, pin: string, extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['verify', '--pr', pr, '--pin', pin, '--base', 'main', ...extra], fx.env);
const gateLines = (out: string) => out.split('\n').filter(l => l.startsWith('GATE_DISPOSITION'));
const why = (err: string) => err.replace(/^gstack-ship-queue (?:land|verify): /m, '');
test('end to end: the owner records, the unattended review reads CURRENT, ready prints it, land refuses until the lander accepts it, and verify agrees', () => {
const fx = prep();
const head = openPr(fx, '41', { ready: false });
fx.repo.git('checkout', '-q', 'pr-41');
const lane = { ...fx.env, GSTACK_SESSION_KIND: 'unattended' };
const start = runBin(fx.repo, 'gstack-review-log', ['--start', 'review'], lane);
expect(start.status, start.err).toBe(0);
const row = { skill: 'review', timestamp: '2026-11-11T20:30:13Z', via: 'ship', review_scope: 'frozen', pass: 1, range_base: null, completed: true, converged: true, status: 'issues_found', issues_found: 1, critical: 1, informational: 0,
findings: [{ fingerprint: F, severity: 'CRITICAL', locus: 'diff', kind: 'defect', blocking: true, raised_by: 'core', gate: 'RH-15', gate_id: G, action: 'kept', reason: 'owner approved' }] };
const logged = runBin(fx.repo, 'gstack-review-log', [JSON.stringify(row), '--finish', start.out.trim()], lane);
expect(logged.status, logged.err).toBe(0);
const review = () => runBin(fx.repo, 'gstack-review-read', [], fx.env).out.split('\n---CONFIG---')[0]!.split('\n')
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review' ? [v] : []; } catch { return []; } }).at(-1);
expect([review().review_freshness.status, review().findings[0].action]).toEqual(['UNVERIFIED', 'open']);
const rec = record(fx, '41', head);
expect(rec.status, rec.err).toBe(0);
const id = rec.out.split(' ')[1]!;
expect([review().review_freshness.status, review().findings[0].action, review().findings[0].disposition]).toEqual(['CURRENT', 'kept', id]);
const ds = applied(fx, '41', head);
expect(ds.map(d => d.approval)).toEqual([id]);
setBody(fx, '41', bodyWith('41', head, ds));
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head, verdict: 'pass', checks: FULL_CHECKS }));
const ready = runBin(fx.repo, 'gstack-ship-queue', ['ready', '--pr', '41', '--pregate', pregate, '--base', 'main'], fx.env);
expect(ready.status, ready.out + ready.err).toBe(EXIT.ok);
expect(gateLines(ready.out)).toEqual([`GATE_DISPOSITION: ${gateDispositionText(ds[0]!)}`, expect.stringMatching(new RegExp(`^GATE_DISPOSITIONS: applied=${id} store=`))]);
fx.repo.git('checkout', '-q', '--detach', 'origin/main');
const bare = land(fx, '41', head);
expect([bare.status, bare.err]).toEqual([EXIT.refused, expect.stringContaining(`disposition ${id} is not accepted`)]);
expect(bare.err).toContain('(QUEUE_GATE_DISPOSITION)');
expect(fx.read().mergeArgs).toBeUndefined();
const checked = verify(fx, '41', head);
expect([checked.status, why(checked.err)]).toEqual([EXIT.refused, why(bare.err)]);
expect(gateLines(checked.out)).toEqual(gateLines(bare.out));
const ok = verify(fx, '41', head, ['--accept-gate-disposition', id]);
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
expect(ok.out).toContain(`VERIFY_PIN: ok #41 at ${head.slice(0, 12)}`);
expect(fx.read().mergeArgs).toBeUndefined();
const landed = land(fx, '41', head, ['--accept-gate-disposition', id]);
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
expect(landed.out).toContain('MERGED: #41 as ');
expect(gateLines(landed.out)).toEqual(gateLines(ok.out));
expect(gateLines(landed.out)[1]).toContain(`accepted=${id}`);
}, 180_000);
test('a row the lane appends to the store by hand, digest and all, still needs the lander: land refuses and prints it, a wrong id refuses, and the accepted id lands', () => {
const fx = prep();
const head = openPr(fx, '42');
const loc = locate({ cwd: fx.repo.work, env: storeEnv(fx), branch: 'pr-42' }) as StoreLocation;
const forged = buildRow({ ...gateDispositionRow(F, G, head, 'the lane says the owner agreed'), words: 'Do it', by: 'Owner', source: GATE_SOURCE }, 'pr-42') as ApprovalRow;
fs.mkdirSync(path.dirname(loc.file), { recursive: true });
fs.appendFileSync(loc.file, JSON.stringify({ ...forged, digest: gateDispositionDigest(forged as unknown as Record<string, unknown>) }) + '\n');
const ds = applied(fx, '42', head);
expect(ds.map(d => d.approval)).toEqual([forged.id]);
setBody(fx, '42', bodyWith('42', head, ds));
const line = `GATE_DISPOSITION: ${gateDispositionText(ds[0]!)}`;
const bare = land(fx, '42', head);
expect([bare.status, gateLines(bare.out)[0], bare.err]).toEqual([EXIT.refused, line, expect.stringContaining(`disposition ${forged.id} is not accepted`)]);
const wrong = land(fx, '42', head, ['--accept-gate-disposition', forged.id, '--accept-gate-disposition', 'a-000000000000']);
expect([wrong.status, wrong.err]).toEqual([EXIT.refused, expect.stringContaining('--accept-gate-disposition a-000000000000 names no disposition the store applies')]);
expect(verify(fx, '42', head, ['--accept-gate-disposition', 'a-000000000000']).status).toBe(EXIT.refused);
expect(land(fx, '42', head, ['--accept-gate-disposition', 'undefined']).status).toBe(EXIT.usage);
const landed = land(fx, '42', head, ['--accept-gate-disposition', forged.id]);
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
expect(gateLines(landed.out)[0]).toBe(line);
}, 120_000);
test('the receipt omitting a disposition, the body dropping its line, and the receipt listing one the store lacks refuse land and verify alike', () => {
const fx = prep();
const head = openPr(fx, '43');
const rec = record(fx, '43', head);
expect(rec.status, rec.err).toBe(0);
const id = rec.out.split(' ')[1]!;
const ds = applied(fx, '43', head);
const invented = { ...ds[0]!, approval: 'a-111111111111' };
for (const [body, problem] of [
[bodyWith('43', head, [], ds), 'the receipt has no gate_dispositions field'],
[shippedBody('43', head, { gate_dispositions: [] }) + `\nGate disposition: ${gateDispositionText(ds[0]!)}\n`, `the receipt omits applied disposition ${id}`],
[bodyWith('43', head, ds, []), 'the PR body lacks the line "Gate disposition: '],
[bodyWith('43', head, [...ds, invented]), 'the receipt lists disposition a-111111111111, which the store'],
] as const) {
setBody(fx, '43', body);
const l = land(fx, '43', head, ['--accept-gate-disposition', id, '--dry-run']);
const v = verify(fx, '43', head, ['--accept-gate-disposition', id]);
expect([l.status, l.err]).toEqual([EXIT.refused, expect.stringContaining(problem)]);
expect([v.status, why(v.err), gateLines(v.out)]).toEqual([EXIT.refused, why(l.err), gateLines(l.out)]);
}
setBody(fx, '43', bodyWith('43', head, ds));
expect(verify(fx, '43', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.ok);
}, 120_000);
// Value: protects=a merger that reads another state root, or a store that lost the row after the receipt was written, never lands a
// PR whose review an owner disposition cleared with zero accepted ids (the ab73c3f audit's B1); fails_when=a receipt id the store
// lacks, or a field-less receipt beside a row, reads as nothing to accept; seam=gateAcceptance via land and verify
test('another state root at merge, a row removed after the receipt, or a field-less receipt beside a row refuses; an explicit [] with an empty store lands', () => {
const fx = prep();
const head = openPr(fx, '44');
const rec = record(fx, '44', head);
expect(rec.status, rec.err).toBe(0);
const id = rec.out.split(' ')[1]!;
const ds = applied(fx, '44', head);
setBody(fx, '44', bodyWith('44', head, ds));
const other = { ...fx.env, GSTACK_STATE_ROOT: path.join(fx.repo.root, 'merger-state'), GSTACK_HOME: path.join(fx.repo.root, 'merger-state') };
const elsewhere = runBin(fx.repo, 'gstack-ship-queue', ['verify', '--pr', '44', '--pin', head, '--base', 'main', '--accept-gate-disposition', id], other);
expect([elsewhere.status, elsewhere.err]).toEqual([EXIT.refused, expect.stringContaining('may read another GSTACK_STATE_ROOT or approvals mirror')]);
const landElsewhere = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '44', '--pin', head, '--base', 'main', '--dry-run', '--accept-gate-disposition', id], other);
expect([landElsewhere.status, why(landElsewhere.err)]).toEqual([EXIT.refused, why(elsewhere.err)]);
const store = (locate({ cwd: fx.repo.work, env: storeEnv(fx), branch: 'pr-44' }) as StoreLocation).file;
const kept = fs.readFileSync(store, 'utf8');
fs.rmSync(store);
const removed = land(fx, '44', head, ['--dry-run', '--accept-gate-disposition', id]);
expect([removed.status, removed.err]).toEqual([EXIT.refused, expect.stringContaining(`the receipt lists disposition ${id}, which the store at`)]);
expect(verify(fx, '44', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.refused);
fs.writeFileSync(store, kept);
setBody(fx, '44', shippedBody('44', head) + `\nGate disposition: ${gateDispositionText(ds[0]!)}\n`);
const fieldless = land(fx, '44', head, ['--dry-run', '--accept-gate-disposition', id]);
expect([fieldless.status, fieldless.err]).toEqual([EXIT.refused, expect.stringContaining('the receipt has no gate_dispositions field')]);
expect(verify(fx, '44', head, ['--accept-gate-disposition', id]).status).toBe(EXIT.refused);
const pin = openPr(fx, '45');
setBody(fx, '45', shippedBody('45', pin, { gate_dispositions: [] }));
expect(verify(fx, '45', pin).status).toBe(EXIT.ok);
const landed = land(fx, '45', pin);
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
expect(gateLines(landed.out)).toEqual([]);
}, 180_000);
test('a git error while deriving the dispositions refuses instead of reading as not applied (N1)', () => {
const fx = prep();
const head = openPr(fx, '46');
expect(record(fx, '46', head).status).toBe(0);
const r = gateAcceptance({ cwd: fx.repo.work, branch: 'pr-46', pin: 'f'.repeat(40), body: '', receipt: { gate_dispositions: [] } as any, env: storeEnv(fx) });
expect(r.failure?.message).toContain(`the pin ${'f'.repeat(12)} is not a commit in this clone`);
expect(r.failure).toMatchObject({ code: 'QUEUE_GATE_DISPOSITION', message: expect.stringContaining('cannot be derived') });
}, 60_000);
// Value: protects=a row whose head a rewrite and gc removed never strands land, verify or ready (the 0a9a0c4 review's blocker),
// while a shallow clone, which cannot prove ancestry, refuses; fails_when=git's exit 128 on the missing head refuses for good,
// or a shallow clone reads an unprovable row as not applied; seam=appliedGateDispositions via ready, verify and land
test('a row whose head a rewrite and gc removed is not applied: ready, verify and land proceed; a shallow clone refuses and says to deepen it', () => {
const fx = prep();
const old = openPr(fx, '47', { ready: false });
expect(record(fx, '47', old).status).toBe(0);
fx.repo.git('checkout', '-q', 'pr-47');
fx.repo.git('commit', '-q', '--amend', '-m', 'pr 47 rewritten');
fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-47');
const head = fx.repo.git('rev-parse', 'HEAD');
for (const dir of [['-C', fx.repo.work], ['--git-dir', fx.repo.origin]]) {
fx.repo.git(...dir, 'reflog', 'expire', '--expire=now', '--all');
fx.repo.git(...dir, 'gc', '-q', '--prune=now');
}
expect([spawnCheck(fx.repo.work, old), spawnCheck(fx.repo.origin, old), spawnCheck(fx.repo.work, head)]).toEqual([false, false, true]);
expect(applied(fx, '47', head)).toEqual([]);
setBody(fx, '47', shippedBody('47', head, { gate_dispositions: [] }));
const pregate = path.join(fx.repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head, verdict: 'pass', checks: FULL_CHECKS }));
const ready = runBin(fx.repo, 'gstack-ship-queue', ['ready', '--pr', '47', '--pregate', pregate, '--base', 'main'], fx.env);
expect(ready.status, ready.out + ready.err).toBe(EXIT.ok);
expect(gateLines(ready.out)).toEqual([]);
fx.repo.git('checkout', '-q', '--detach', 'origin/main');
const ok = verify(fx, '47', head);
expect(ok.status, ok.out + ok.err).toBe(EXIT.ok);
const landed = land(fx, '47', head);
expect(landed.status, landed.out + landed.err).toBe(EXIT.ok);
expect(landed.out).toContain('MERGED: #47 as ');
const shallow = path.join(fx.repo.root, 'shallow');
fx.repo.git('clone', '-q', '--depth', '1', '--branch', 'pr-47', 'file://' + fx.repo.origin, shallow);
fx.repo.git('-C', shallow, 'config', 'remote.origin.url', fx.repo.git('config', 'remote.origin.url'));
const r = gateAcceptance({ cwd: shallow, branch: 'pr-47', pin: head, body: '', receipt: { gate_dispositions: [] } as any, env: storeEnv(fx) });
expect([r.failure?.code, r.failure?.message]).toEqual(['QUEUE_GATE_DISPOSITION', expect.stringContaining('this clone is shallow')]);
expect(r.failure?.message).toContain('deepen the clone (git fetch --unshallow)');
}, 180_000);
});
@@ -0,0 +1,4 @@
{
"bytes": 105,
"why": "gate dispositions (GST-2491): the PR body carries each Gate disposition line"
}