mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
* v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen registry, intent-based issue links (multi-agent wave C-pregate) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): keep the bin-driving pregate tests off the Windows subset; pin files are free-only for eval selection The three C-pregate tests that spawn bin/gstack-regen, bin/gstack-pregate, bin/gstack-issue-links and bin/gstack-ship-receipt do so through test/helpers/pregate-fixture.ts runBin, so the Windows curation's source scan of the test file never saw a bin/ spawn and shipped them to windows-latest, where CreateProcess cannot exec a shebang (14 failures in windows-free-shard 2 and 5). Each is now a KNOWN_WINDOWS_INCOMPATIBLE entry with the precise reason. .gstack/generated.json and .gstack/pregate.json join FREE_ONLY_PR_FILES: no paid case reads gstack's own pin files (the tools read origin/<base> of the repo they run in, and paid cases run in fixture repos), so touching them no longer restores every gate case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(harness-version): record the FREE_ONLY_PR_FILES pin-file entries as non-behavioral scripts/test-pr-profile.ts is a pinned shared-harness file; the two .gstack pin-file entries change selection only, never a case verdict. 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>
128 lines
7.1 KiB
TypeScript
128 lines
7.1 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';
|
|
|
|
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',
|
|
];
|
|
|
|
/** 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}` }] };
|
|
}
|
|
}
|
|
|
|
/** 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 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}`);
|
|
return `SHIP_RECEIPT: ${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';
|
|
}
|