Files
gstack/lib/ship-receipt.ts
T
Garry Tanandcapy-ai[bot] 6665e20d56 v1.91.236.0 feat(ship-queue): require a current /ship receipt before READY, train build and train merge (#3438)
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge

Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed
SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without
/ship. Now ready, train build (dry run included) and verify --train refuse with
QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts
the receipt written for the pinned head when the current head is its stamp commit.

gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship,
run dir with the head and receipt), unattended /ship may update an existing PR's
body, and INSTALL_UNVERIFIED with no verdict names the project directory to
install from (GSTA-85).

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

* test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton

Gate edit: test/fixtures/workflow-required-steps.json pins the new
"## Step 22: Terminal line (every run)" heading (strengthens: a later
edit that drops the terminal line turns the required-steps test red).
Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise
shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027).

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

* fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json)

- ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2
  merge of main), as verify --train already did; a real edit stays STALE
- ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is
  missing or merges an older head; a finished screen that planned 0 cases is
  never followed, and train build seats that slot unscreened (GSTA-95 on #3431)
- gstack-ship-receipt result writes ship-run.json (a /ship record, not a run
  v1 manifest), validates --head, and prints status=incomplete on gh failures
- bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines
- tests for every state-matrix cell the review named; the train fixture's
  merge ref is a real merge commit

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

* fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91)

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

* fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready)

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

* fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch

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

* test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire)

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

* fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr

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

* fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head

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

* test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal

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

* fix(train build): say when the repository slug is unreadable instead of counting the screen silently

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

* fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one

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

* test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals

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

* fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 09:03:59 +00:00

181 lines
11 KiB
TypeScript

