Files
gstack/bin/gstack-ship-receipt
T
Garry Tanandcapy-ai[bot] f27a7605d4 v1.91.270.0 feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups (#3487)
* feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups

Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers.

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

* test: repair the CI reds the retarget onto main surfaced (test-only)

Seven test files, no product change. The pull_request run at 2d73fadd
failed 5 Linux shards and 4 Windows shards:

- test/ship-frozen-scope-actor.test.ts imported only test code, so the
  test-of-test ratchet refused it; the first case now recomputes the loop
  summary with summarizeReviewLoop/reviewLoopLine from lib/review-scope and
  asserts the exact REVIEW_LOOP line. Its three cases replay the bash
  workflow through bash -c and the shebang bins, so they are POSIX-only.
- test/codex-host.test.ts: the new gate case is Claude-specific (33 -> 34).
- test/paid-retry-supervision.test.ts: the gate census fixture now carries a
  trial record for the one planned behavior-kind gate case, the way
  test/ci-paid-coordination.test.ts already does.
- test/review-fix-range.test.ts asserted the pre-#3487 DIFF_BASE sentence;
  it now asserts the ${RANGE_BASE:-merge-base} clause that carries the same
  contract (every reviewer reads the range from pass 2 on).
- test/gstack-coauthor-trailers.test.ts: the fixture sets its own git
  identity; a CI runner has none to auto-detect ("Committer identity
  unknown").
- test/review-scope-followups.test.ts and test/gstack-ship-receipt-docs.test.ts:
  the gh and old-git stand-ins are sh scripts on a colon-joined PATH, which
  CreateProcess cannot run, so those three cases skip on win32.

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

* fix: pre-landing review fixes (pass 1)

Twelve informational findings from the /ship pass-1 review, all below the
blocking floor, fixed in place:

- docs-caps reads docs.caps from origin/<base>'s policy (--base <ref>), never
  the branch's copy; an unavailable base policy refuses with
  POLICY_SOURCE_UNAVAILABLE instead of substituting DEFAULT_POLICY
- receiptVersion returns "none" only when the stamp file is absent; other
  errors surface as undefined
- checkReceiptProse groups counters by leaf key and uses word boundaries that
  cover hyphenated names, so shared leaf keys and hyphenated words no longer
  report RECEIPT_PROSE_MISMATCH
- gstack-coauthor-trailers refuses a --range/--base starting with "-",
  prefixes origin/ when origin/<ref> resolves (slash-named branches), and
  passes --end-of-options to git log
- REVIEW_SCOPE_KEY_VERSION and DOCS_CAPS_KEY_VERSION name the releases that
  first read their keys
- the Step 11 late-defer row logs under skill review-late-defer so a tokenless
  review row never reads UNVERIFIED; summarizeReviewLoop counts both
- the "already filed" literal is one constant shared by bin and renderer
- receiptDocs (dead) removed
- Step 14.5 clears .gstack/tmp/docs-audit.json on first entry
- dispatched review prompts carry a literal diff-base sha (<diff-base>) instead
  of a shell variable the reviewer process never sees
- the coauthor-trailers tests launch the bin through runScriptSync so the
  Windows lane can run them

Tests extended for each change; SKILL.md, sections and goldens regenerated.

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

* test: cover the carry patch-id refusal (Step 7 coverage gap)

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

* test: derive the too-old review_scope minimum from the constant

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

* fix: keep co-author trailers in one trailer block

gstack-coauthor-trailers --message appended the new Co-authored-by lines as a
separate paragraph after existing trailers, so git interpret-trailers (and
GitHub attribution) read only the last paragraph and the existing co-author
lost credit. The new lines now join the existing trailer block; the test
checks the result with git interpret-trailers --parse.

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

* fix: let git place co-author trailers; name the docs key release

gstack-coauthor-trailers --message now runs git interpret-trailers --in-place,
so the appended lines join the existing trailer block whatever its shape
(CRLF, comment lines, folded continuations) and the existing co-author keeps
attribution; the interpret-trailers check in the test carries a timeout for
the spawnSync tripwire. DOCS_CAPS_KEY_VERSION names the release after the
newest CHANGELOG heading on main (1.91.258.0), and a test ties every
unreleased policy key to that next release so a stamp elsewhere fails loudly.

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

* fix: hold unreleased policy keys newer than any visible heading; pin trailer placement

The CHANGELOG-heading test asked for the exact next release after the newest
heading, which a depth-1 CI clone (no origin/main) computes from the branch
alone, so it went red there. It now requires an unreleased key's version to be
newer than every heading the checkout can see, which holds at depth 1, on the
stamped train tree and on main; the ship-policy comment says who moves the
constant when the stamp lands under another number.

gstack-coauthor-trailers pins git interpret-trailers' placement on the command
line (--no-divider so a --- body line is not a divider, --where end,
--if-exists addIfDifferentNeighbor, trailer.separators=:, -- before the path)
so a user's trailer.* config cannot replace or drop the existing co-author.

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

* docs: describe the shipped contracts (Step 13 and 14.5)

CHANGELOG [Unreleased]: docs-caps --base <base> reading origin/<base>'s
policy, the review-late-defer row, the literal <diff-base> prompt rule, the
coauthor-trailers placement and refusals, the policy key versions (review_scope
1.91.256.0 re-pin, docs.caps at the stamping release), the version-less
conditions split between Step 12 and the receipt, and the carry refusal wording.
docs/ship-policy.md, docs/review-policy.md and docs/unattended.md follow the
code: docs-caps --base, the literal diff-base sha, the trailer block, the
gstack-outside-voice run binding and the Step 11 row fields.

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

* docs: scope the literal diff-base claim to the review-army prompts; name where the outside-voice cause lives

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

* fix(docsync): declare Step 14.5's own-tree commands to the fixture; exclude .gstack/tmp at the Entry

The ship-docsync-completion and ship-docsync-current paid cases failed twice at
775d9804: Step 14.5's continue path now runs gstack-ship-receipt docs-caps and
writes .gstack/tmp/docs-audit.json, and the docsync fixture's closed interface
declared neither, while its repository had no origin/main for docs-caps to read.
The actor stopped correctly (blocked, no exception) and never reached the
publication stand-in.

- test/helpers/docsync-interface.ts: the three housekeeping commands (the shared
  exclude line, rm -f of the record, docs-caps --base main) and the record write
  are declared, byte for byte with the generated section; DOCS_AUDIT_RECORD_PATHS
  names what the kernel observer may see.
- test/helpers/docsync-observer.ts: accepts exactly those commands and the record
  write; every neighbour (another base, --json, write --docs, another .gstack file)
  stays outside the interface.
- test/helpers/docsync-fixture.ts: every scenario pushes main to the fixture
  remote, as Steps 0-14 would have.
- ship/sections/documentation.md.tmpl: the Entry runs the shared .gstack/tmp
  exclude line before rm -f, so the record never surfaces as an untracked file
  when no earlier step added it; the caps paragraph tightened to hold the ship
  parity ratio at 1.7400.
- test/docsync-command-grammar.test.ts: pins the literals against the generated
  section and the refusals around them.

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

* fix(docsync): the receipt record is housekeeping for read-only audits and the fault cases too

Pass 9's outside voice found two gaps in the fixture change (2524ec4): a
read-only audit (ship-docsync-current) must still write .gstack/tmp/docs-audit.json,
but docsToolFailures flagged every product write under readOnly before the
record allowance; and the unseeded fault cases (missing-asset, legacy-completion)
run the Entry too, so their kernel-write check saw .git/info/exclude as a
forbidden write because only the lifecycle test named the housekeeping paths.

- docsToolFailures: the record write is permitted under readOnly; the document
  and every other product file stay refused.
- docsWriteFailures: DOCS_AUDIT_RECORD_PATHS are permitted for every caller, so
  the fault evaluator needs no per-scenario list; anything else under .git or
  .gstack stays forbidden.
- test/docsync-command-grammar.test.ts pins both: the record under readOnly, the
  document refused there, the observed housekeeping paths accepted with an empty
  allowance and two neighbours refused.

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

* test(docsync): accept the cap gate's ~/.claude/skills/gstack form the section prints (pass 9 Red Team note)

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

* test(docsync): name the admitted composed line precisely (pass 9 maintainability note)

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

* fix(docsync): install lib/ beside bin/ in the fixture so the declared cap gate can load (2524ec4 rerun: module error, not a verdict)

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

* fix(docsync): admit the Write tool's temp sibling of the receipt record (0230fc2 rerun: both lifecycle cases red on it)

The paid rerun at 0230fc2 cleared every earlier refusal and both cases then
failed on one path: forbidden docs write: .gstack/tmp/docs-audit.json.tmp.<pid>.<12 hex>.
Claude Code's Write and Edit tools land every file, new ones included, through
that sibling plus fsync and rename, and the fixture's kernel observer proved
such a landing for the document only (atomicTargets: [DOC_PATH]).

The observer now treats the record's sibling as a transient file too, and
docsWriteFailures admits it only as the cookie-paired MOVED_FROM source of a
MOVED_TO on the record whose other events are create/modify/attrib/close_write
and which is left in neither snapshot nor in changed. A lingering sibling, an
unpaired move, a move onto another path or a sibling of the document moved onto
the record stay forbidden (grammar test). A live check on this machine saw the
second landing's create/modify/close_write/moved_from events admitted and a
stray sibling refused.

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-27 08:16:11 +00:00

585 lines
44 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-ship-receipt — the handoff record, identifiers first (plan C5).
*
* gstack-ship-receipt write [--pr <n>] [--head <sha>] [--base <ref>] [--version <X>] [--gated-tree <sha>]
* [--predecessor <sha>] [--gate <json|@file>] [--ci <json|@file>] [--spend-usd <n>]
* [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
* [--out <file>] [--body <pr-body.md>] [--json]
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json]
* gstack-ship-receipt read --body <file> [--json]
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
* gstack-ship-receipt carry (--pr <n> [--repo <owner/repo>] | --body <file>) [--base <ref>] [--head <sha>] [--dry-run] [--out <dir>] [--json]
* gstack-ship-receipt docs-caps [--base <ref>] [--policy <ship-policy.json>] [--json]
*
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
* and with --body makes it the first section of that PR body file.
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
* `carry` rebinds an earlier head's receipt to HEAD when the product diff
* against the base is unchanged (a conflict-only base merge, a test-only fix,
* release metadata), records the evidence in the receipt's `carry` object,
* posts the body and ends with the same terminal line; a product change is
* RECEIPT_CARRY_PRODUCT_CHANGED and nothing is rewritten.
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
import {
CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS,
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry,
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body
read --pr <n> [--repo <r>] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID)
read --body <file> parse the block from a file
read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41)
with the receipt's review_loop, gate and ci integers: RECEIPT_PROSE_MISMATCH (exit 1)
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
census --repo <r> --since <tag|date> [--cohort <file>]
the P7 adoption number over merged PRs (deduplicated by run and thread)
carry --pr <n> [--repo <r>] | --body <file> [--base <ref>] [--head <sha>] [--dry-run] [--out <dir>]
carry the receipt to HEAD when only a base merge, tests or release metadata changed
since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record,
posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete;
RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
needs git >= 2.39 (RECEIPT_GIT_TOO_OLD)
docs-caps [--base <ref>] /ship Step 14.5: one DOCS_CAP: <path> <bytes>/<max> ok|over line per docs.caps entry of the
reviewed policy (origin/<base>'s .gstack/ship-policy.json; --policy <file> reads that file
instead); any over (or absent) file exits 1 (DOCS_AUDIT_BLOCKED)
write flags: --pr --head --base --version --gated-tree --predecessor --gate <json|@file> --ci <json|@file> --spend-usd
--session-kind --artifacts-consumed <n>/<m> --run --thread --receipt <tree-receipt.json> --policy --preregistration
--history --queue-mode --pregate <pregate.json> (C8: refused unless current, green and every requires-remote obligation
carries a receipt: PREGATE_STALE / PREGATE_LANE_FAILED / PREGATE_INCOMPLETE / PREGATE_REMOTE_UNCLEARED) --issues <json|@file>
(C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line)
--approval <"Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>]"> (repeatable; the --body file's lines count too)
--review-loop <json|@file> ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out)
--docs <json|@file> ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome;
blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim)
--approval-run <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
version source at HEAD ("none" when HEAD has no version source, no policy stamp file and no CHANGELOG), session_kind=$GSTACK_SESSION_KIND (gstack-session-kind), tree/gate_reuse/gated_tree/predecessor
from --receipt when given.
--json: JSON only on stdout.
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function flags(args: string[], name: string): string[] {
return args.flatMap((a, i) => (a === name && args[i + 1] !== undefined ? [args[i + 1]!] : []));
}
function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
if (raw === undefined) return undefined;
const text = raw.startsWith('@') ? fs.readFileSync(raw.slice(1), 'utf8') : raw;
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('expected a JSON object');
return parsed;
}
function git(args: string[], cwd?: string): string | undefined {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
return r.status === 0 ? r.stdout.trim() : undefined;
}
function gh(args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
/**
* The receipt's `version`: the version source at HEAD (the pinned path, else
* VERSION, else package.json#version). A repository with none of those, no
* policy stamp file and no CHANGELOG at HEAD has no release version to record,
* and the receipt says so with `"none"` (docs/ship-policy.md#ship-receipt);
* `read`, `result` and the queue accept it like any other version string.
* Only an absent source is `"none"`: a VERSION that exists but holds no
* parsable version, a pin that escapes the repository, or an unreadable base
* policy keeps the existing no-version refusal.
*/
function receiptVersion(repoRoot: string, head: string, base: string): string | undefined {
const atHead = versionAtRef(repoRoot, head);
if ('version' in atHead) return atHead.version;
if (!/ does not exist at /.test(atHead.error)) return undefined;
const show = (rel: string) => git(['show', `${head}:${rel}`], repoRoot);
try { const v = JSON.parse(show('package.json') ?? 'null')?.version; if (typeof v === 'string' && v.trim()) return v.trim(); } catch { /* not a manifest */ }
const load = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(load)) return undefined;
const changelogs = [...new Set([load.policy.changelog, 'CHANGELOG.md'].filter((p): p is string => !!p))];
if (stampFilesInTree(repoRoot, head, load.policy.stamp_files).length || changelogs.some(p => show(p) !== undefined)) return undefined;
return 'none';
}
function cmdWrite(rest: string[], json: boolean): number {
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot);
if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; }
const receiptFile = flag(rest, '--receipt');
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
if (!sessionKind) {
const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
let pregate: { pregate: string; requires_remote?: string[] } | undefined;
const pregateFile = flag(rest, '--pregate');
if (pregateFile) {
const read = readPregateFile(pregateFile);
if (!read.file) {
for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message} at ${e.path}`) + '\n');
return EXIT.fail;
}
const v = verifyPregate(repoRoot, read.file);
if (!v.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', v.code!, `${v.message}; nothing written`) + '\n'); return EXIT.fail; }
const remote = read.file.requires_remote ?? [];
pregate = { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined };
}
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const bodyFile = flag(rest, '--body');
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
let docs: ReturnType<typeof parseReceiptDocs> | undefined;
if (flag(rest, '--docs') !== undefined) {
try { docs = parseReceiptDocs(jsonArg(flag(rest, '--docs'))); } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: --docs: ${e.message}\n`); return EXIT.usage; }
if (!docs.ok) { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', docs.code, `${docs.message}; nothing written`) + '\n'); return docs.code === 'DOCS_AUDIT_BLOCKED' ? EXIT.refused : EXIT.fail; }
const exceptionLine = docsExceptionLine(docs.docs);
if (bodyFile && exceptionLine && !bodyText.split(/\r?\n/).some(l => l.trim() === exceptionLine)) {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'DOCS_AUDIT_BLOCKED', `${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(exceptionLine)}; nothing written`) + '\n');
return EXIT.refused;
}
}
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
if (approval.kind === 'invalid') {
for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n');
return EXIT.fail;
}
if (approval.kind === 'error' && approval.code) { process.stderr.write(resultLine('gstack-ship-receipt write', approval.code, `${approval.message}; nothing written`) + '\n'); return EXIT.fail; }
if (approval.kind === 'error') { process.stderr.write(`gstack-ship-receipt write: approval classes: ${approval.message}; nothing written\n`); return EXIT.fail; }
if (approval.kind === 'missing') {
for (const m of approval.missing) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CITATION_MISSING', `${m.class} (${m.token}; effects in ${m.files.join(', ')}): ${m.reasons.join('; ')}`) + '\n');
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
if (approval.kind === 'ok' && approval.note) process.stderr.write(approval.note + '\n');
let input: Partial<ShipReceipt> & { docs?: unknown };
try {
const issuesFile = jsonArg(flag(rest, '--issues'));
const loopRaw = jsonArg(flag(rest, '--review-loop'));
const reviewLoop = loopRaw ? parseReviewLoop(loopRaw) : undefined;
if (loopRaw && !reviewLoop) throw new Error('--review-loop needs integer passes, blocking_found and deferred (gstack-review-scope summary --out <file>, then @<file>)');
input = {
schema_version: 1, pr: flag(rest, '--pr') !== undefined ? Number(flag(rest, '--pr')) : undefined, head,
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')),
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
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 } : {}),
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
const receipt = orderReceipt(input);
const errors = validateReceipt(receipt);
if (errors.length) {
for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${e.message} at ${e.path}`) + '\n');
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
const body = bodyFile;
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
if (missing.length) {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'ISSUE_LINKS_MISSING', `${body} lacks ${missing.map(l => JSON.stringify(l)).join(', ')}`) + '\n');
return EXIT.fail;
}
const block = renderReceiptBlock(receipt);
const out = flag(rest, '--out');
if (out) fs.writeFileSync(out, block);
if (body) fs.writeFileSync(body, placeReceiptFirst(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt));
process.stderr.write(receiptSummaryLine(receipt) + '\n');
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : block);
return EXIT.ok;
}
function cmdRead(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
const pr = flag(rest, '--pr');
let body: string;
if (bodyFile) body = fs.readFileSync(bodyFile, 'utf8');
else if (pr) {
const repo = flag(rest, '--repo');
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body']);
if (!r.ok) { process.stderr.write(`gstack-ship-receipt read: gh pr view failed: ${r.err}\n`); return EXIT.fail; }
try { body = JSON.parse(r.out).body ?? ''; } catch { process.stderr.write('gstack-ship-receipt read: gh returned no JSON body\n'); return EXIT.fail; }
} else { process.stderr.write(`gstack-ship-receipt read: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
const ex = extractReceipt(body);
if (!ex.found) {
process.stderr.write(resultLine('gstack-ship-receipt read', 'RECEIPT_MISSING', pr ? `PR #${pr}` : bodyFile!) + '\n');
if (json) process.stdout.write(JSON.stringify({ found: false, errors: [] }) + '\n');
return EXIT.fail;
}
if (ex.errors.length) {
for (const e of ex.errors) process.stderr.write(resultLine('gstack-ship-receipt read', 'RECEIPT_INVALID', `${e.message} at ${e.path}`) + '\n');
if (json) process.stdout.write(JSON.stringify({ found: true, valid: false, errors: ex.errors, raw: ex.raw }) + '\n');
return EXIT.fail;
}
process.stderr.write(receiptSummaryLine(ex.receipt!) + '\n');
if (rest.includes('--check-prose')) {
const prose = checkReceiptProse(ex.receipt as unknown as Record<string, unknown>, body);
process.stderr.write(proseLine(prose) + '\n');
if (prose.mismatches.length) {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt read', 'RECEIPT_PROSE_MISMATCH', prose.mismatches.map(m => `${m.field} body=${m.prose} receipt=${m.receipt}`).join(', ')) + '\n');
if (json) process.stdout.write(JSON.stringify({ found: true, valid: true, receipt: ex.receipt, prose }) + '\n');
return EXIT.fail;
}
if (json) { process.stdout.write(JSON.stringify({ found: true, valid: true, receipt: ex.receipt, prose }) + '\n'); return EXIT.ok; }
}
process.stdout.write(json ? JSON.stringify(ex.receipt) + '\n' : renderReceiptBlock(ex.receipt!));
return EXIT.ok;
}
/**
* /ship's terminal line (docs/unattended.md#the-terminal-line). The receipt in
* the PR body (or the handoff file) must bind to HEAD: then the run record
* goes under <state>/ship/runs/ with the head, the PR and the receipt, and
* the last stdout line is GSTACK_RESULT: skill=ship status=complete run=<dir>.
* A missing or stale receipt is status=refused run=- (exit 3), so a parent
* never has to parse the PR body to know whether /ship finished at this head.
*/
function cmdResult(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
const prFlag = flag(rest, '--pr');
if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; }
const pr = prFlag;
if (prFlag !== undefined && flag(rest, '--body') !== undefined) { process.stderr.write('gstack-ship-receipt result: pass --pr <n> (the PR body, checked against its pushed head) or --body <file> (the handoff receipt), not both\n'); return EXIT.usage; }
const repoRoot = git(['rev-parse', '--show-toplevel']);
const head = (flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined))?.toLowerCase();
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt result', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : 'GSTACK_RESULT: skill=ship status=refused run=-\n');
return EXIT.refused;
};
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
if (!/^[0-9a-f]{40,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a full hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
const incomplete = (why: string) => {
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
return EXIT.fail;
};
let body: string;
let source: string;
if (bodyFile) {
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist; /ship did not write its receipt`);
try { body = fs.readFileSync(bodyFile, 'utf8'); } catch (e) { return incomplete(`cannot read ${bodyFile}: ${e instanceof Error ? e.message : String(e)}`); }
source = path.resolve(bodyFile);
} else if (pr) {
const repo = flag(rest, '--repo');
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
let pushed: unknown;
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
if (typeof pushed !== 'string' || !/^[0-9a-f]{12,64}$/i.test(pushed)) return incomplete(`gh pr view returned no headRefOid for PR #${pr}`);
if (!pushed.toLowerCase().startsWith(head.toLowerCase())) {
return refused('QUEUE_RECEIPT_STALE', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push it, then rerun gstack-ship-receipt result`);
}
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
const bound = bindReceipt(body, pr, [head]);
if (!bound.ok) return refused(bound.code, bound.message);
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete });
}
/** The run record under <state>/ship/runs/ and the terminal line, shared by `result` and `carry`. */
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown> }): number {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dir = a.out ? path.resolve(a.out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${a.head.slice(0, 12)}`);
const receiptPath = path.join(dir, 'ship-receipt.md');
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(receiptPath, renderReceiptBlock(a.receipt));
} catch (e) { return a.incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
const run = {
schema_version: 1, skill: 'ship', status: 'complete', head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
queue_mode: a.receipt.queue_mode ?? null, session_kind: a.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
receipt: receiptPath, receipt_source: a.source, branch: a.repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], a.repoRoot) ?? null : null, created_at: new Date().toISOString(),
...(a.extra ?? {}),
};
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return a.incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
process.stderr.write(a.line + '\n');
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
return EXIT.ok;
}
/**
* One diff's patch-id over a pathspec; empty when the diff is empty. `--verbatim`
* keeps whitespace in the hash (the default and `--stable` strip it, so a space
* moved in a shell line would read as the same patch) while still ignoring the
* hunk line numbers a base merge shifts.
*/
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
if (!diff.stdout.trim()) return '';
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
return id.stdout.trim().split(/\s+/)[0] ?? '';
}
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
const CARRY_MIN_GIT = '2.39';
function gitVersionBelow(repoRoot: string, minimum: string): string | null {
const raw = git(['--version'], repoRoot) ?? '';
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(raw);
if (!m) return raw || 'unknown';
const [minMajor, minMinor] = minimum.split('.').map(Number) as [number, number];
const major = Number(m[1]); const minor = Number(m[2]);
return major > minMajor || (major === minMajor && minor >= minMinor) ? null : m[0];
}
/**
* The receipt follows HEAD when the reviewed product is unchanged
* (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains
* reran a full /ship on identical product code after a base merge or a
* test-only fix; the carry compares the product diff against the base at the
* receipt's head and at HEAD by patch-id, classifies every changed path, and
* records the range-diff as evidence in the receipt it rewrites.
*/
function cmdCarry(rest: string[], json: boolean): number {
const bodyFile = flag(rest, '--body');
const pr = flag(rest, '--pr');
if (pr !== undefined && !/^[1-9]\d*$/.test(pr)) { process.stderr.write(`gstack-ship-receipt carry: --pr must be a PR number, got ${JSON.stringify(pr.slice(0, 40))}\n`); return EXIT.usage; }
if ((pr === undefined) === (bodyFile === undefined)) { process.stderr.write(`gstack-ship-receipt carry: pass --pr <n> or --body <file>, not both\n${HELP}\n`); return EXIT.usage; }
const repoRoot = git(['rev-parse', '--show-toplevel']);
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
return EXIT.refused;
};
const incomplete = (why: string) => {
process.stderr.write(`gstack-ship-receipt carry: ${why}\n`);
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
return EXIT.fail;
};
const repo = flag(rest, '--repo');
let body: string;
let source: string;
if (bodyFile) {
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist`);
body = fs.readFileSync(bodyFile, 'utf8');
source = path.resolve(bodyFile);
} else {
const r = gh(['pr', 'view', pr!, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
if (!r.ok) return incomplete(`gh pr view failed: ${r.err}`);
let pushed: unknown;
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
if (typeof pushed === 'string' && /^[0-9a-f]{12,64}$/i.test(pushed) && !pushed.toLowerCase().startsWith(head)) {
return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push first, then carry`);
}
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
}
const ex = extractReceipt(body);
if (!ex.found) return refused('QUEUE_RECEIPT_MISSING', `${source} carries no ship receipt; run /ship`);
if (!ex.receipt || ex.errors.length) return refused('QUEUE_RECEIPT_MISSING', `${source}'s ship receipt is invalid (${ex.errors[0]?.message ?? 'invalid'}); run /ship`);
const receipt = ex.receipt;
if (pr !== undefined && receipt.pr !== undefined && String(receipt.pr) !== pr) return refused('RECEIPT_CARRY_REFUSED', `the receipt names pr=${receipt.pr}, not this PR`);
const from = receipt.head.toLowerCase();
if (!/^[0-9a-f]{40,64}$/.test(from)) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${receipt.head.slice(0, 12)} is not a full commit id`);
if (from === head) {
process.stderr.write(receiptSummaryLine(receipt) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'current', head, carry: null }) + '\n' : `CARRY: current (the receipt already names ${head.slice(0, 12)})\n`);
return EXIT.ok;
}
const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT);
if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`);
const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`);
// The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped.
const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree');
const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, ''));
if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`);
const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot);
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
const fromBase = git(['merge-base', baseRef, from], repoRoot);
const headBase = git(['merge-base', baseRef, head], repoRoot);
if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
const load = loadPolicyFromBase(repoRoot, base);
const policy = isUnavailable(load) ? null : load.policy;
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] };
const changedRaw = git(['diff', '--name-only', from, head], repoRoot);
if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
// Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch.
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
const versionAt = (ref: string): string | null => {
const file = blobAt(ref, 'VERSION');
if (file !== null) return file.trim() || null;
try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
};
const oldVersion = versionAt(from);
const newVersion = versionAt(head);
const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata];
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion } : undefined) }));
// Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone.
// A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion.
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path);
const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p)));
const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)];
const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)];
const productFrom = patchId(repoRoot, fromBase, from, productSpec);
const productHead = patchId(repoRoot, headBase, head, productSpec);
const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec));
const testHead = asId(patchId(repoRoot, headBase, head, testSpec));
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
// and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
// path the base changed, none of which can differ between the two base diffs.
const changedProduct: string[] = [];
if (productFrom !== productHead) {
const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
for (const p of [...touched].sort()) {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra);
if (cls !== 'product') continue;
if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
}
if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
}
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
if (!plan.ok) return refused(plan.code, plan.message);
const carried = carryReceipt(receipt, head, plan.carry);
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const newBody = placeReceiptFirst(body, carried);
const line = receiptSummaryLine(carried);
if (rest.includes('--dry-run')) {
process.stderr.write(line + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
return EXIT.ok;
}
if (bodyFile) fs.writeFileSync(bodyFile, newBody);
else {
const tmp = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'gstack-carry-')), 'body.md');
fs.writeFileSync(tmp, newBody, { mode: 0o600 });
const edit = gh(['pr', 'edit', pr!, ...(repo ? ['--repo', repo] : []), '--body-file', tmp]);
fs.rmSync(path.dirname(tmp), { recursive: true, force: true });
if (!edit.ok) return incomplete(`gh pr edit failed: ${edit.err}; the receipt was not carried`);
}
process.stdout.write(`CARRY: carried from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason})\n`);
return writeRunRecord({ receipt: carried, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line, incomplete, extra: { carried_from: carried.carry!.from, carry_reason: carried.carry!.reason } });
}
function sinceDate(repo: string, since: string): string | undefined {
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since.slice(0, 10);
const local = git(['log', '-1', '--format=%cI', since]);
if (local) return local.slice(0, 10);
const r = gh(['api', `repos/${repo}/commits/${since}`, '--jq', '.commit.committer.date']);
return r.ok && r.out.trim() ? r.out.trim().slice(0, 10) : undefined;
}
function cmdCensus(rest: string[], json: boolean): number {
const repo = flag(rest, '--repo');
const since = flag(rest, '--since');
if (!repo || !since) { process.stderr.write(`gstack-ship-receipt census: --repo <owner/repo> and --since <tag|date> are required\n${HELP}\n`); return EXIT.usage; }
const date = sinceDate(repo, since);
if (!date) { process.stderr.write(`gstack-ship-receipt census: cannot resolve --since ${since} to a date (not a local tag, and gh could not read it)\n`); return EXIT.fail; }
const limit = flag(rest, '--limit') ?? '500';
const r = gh(['pr', 'list', '--repo', repo, '--state', 'merged', '--search', `merged:>=${date}`, '--limit', limit, '--json', 'number,body,mergedAt,title']);
if (!r.ok) { process.stderr.write(`gstack-ship-receipt census: gh pr list failed: ${r.err}\n`); return EXIT.fail; }
let prs: CensusPr[];
try { prs = JSON.parse(r.out); } catch { process.stderr.write('gstack-ship-receipt census: gh returned no JSON\n'); return EXIT.fail; }
const cohortFile = flag(rest, '--cohort');
const cohort = cohortFile ? fs.readFileSync(cohortFile, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')) : null;
const census = computeCensus(repo, date, prs, cohort);
process.stdout.write(json ? JSON.stringify(census) + '\n' : renderCensus(census));
return EXIT.ok;
}
/**
* /ship Step 14.5's doc-cap check: every `docs.caps` entry of the reviewed
* policy (origin/<base>'s .gstack/ship-policy.json, like every other policy
* read here; the branch cannot raise its own cap) against the working tree,
* one DOCS_CAP: line each; any `over` (an absent file counts) exits 1 so the
* audit blocks before the verification gate. `--policy <file>` reads that
* file instead, for a repository bootstrapping its policy.
*/
function cmdDocsCaps(rest: string[], json: boolean): number {
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
const policyFile = flag(rest, '--policy');
let caps: NonNullable<ShipPolicy['docs']>['caps'] = [];
let source: string;
if (policyFile !== undefined) {
if (!fs.existsSync(policyFile)) { process.stderr.write(`gstack-ship-receipt docs-caps: ${policyFile} does not exist\n`); return EXIT.usage; }
let raw: unknown;
try { raw = JSON.parse(fs.readFileSync(policyFile, 'utf8')); } catch (e: any) { process.stderr.write(`gstack-ship-receipt docs-caps: ${policyFile} is not JSON: ${e.message}\n`); return EXIT.fail; }
const errors = validatePolicy(raw).filter(e => e.path === 'docs' || e.path.startsWith('docs.'));
if (errors.length) { for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt docs-caps', e.code, `${e.message} at ${e.path}`) + '\n'); return EXIT.fail; }
caps = (raw as Partial<ShipPolicy>).docs?.caps ?? [];
source = policyFile;
} else {
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const load = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine('gstack-ship-receipt docs-caps', base, `${load.detail}; pass --policy <file> to check caps without the base`) + '\n'); return EXIT.refused; }
const errors = load.errors.filter(e => e.path === 'docs' || e.path.startsWith('docs.'));
if (errors.length) { for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt docs-caps', e.code, `${e.message} at ${e.path} (${load.source.label})`) + '\n'); return EXIT.fail; }
caps = load.policy.docs?.caps ?? [];
source = load.source.label;
}
const results = checkDocsCaps(caps, rel => { try { const st = fs.statSync(path.join(repoRoot, rel)); return st.isFile() ? st.size : null; } catch { return null; } });
const over = results.filter(r => r.verdict === 'over');
const lines = results.length ? results.map(docsCapLine) : ['DOCS_CAP: none declared'];
process.stdout.write(json ? JSON.stringify({ policy: source, caps: results, over: over.length }) + '\n' : lines.join('\n') + '\n');
if (!over.length) return EXIT.ok;
process.stderr.write(resultLineWithDocs('gstack-ship-receipt docs-caps', 'DOCS_AUDIT_BLOCKED', `${over.length} of ${results.length} documentation cap${results.length === 1 ? '' : 's'} over: ${over.map(r => r.path).join(', ')}`) + '\n');
return EXIT.fail;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'write') return cmdWrite(rest, json);
if (cmd === 'read') return cmdRead(rest, json);
if (cmd === 'result') return cmdResult(rest, json);
if (cmd === 'census') return cmdCensus(rest, json);
if (cmd === 'carry') return cmdCarry(rest, json);
if (cmd === 'docs-caps') return cmdDocsCaps(rest, json);
process.stderr.write(`gstack-ship-receipt: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
process.exitCode = main(process.argv.slice(2));