/**
* ship-receipt — the handoff record, identifiers first (plan C5). Renders the
* ```gstack-ship-receipt``` block from the `ship-receipt` schema in
* lib/headless-artifacts.ts, puts it first in a PR body so truncation loses
* prose rather than identifiers, reads it back from a PR through `gh`, and
* computes the P7 adoption census over merged PRs (deduplicated by run and
* thread id against an owner-maintained thread cohort).
*/
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
import { approvalSummary } from './approval-citations';
import { renderIssueLines } from './issue-links';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt> = [
'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',
'pregate', 'requires_remote', 'issues', 'approval',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
export function orderReceipt(input: Partial<ShipReceipt>): ShipReceipt {
const out: Record<string, unknown> = {};
for (const key of KEY_ORDER) if (input[key] !== undefined) out[key] = input[key];
for (const key of Object.keys(input)) if (!(key in out) && (input as any)[key] !== undefined) out[key] = (input as any)[key];
return { schema_version: 1, ...out } as ShipReceipt;
}
export function validateReceipt(receipt: unknown): ValidationError[] {
const errors: ValidationError[] = [];
checkSchema(receipt, ARTIFACT_SCHEMAS['ship-receipt'], '', errors, 'ship-receipt');
return errors;
}
export function renderReceiptBlock(receipt: ShipReceipt): string {
return '```' + RECEIPT_FENCE + '\n' + JSON.stringify(receipt, null, 2) + '\n```\n';
}
const BLOCK_RE = /```gstack-ship-receipt[ \t]*\r?\n([\s\S]*?)\r?\n```/;
/** The first receipt block in a PR body, parsed; `found: false` when absent. */
export function extractReceipt(body: string): { found: boolean; receipt?: ShipReceipt; raw?: string; errors: ValidationError[] } {
const m = BLOCK_RE.exec(body);
if (!m) return { found: false, errors: [] };
try {
const parsed = JSON.parse(m[1]!);
return { found: true, receipt: parsed, raw: m[1], errors: validateReceipt(parsed) };
} catch (e: any) {
return { found: true, raw: m[1], errors: [{ code: 'RECEIPT_INVALID', path: 'ship-receipt', message: `block is not JSON: ${e.message}` }] };
}
}
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
export type ReceiptBinding =
| { ok: true; receipt: ShipReceipt; head: string; line: string }
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
/**
* The /ship receipt a PR body must carry before READY, train build or a train
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
* whose `head` is one of the accepted heads. `accepted` is the head being
* READY'd, or the slot's pinned head beside the current head when the current
* head is the pin's stamp commit (the stamp changes only stamp files, so the
* receipt written for the pin stays the binding one). `line` is the
* informational `SHIP_RECEIPT:` line the READY block prints either way.
*/
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
const ex = extractReceipt(body);
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
if (!ex.receipt || ex.errors.length) {
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
}
const r = ex.receipt;
const line = receiptSummaryLine(r);
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
if (!head) {
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
}
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
return { ok: true, receipt: r, head, line };
}
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
const block = renderReceiptBlock(receipt);
const stripped = body.replace(BLOCK_RE, '').replace(/^\s+/, '');
return block + (stripped ? '\n' + stripped : '');
}
/** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */
export function receiptIssuesFrom(file: Record<string, unknown>): ShipReceipt['issues'] {
const issues: Record<string, unknown> = {};
for (const key of ['fixes', 'refs', 'flakes', 'related']) if (file[key] !== undefined) issues[key] = file[key];
return issues as ShipReceipt['issues'];
}
/** Lines the receipt's issues imply that the body does not carry (a line may continue, e.g. `Refs #N (unreadable …)`). */
export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt['issues']>): string[] {
const have = body.split(/\r?\n/).map(l => l.trim());
const expected = renderIssueLines({ fixes: issues.fixes ?? [], refs: issues.refs ?? [], flakes: issues.flakes ?? [], cross: [], unreadable: [], ...(issues.related ? { related: issues.related } : {}) });
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
}
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
/** The first lines of the completion message to the coordinator: identifiers only. */
export function receiptSummaryLine(r: ShipReceipt): string {
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
if (r.gated_tree) parts.push(`gated_tree=${r.gated_tree.slice(0, 12)}`);
if (r.tree) parts.push(`tree=${r.tree}`);
if (r.gate_reuse) parts.push(`gate-reuse=${r.gate_reuse}`);
if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`);
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
}
// ---------------------------------------------------------------------------
// census (P7)
// ---------------------------------------------------------------------------
export interface CensusPr { number: number; mergedAt: string; body: string; title?: string }
export interface Census {
repo: string; since: string; merged_prs: number; with_receipt: number; invalid_receipts: number;
unattended_prs: number; consumed_prs: number; runs: string[]; threads: string[]; consumed_threads: string[];
cohort: string[] | null; cohort_consumed: number; adoption: string; durable: 'yes'; by_session_kind: Record<string, number>;
prs: Array<{ number: number; session_kind: string | null; artifacts_consumed: string | null; run: string | null; thread: string | null; valid: boolean }>;
}
function consumedCount(value: string | undefined): number {
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value ?? '');
return m ? Number(m[1]) : 0;
}
/** Merged PRs → the adoption number: receipts deduplicated by run id and thread id, against the cohort when given. */
export function computeCensus(repo: string, since: string, prs: CensusPr[], cohort: string[] | null): Census {
const runs = new Set<string>();
const threads = new Set<string>();
const consumedThreads = new Set<string>();
const byKind: Record<string, number> = {};
const rows: Census['prs'] = [];
let withReceipt = 0;
let invalid = 0;
let unattended = 0;
let consumed = 0;
const seenRuns = new Set<string>();
for (const pr of prs) {
const ex = extractReceipt(pr.body ?? '');
if (!ex.found) continue;
withReceipt += 1;
if (ex.errors.length || !ex.receipt) { invalid += 1; rows.push({ number: pr.number, session_kind: null, artifacts_consumed: null, run: null, thread: null, valid: false }); continue; }
const r = ex.receipt;
rows.push({ number: pr.number, session_kind: r.session_kind ?? null, artifacts_consumed: r.artifacts_consumed ?? null, run: r.run ?? null, thread: r.thread ?? null, valid: true });
const kind = r.session_kind ?? 'unknown';
byKind[kind] = (byKind[kind] ?? 0) + 1;
if (r.run && seenRuns.has(r.run)) continue;
if (r.run) { seenRuns.add(r.run); runs.add(r.run); }
if (r.thread) threads.add(r.thread);
if (kind === 'unattended') unattended += 1;
if (consumedCount(r.artifacts_consumed) > 0) { consumed += 1; if (r.thread) consumedThreads.add(r.thread); }
}
const cohortConsumed = cohort ? cohort.filter(t => consumedThreads.has(t)).length : consumedThreads.size;
const denominator = cohort ? cohort.length : threads.size;
return {
repo, since, merged_prs: prs.length, with_receipt: withReceipt, invalid_receipts: invalid, unattended_prs: unattended, consumed_prs: consumed,
runs: [...runs], threads: [...threads], consumed_threads: [...consumedThreads], cohort, cohort_consumed: cohortConsumed,
adoption: `${cohortConsumed}/${denominator}${cohort ? ' (cohort)' : ' (threads seen; no cohort file)'}`, durable: 'yes', by_session_kind: byKind, prs: rows,
};
}
export function renderCensus(c: Census): string {
return [
`CENSUS: repo=${c.repo} since=${c.since} merged_prs=${c.merged_prs} with_receipt=${c.with_receipt} invalid=${c.invalid_receipts} unattended=${c.unattended_prs} consumed=${c.consumed_prs} runs=${c.runs.length} threads=${c.threads.length} adoption=${c.adoption} durable=${c.durable}`,
...Object.entries(c.by_session_kind).map(([k, n]) => ` session_kind ${k}: ${n}`),
...c.prs.map(p => ` #${p.number} ${p.valid ? `session_kind=${p.session_kind ?? '-'} artifacts_consumed=${p.artifacts_consumed ?? '-'} run=${p.run ?? '-'} thread=${p.thread ?? '-'}` : 'receipt invalid'}`),
].join('\n') + '\n';
}