Files
gstack/bin/gstack-evidence
T
Garry Tanandcapy-ai[bot] 4ec1722ecb v1.94.0.0 ship: CI is the one full test run (CI carry, tree cache, /ship beside CI) (#3901)
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)

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

* ship driver: trim two phrases for headroom with #3662

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

* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere

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

* test: regenerate ship goldens

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

* fix: pre-landing review fixes

comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.

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

* fix: pass-2 review fixes

A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.

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

* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)

gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.

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

* fix: no test edit skips review; guard packet writes; exclusive attempt state

A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.

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

* fix: attempt prints gstack's install command; carry checks the product diff outside context paths

The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.

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

* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs

A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.

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

* docs(unattended): carry's scoped-review refusal and what it keeps

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

* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)

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

* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)

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

* fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430)

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

* fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448)

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

* fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442)

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

* docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444)

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

* fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals

- A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the
  blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account
  for the claimed issue and critical counts (GST-430).
- The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442).
- patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448).
- Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444).

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

* test: pin the Step 16 --docs-requested route with its --docs-result

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

* fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1)

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

* fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept

- deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot
  dispose of a gate finding the unattended logger forces open (outside-voice#1).
- reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses;
  noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL.
- patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR.

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

* fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT

- isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition.
- reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so
  interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before.

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

* fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor

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

* fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team)

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

* docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit)

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

* fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin)

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

* fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check

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

* test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055)

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

* fix(review): a stale local base branch yields to its origin twin in gate-diff and diff-scope (GST-462)

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

* fix(ship): Step 11 saves the targeted record and the receipt writes carry it; Step 9 clears it (GST-464)

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

* changelog: GST-464 and GST-462

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

* fix(review): pass 1: ahead and diverged labels, diff-scope needs shared history; preflight refreshes origin/<base> and stops on a diff far beyond the PR (GST-462, HARN-15)

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

* fix: pass-2 review fixes for base twin, prSized stop and targeted record

Resolve the tracking twin by refs/remotes/<remote>/<ref> and only for a bare
branch name, keep a local base that is ahead or diverged, quote the prSized
stop's base and name the fork case, and have every Step 9 entry create,
exclude and clear .gstack/tmp/targeted.json.

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

* fix: origin's tracking base wins whenever related; prSized lower bound

The fix-check found that keeping a local base ahead of origin left unpushed
base commits out of the gate scan while review-plan listed them. The tracking
branch now wins whenever it shares history with HEAD, an explicit origin/main
reads refs/remotes/origin/main, the preflight also stops when the diff lists
fewer files than the PR, and the Step 9 exclude line survives a missing
.git/info and an exclude file without a final newline.

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

* changelog: the tracking-ref claim holds while the tracking branch exists

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

* test: repin the two ship section briefs under no-loss replay GST-464

GST-464 brief-identity check: every reviewer brief lib/review-brief.ts cuts
from ship/sections/review-army.md and adversarial.md stays byte-identical to
the bodies the L and GST-257 replays admitted; only lead instructions outside
the cut spans changed. The check is a test CI runs.

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

* ship: keep Step 9's PASS_HEAD sentence whole; repin review-army under GST-464

The targeted-record line follows the PASS_HEAD/RANGE_BASE sentence instead of
splitting it (test/review-fix-range.test.ts pins that sentence). The brief-body
check still holds: every reviewer brief is byte-identical.

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

* fix: restore bin/gstack-ship-receipt after the main merge

The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.

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

* review-collect: narrow the consumer scan and give it one 300 s deadline (GST-544)

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

* review-collect: keep the consumer scan deadline under the 180 s plan timeout (review fix)

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

* review-collect: plain paths under --literal-pathspecs; whole-millisecond timeout override (review fix)

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

* review-collect: whole-tree grep for non-UTF-8 names, no argument spread, name spawn errors (review fix)

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

* review-collect: cap the consumer grep output at 256 MB across calls; per-scan deadline wording (review fix, cycle 4)

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

* review-collect: read a missing spawn status as null (Bun 1.4.2 returns undefined on ENOENT)

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

* test: review-collect whole-tree helper reads a null status as -1 (typecheck:test ratchet)

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

* review-bots: /review and /ship triage every configured review bot, not only Greptile (#3122)

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

* fix: pre-landing review fixes (gh before 2.48 pages without --slurp; driver routes Step 10; tests for guards, review-bot fence, REMOTE_SLUG owner)

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

* fix: drop the review-bot fence test that could not fail (the tool-fence WARN degrade is unreachable: placeholder spans are skipped first)

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

* fix: Step 11 review fixes (record by comment id so a filename never reaches a shell; lookup failures are errors; tojson pages; longest bot prefix wins)

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

* fix: pass-2 review fixes (record refuses * paths, colliding ids and a missing fetch dir; parent requires id and dir; botOwning doc)

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

* review: classify the PR type and always check repo conventions (#356)

The checklist now opens with a PR-type line (application, CI/infra,
scripts/tooling, docs-only or mixed). Application and mixed diffs run
every category; other types always run five categories, including the
new Repo Conventions & Platform Fit, and report the rest as not
applicable in one line instead of skipping silently.

No-loss replay: GSTA-224 H1 preregistered no-loss run (run id
gsta224-h1-20261031, Opus 5.5, 81 calls, $9.53) against main's checklist
with a no-gstack arm; every preregistered bar passed. The
review/checklist.md pin in test/review-packets-removed.test.ts moves to
a32963ef with replay 'GSTA-224 H1'.

Refs GSTA-224

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

* review-bots: allowlist the record placeholders and match Step 10's id rule in tests

The record fence uses <comment-id>, <fix|fp|already-fixed> and <category>,
each with an enum or numeric grammar; <greptile-dir> becomes <bot-dir>.
review/greptile-triage.md also lists every REVIEW_BOTS skip reason.

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

* docs: review-bot triage covers every configured bot (#3122)

README, CONTRIBUTING, the contributor reference and standalone-binaries
name gstack-review-bots and the review-bot fence; TODOS drops the #3122
item; the changelog fragment covers record-by-id and lookup errors.

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

* test: the PR-type test also pins the compose, Terraform and README/CHANGELOG phrases

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

* test: follow the review-bot rename in the remote free tier's three reds

The cross-skill consistency judge describes review-bot-history.md and its
record helper; the tracker-guard exemption for the removed raw Greptile
fetch in review/greptile-triage.md is dropped (the fetch now lives in
gstack-review-bots, whose bodies still reach context only through
gstack-issue-guard); the Step 10 PR-check test expects 'Review bots: not
run'. HARNESS_VERSION stays 6: skill-fixture.ts and llm-judge.ts are
recorded non-behavioral for every other case.

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

* test: keep the cross-skill judge's rubric to one renamed bullet

Name review-bot-history.md beside the older greptile-history.md so the
calibration corpus still matches, and keep the REMOTE_SLUG bullet. One
live 3-sample panel at this tree: consistent 3/3, scores 4/4/4.

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

* changelog: H1 fragment passes prose lint

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

* changelog: name the H1 fragment after its branch; TODOS drops #356

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

* docs: /review's PR type step in docs/skills.md (#356)

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

* ship-policy: review_exclude_paths key and loadPolicyAt (GST-508)

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

* review: apply the base policy's review_exclude_paths and the reviewer diff ceiling (GST-508)

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

* receipt: bind review_exclude in write, carry and the queue (GST-508)

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

* docs, tests and changelog for review_exclude_paths (GST-508)

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

* fix: pre-landing review fixes (binary by bytes, links kept, untracked files out of the selection)

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

* fix: keep binary files at code paths under review_exclude_paths

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

* docs: name the reasons that keep a binary file under review_exclude_paths

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

* fix: keep every review_exclude_paths candidate when the gate scan is unknown; docs example names its version

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

* test: keep the NUL fixture clear of the sync-spawn tripwire

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

* changelog: split the ceiling sentence

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

* fix: keep a live binary file whose bytes cannot be read; tests render outside voices from a clean checkout and read the ship fixture text

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

* test(review-exclude): explicit +x for the census script; skip the chmod-000 read case on Windows and root

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

* refactor: move loadPolicyAt to lib/ship-policy-at.ts so lib/ship-policy.ts stays under the 800-line module cap

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

* fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774)

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

* chore: regenerate gstack-doctor-components.sh for BASE_CONTAINS_HEAD

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

* fix: land resumes a merged merge-only PR instead of refusing BASE_CONTAINS_HEAD (GST-774 review)

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

* docs: changelog fragment for GST-774

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

* docs: troubleshooting names diff-scope base_contains_head and the self-pointing no_base case (GST-774 docs audit)

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

* test: repin the reviewer briefs to GST-508's no-loss-tested bytes

ship/sections/review-army.md, ship/sections/adversarial.md and lib/review-depth.ts now pin
the bytes the GST-508 preregistered no-loss replay ran at ef2760f (handoffs/GSTA-202/noloss-results.md
in the GStack Minder drive): Arm V 33/41 vs R 33/41, delta 4, exact sign flip, p_NI 0.0279 (pass).
The merge of main 83ae96e leaves these three files byte-identical to ef2760f, and an Arm P unit
(3593) renders byte-identical briefs at ef2760f and at the merge.

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

* fix: GST-774 review round 1 — merged resume depth, aliased and detached PR checkouts, develop/trunk defaults

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

* ship: Step 20 says a metrics row, keeping DRIVER.md at 12 KB after the main merge

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

* test: ship-parts resend case leaves room for the first phase-3 part after the Step 9 targeted-record sentence

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

* fix: /ship delivers review/checklist.md in two parts so checklist v1 fits the 24576-byte part cap (#3670)

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

* changelog: drop the GST-407 and GST-430 fragments, already released on main (stacked history)

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

* wave review-53cfb5bfe011: lib/ship-policy.ts back to 800 lines (module-size ratchet after merging #3744 and #3795)

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

* wave review-53cfb5bfe011: /ship pass 1 fixes

- lib/ship-receipt-depth.ts: a merged resume (land after a true merge) no longer recomputes the review_exclude digest over the emptied range, so it lands instead of RECEIPT_INVALID (failing-first land test with review_exclude_paths active).
- bin/gstack-review-dispatch render: a standalone targeted render under an active review_exclude_paths refuses and points at the pass manifest voices stage, instead of writing a brief with no selection that leaves verify dirty (failing-first test).
- test/default-base.test.ts: #3795 expectations follow main R51 depth (light for an 80-line code diff; runtime_lines=80 still proves the real base).
- test/review-dispatch.test.ts: the GST-897 fingerprint test renders from the clean checkout, like its neighbor, because CI rebuilds the tracked lib/diagram-render/dist/diagram-render.html and the reviewer diff ceiling refused the ROOT render.

* wave review-53cfb5bfe011: /ship pass 2 fixes

- lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs.
- lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test.
- test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment).

* fix: merge follow-ups for main 3d99c22: origin/* stays qualified in diff-scope's twin step, retro skeleton back under its cap, combined no-loss pin GST-508+R51+GST-911

* fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/<base> (#3852 /ship red-team#1, outside-voice#1)

* test: the docs-only receipt digest test keeps its gate scan out of the real home (free-suite shard 5 FREE_SUBSHARD_REJECTED)

* fix(merge fc292e22f): tentative REVIEW_EXCLUDE_KEY_VERSION, classify targeted.json, render main's new outside-voice test from a clean checkout

- lib/ship-policy.ts: REVIEW_EXCLUDE_KEY_VERSION 1.93.0.0 -> 1.94.0.0, the next-minor tentative form stamp_paths moves (docs/ship-policy.md, #3512); 1.93.0.0 is a released heading that never names the key.
- test/gstack-clean-producers.test.ts (new from main): KEPT gains targeted.json, the wave's --review-targeted input.
- test/review-dispatch.test.ts: main's wrong-tree test rendered the outside voice from ROOT; a CI build that dirties ROOT pushes that diff past the wave's REVIEW_DIFF_MAX_BYTES ceiling, so render refused and verify saw no brief (exit 3). It now runs in cleanCheckout like the wave's other outside-voice tests.

* test(merge 68410828f): GST-967's brief-body pin carries the wave's GST-508 placeholders

The merged sectionTexts differ from main's by exactly the wave's GST-508 exclude-pathspec lines (merged minus main equals wave minus fc292e22f); the 1,440-case plan grid still hashes to main's MAIN_PLANS. Checks: GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md.

* ship: CI carries mapped lanes, --reuse tree cache, early push, finish/accept split (GSTA-258 A)

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

* ship: name the ship and pregate bins in prose so skill-bin-drift does not read a bin

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

* size-ledger: re-record ship after the tests.md wording fix

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

* pregate: a carried run clears the remote gate through remote_gate_runs, so a paid-only remainder is not a dead end

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

* merge cleanup: drop wave fragments main already consumed and a duplicated KEPT entry

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

* fix(ship): record-ci verifies the CI run itself; finish's deferred preflight keeps pregate.json

/ship pass 1 findings red-team#1 and specialist-batched#1 (GSTA-258 piece A).

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

* feat(pregate): CI carry is gstack's default remote gate route; the VM is opt-in (plan choice F1)

The uncleared remote_gate obligation names the CI route first when the base
declares remote_gate_runs. docs/pregate.md says how gstack clears it, including
the pull_request merge-ref tree mismatch. GSTA-258 piece A.

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

* fix: pre-landing review fixes (round 2)

record-ci records the base policy that mapped the lane; the CI-first remote
gate hint respects --no-ci-carry and reruns the whole pregate; changelog and
docs say what the tool does. GSTA-258 piece A.

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

* docs: CI carry in CONTRIBUTING, recommended-harness and the pregate usage line (ship docs audit)

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-11-03 04:02:41 +00:00

936 lines
47 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-evidence — verification-evidence ledger: the mechanical arm of /ship's
* IRON LAW ("no completion claims without fresh verification evidence").
*
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
* gstack-evidence carried [--json]
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
* [--max-age <hours>] [--allow-paths <csv>]
* gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]...
* [--run-url L=<url>]... [--import <json>] [--base <branch>] (plan C3; lib/evidence-bundle.ts)
* gstack-evidence ancestor <sha>... | --from-policy [--base <branch>] (plan C7)
* gstack-evidence verify <bundle> [--rerun] [--out <file>] [--json] (plan E2)
*
* `run` is a TRANSPARENT wrapper: it streams the child's output through
* unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and
* appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree,
* dirty, wtree or wtree_changed, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
*
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
* is parsed from a bounded tail of the streamed output for the supported
* runners only: bun (shard-engine's summary parser where importable; executed =
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, Go's
* zero-only case (every package `[no test files]`), gstack's own shard runner
* (`bun run test:free` / `test:ubicloud`: the sum of its per-shard
* `[test:free] PASS — N tests` epilogues, or a files-from run's
* `FILES-FROM RESULT: … tests=N`), and a `gstack-pregate --remote` lane (the
* remote gate's `PREGATE_REMOTE: … tests=N` row, or the cleared obligation's
* `cleared by remote-gate … tests=N` receipt). The shard runner and the remote
* gate print only bun's terminal totals, which count skipped tests, so for
* those two the number is "ran, skips included" rather than executed (the shard
* runner refuses a shard whose bun summary counts fewer files than planned, but
* a shard whose files all loaded and executed nothing still passes with that
* total). When the shard runner spoke, its verdict stands for the whole tail:
* the tail is the stdout tail then the stderr tail, so a line's position says
* nothing about when it was printed across streams, and bun's own counts (on
* stderr) would otherwise outrank a 10k-test run whichever of the two ran
* first. Any summary when it did not speak follows the last-summary-wins rule;
* anything else is "unknown".
* Three states, never merged:
* a count, 0 (ZERO-RUN, graded like STALE by `check`) and unknown (non-blocking).
*
* TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the
* wrapper's exit code. Every bookkeeping failure — ledger append, log dir,
* non-git context, redact scan — is a stderr warning, never a failure. The
* wrapper must never turn green tests red.
*
* Freshness binds to `wtree`, the working-tree content fingerprint from
* bin/gstack-wtree: evidence recorded on uncommitted code stays FRESH after
* the exact tested content is committed, and an untracked new source file
* invalidates it. `cmd_sha256` = sha256 of the exact command string, no
* normalization — the same convention as bin/gstack-verify-gate (which hashes
* for TRUST; this ledger hashes for FRESHNESS).
*
* `run --reuse` (GSTA-258, what gstack-ship-preflight and finish pass): before
* running, the newest row for this label, command hash and working-tree
* fingerprint decides. It is reused only when it passed with a counted run
* (tests_ran > 0) under the same execution identity (bun and node versions,
* OS, architecture and runner image; lockfiles and pins are tree content) within --max-age
* hours (default 24); a newer failure on that tree vetoes an older pass. The
* reuse appends a copy that keeps the original timestamp, so check's max age
* still measures the real run.
*
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
* rows at the current tree for the ship receipt.
*
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
* A synced record citing an unsynced log would grade FRESH on a machine where
* the log doesn't exist.
*
* `check` is read-only and never throws into the calling skill flow: any git
* failure (gc'd stored tree, not a repo) degrades to STALE/MISSING. Call sites
* must name expected labels explicitly — `--all` checks only labels that exist
* in the ledger; it cannot prove that an expected lane ever ran.
*/
import { openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync, readFileSync } from "fs";
import { mkdirpSync } from "../lib/fs-utils";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
import { resolveStateRoot } from "../lib/state-root";
import { scan, applyRedactions } from "../lib/redact-engine";
import { buildBundle, checkAncestors, readBundle, renderVerification, verifyBundle } from "../lib/evidence-bundle";
import { resultLine } from "../lib/result-codes";
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
import { versionAtRef } from "../lib/restamp";
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
import { findCarry } from "../lib/ci-carry";
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
import { buildRunCheck } from "../lib/pregate/run-check";
import { runInventory } from "../lib/run-evidence";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
const LOG_MAX_BYTES = 2 * 1024 * 1024;
const LOG_PRUNE_DAYS = 30;
interface EvidenceRecord {
ts: string;
label: string;
command: string;
cmd_sha256: string;
exit: number;
duration_s: number;
commit?: string;
tree?: string;
dirty?: boolean;
wtree?: string;
/** Up to five paths whose content changed between the before-run and after-run fingerprints (set instead of wtree). */
wtree_changed?: string[];
log_path?: string;
redacted?: boolean;
/** sha256 of the runtime the run had (identityDigest). */
identity?: string;
/** The original ts of the row a `run --reuse` copied. */
reused_from?: string;
/** "ci" for a lane CI carried (record-ci); absent for a local run. */
source?: "ci";
runs?: string[];
profiles?: string[];
/** The base policy that mapped a carried lane (record-ci), as `origin/<base>@<sha>:.gstack/pregate.json`. */
policy?: string;
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
tests_ran?: number | "unknown";
}
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
const TAIL_MAX_BYTES = 64 * 1024;
/**
* Ring of the last TAIL_MAX_BYTES of each child stream, kept per stream: the
* pumps interleave by arrival, so a stderr summary read while stdout still has
* pipe-buffered bytes behind it would otherwise be pushed out of a shared ring.
*/
class OutputTail {
private readonly decoders = { stdout: new TextDecoder("utf-8"), stderr: new TextDecoder("utf-8") };
private readonly text = { stdout: "", stderr: "" };
push(chunk: Uint8Array, origin: "stdout" | "stderr"): void {
this.text[origin] += this.decoders[origin].decode(chunk, { stream: true });
if (this.text[origin].length > TAIL_MAX_BYTES * 2) this.text[origin] = this.text[origin].slice(-TAIL_MAX_BYTES);
}
end(): string {
const tails = (["stdout", "stderr"] as const).map((origin) => {
const text = this.text[origin] + this.decoders[origin].decode();
return text.length > TAIL_MAX_BYTES ? text.slice(-TAIL_MAX_BYTES) : text;
});
return tails.join("\n");
}
}
const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, "");
const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0);
/**
* Bun: the last "Ran N tests across M files" block; executed = its " N pass" +
* " N fail" lines. Bun always prints that counts block (" 0 pass" / " 0 fail"
* even for an all-skip or empty run), so a terminal line with no " N pass"
* above it is a line gstack's quiet shard runner forwarded (it forwards the
* terminal line without the " N pass" line), not a bun summary:
* it is skipped and the shard runner's own epilogue counts. A terminal line
* that itself says "Ran 0 tests" is ZERO-RUN even without counts, unless the
* shard runner spoke: a forwarded "Ran 0 tests across 1 file" is one shard's
* empty file, not the run.
*/
async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number } | null> {
let isTerminal = (l: string) => /^Ran \d+ tests? across \d+ files?\. \[/.test(l);
try {
const engine = await import("../scripts/lib/shard-engine");
isTerminal = (l: string) => engine.parseBunTerminalSummary(l) !== null;
} catch {
warn("shard-engine classifier not importable; using the built-in bun summary parser");
}
for (let i = lines.length - 1; i >= 0; i--) {
if (!isTerminal(lines[i])) continue;
let pass: number | null = null;
let fail = 0;
for (let j = i - 1; j >= 0 && j >= i - 12; j--) {
if (isTerminal(lines[j])) break;
const p = /^\s*(\d+) pass$/.exec(lines[j]);
const f = /^\s*(\d+) fail$/.exec(lines[j]);
if (p) pass = Number(p[1]);
if (f) fail = Number(f[1]);
}
if (pass === null) {
if (/^Ran 0 tests? across/.test(lines[i])) return { at: i, ran: 0 };
continue;
}
return { at: i, ran: pass + fail };
}
return null;
}
/**
* gstack's shard runner (scripts/test-free-shards.ts; `bun run test:free` /
* `test:ubicloud`). Its quiet shards forward bun's terminal line without the
* counts the bun branch reads, so each shard's epilogue "[test:free] PASS — N
* tests, M files, Ts." carries the shard's "Ran N" total (skips included) and
* the run's total is their sum; a files-from run ends with "[test:free]
* FILES-FROM RESULT: files=F tests=N failed=K exit=E", the same sum. Only the
* last run in the tail counts (its "[test:free] full suite:" / "files-from" /
* "QUICK SUBSET" start line); the other parsers decide only when no runner
* line at all is in the tail. A run with a "FAIL —" or "FLAKY-PASS —" epilogue and no FILES-FROM
* total has lost a shard's count (a FAIL epilogue carries none; the flaky retry
* re-runs only the failing files), so it is unknown rather than a partial sum.
* The runner's verdict covers the whole tail once it spoke: the tail is
* stdout-then-stderr, bun's counts block is on stderr and the runner's
* epilogues are on stdout, so "after" cannot be told from "before" across the
* two streams and a bun block printed before the suite (a focused file run
* first) would otherwise record its count, or ZERO-RUN, for a 10k-test run.
* The quiet shards forward only count-less terminal lines (a shard's "Ran 0
* tests across 1 file" is one empty file, not the run); under `--verbose` they
* forward whole blocks, which the runner's sum outranks. The epilogue is
* matched as the runner's whole line (the log path may contain spaces); like
* every runner summary here it is the child's own stdout, so a test that
* forges one is trusted the same way a forged bun summary is (see W3's trust
* model), and the exit is never derived from it. Returns undefined when no
* runner line is in the tail. A verdict line (PASS/FAIL/FLAKY-PASS epilogue or
* FILES-FROM RESULT) without its start line means the start line was evicted
* from the tail (a chatty default-mode or --verbose run): the runner spoke,
* the sum is incomplete, so the result is unknown rather than a forwarded
* shard block's count. A full-suite start line names its shard count
* ("across N shard processes", plus one exclusive host-state shard when it
* says so); fewer PASS epilogues than that (killed mid-shards, `--ci-verify`)
* is unknown, never a partial sum. An empty shard prints "shard i/N: 0 files,
* 0s, pass" instead of a PASS epilogue and counts as zero.
*/
function shardRunnerTestsRan(lines: string[]): number | "unknown" | undefined {
const verdictLine = /^\s*\[test:free\] (?:PASS — |FAIL — |FLAKY-PASS — |FILES-FROM RESULT: )/;
const start = lines.findLastIndex((l) => /^\s*\[test:free\] (?:full suite:|files-from |QUICK SUBSET:)/.test(l));
if (start < 0) return lines.some((l) => verdictLine.test(l)) ? "unknown" : undefined;
const run = lines.slice(start);
const result = /^\s*\[test:free\] FILES-FROM RESULT: files=\d+ tests=(\d+) failed=\d+ exit=\d+\s*$/;
const total = run.findLast((l) => result.test(l));
if (total) return Number(result.exec(total)![1]);
if (run.some((l) => /^\s*\[test:free\] (?:FAIL|FLAKY-PASS) — /.test(l) || /^\s*\[test:free\] PASS — \? tests/.test(l))) return "unknown";
const counts = run.map((l) => /^\s*\[test:free\] PASS — (\d+) tests?, \d+ files?, \d+s\. Full log: \S.*$/.exec(l)?.[1] ?? (/^\s*\[test:free\] shard \d+\/\d+: 0 files, \d+s, pass\s*$/.test(l) ? "0" : undefined)).filter((n): n is string => n !== undefined);
const planned = /across (\d+) shard processes/.exec(run[0]!);
const expected = planned ? Number(planned[1]) + (/exclusive host-state file/.test(run[0]!) ? 1 : 0) : 1;
return counts.length >= expected ? counts.reduce((sum, n) => sum + Number(n), 0) : "unknown";
}
function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | null {
const hits: { at: number; ran: number }[] = [];
const last = (re: RegExp) => {
for (let i = lines.length - 1; i >= 0; i--) if (re.test(lines[i])) return i;
return -1;
};
// jest: "Tests: 1 failed, 2 passed, 3 total" / "No tests found"
let i = last(/^Tests:\s+.*\d+ total/);
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "passed") + count(lines[i], "failed") });
i = last(/^No tests found/);
if (i >= 0) hits.push({ at: i, ran: 0 });
// vitest: " Tests 3 passed | 1 skipped (4)" / "No test files found"
i = last(/^\s*Tests\s+\d+ .*\(\d+\)\s*$/);
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "passed") + count(lines[i], "failed") });
i = last(/^No test files found/);
if (i >= 0) hits.push({ at: i, ran: 0 });
// pytest: "===== 3 passed, 1 skipped in 0.12s =====" / "no tests ran in 0.01s"
i = last(/^=+ .* in [\d.]+s(?: \([^)]*\))? =+$/);
if (i >= 0) {
const t = lines[i];
hits.push({ at: i, ran: /\bno tests ran\b/.test(t) ? 0 : count(t, "passed") + count(t, "failed") + count(t, "errors?") + count(t, "xfailed") + count(t, "xpassed") });
}
// rspec: "12 examples, 0 failures, 2 pending"
i = last(/^\d+ examples?, \d+ failures?/);
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "examples?") - count(lines[i], "pending") });
// go test: zero-only — every package "[no test files]" and no ok/FAIL package line
const goNoTests = lines.filter((l) => /^\?\s+\S+\s+\[no test files\]$/.test(l));
const goRan = lines.some((l) => /^(?:ok|FAIL|---)\s/.test(l));
if (goNoTests.length > 0 && !goRan) hits.push({ at: last(/\[no test files\]$/), ran: 0 });
// gstack-pregate --remote: the remote gate's own "PREGATE_REMOTE: tree=… tests=N failed=M"
// line (tests = the shard runner's FILES-FROM RESULT sum, relayed by scripts/ubicloud/pregate-remote.sh;
// lib/pregate/checks/lanes.ts parseRemoteReport is the canonical parser), rendered as a lane
// row under "lanes:" in the pregate table, and the obligation's "requires-remote: … cleared by
// remote-gate tree=… tests=N" receipt line (lib/pregate/index.ts). Whole lines, like every
// other runner pattern here, so a prefixed or quoted echo does not count (the row is indented
// under "lanes:" by design, so indentation is accepted). When both appear they carry the same
// count; the later one wins.
for (const re of [/^\s*(?:lanes:\s+)?PREGATE_REMOTE: tree=\S+ tests=(\d+) failed=\d+(?: spent=\S+)?\s*$/, /^\s*requires-remote: .* cleared by remote-gate tree=\S+ tests=(\d+)\b/]) {
i = last(re);
if (i >= 0) hits.push({ at: i, ran: Number(re.exec(lines[i])![1]) });
}
if (hits.length === 0) return null;
return hits.reduce((a, b) => (b.at > a.at ? b : a));
}
/** The runner-reported test count from the output tail (see the header for which runners report executed vs ran), 0, or "unknown". Never throws into the caller's exit path. */
async function parseTestsRan(tail: string): Promise<number | "unknown"> {
if (process.env.GSTACK_EVIDENCE_FAULT === "tests_ran") throw new Error("injected tests_ran parser fault");
const lines = tail.split("\n").map(strip);
const shard = shardRunnerTestsRan(lines);
if (shard !== undefined) return shard;
const candidates = [await bunTestsRan(lines), otherRunnersTestsRan(lines)].filter((c): c is { at: number; ran: number } => c !== null);
if (candidates.length === 0) return "unknown";
return candidates.reduce((a, b) => (b.at > a.at ? b : a)).ran;
}
function warn(msg: string): void {
console.error(`gstack-evidence: warning: ${msg}`);
}
function sha256(text: string): string {
const h = new Bun.CryptoHasher("sha256");
h.update(text);
return h.digest("hex");
}
function git(args: string[]): string | undefined {
try {
const r = spawnSync("git", args, { encoding: "utf-8", timeout: 15000 });
if (r.status !== 0) return undefined;
const out = (r.stdout || "").trim();
return out || undefined;
} catch {
return undefined;
}
}
let wtreeWarned = false;
/** Content fingerprint (SHA-1 or SHA-256 tree id); undefined grades STALE, with the reason printed once. */
function currentWtree(): string | undefined {
const fp = wtreeFingerprint({ cwd: process.cwd(), timeoutMs: 30000 });
if ("tree" in fp) return fp.tree;
if (!wtreeWarned) warn(`working-tree fingerprint unavailable (${fp.error}) — evidence recorded without it (will grade STALE)`);
wtreeWarned = true;
return undefined;
}
function ledgerPath(): { dir: string; file: string; logsDir: string } {
const home = resolveStateRoot();
// No resolvable home: skip bookkeeping (the relative ".gstack" fallback would
// land inside the repo and perturb the fingerprint it exists to compute).
if (home === ".gstack") throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped");
// ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines
// (same branch→filename sanitization as reviews.jsonl).
const slugOut = runScriptSync(join(BIN_DIR, "gstack-slug"), [], { encoding: "utf-8", timeout: 30000 });
const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m);
const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m);
const slug = sm ? sm[1].trim() : "unknown";
const branch = bm ? bm[1].trim() : "no-branch";
const dir = join(home, "projects", slug);
return { dir, file: join(dir, `${branch}-evidence.jsonl`), logsDir: join(dir, "logs") };
}
/** Redact-engine pass over the command string. HIGH finding → store redacted. */
function safeCommandForRecord(command: string): { command: string; redacted: boolean } {
try {
const { findings } = scan(command);
const high = findings.filter((f) => f.tier === "HIGH");
if (high.length === 0) return { command, redacted: false };
const redactedBody = applyRedactions(command, findings.map((f) => f.id)).body;
const still = scan(redactedBody).findings.some((f) => f.tier === "HIGH");
return { command: still ? "<redacted: HIGH credential in command>" : redactedBody, redacted: true };
} catch {
return { command, redacted: false };
}
}
/** Opportunistic prune of logs older than LOG_PRUNE_DAYS. Best-effort. */
function pruneOldLogs(logsDir: string): void {
try {
const cutoff = Date.now() - LOG_PRUNE_DAYS * 24 * 3600 * 1000;
for (const name of readdirSync(logsDir)) {
const p = join(logsDir, name);
try {
if (statSync(p).mtimeMs < cutoff) unlinkSync(p);
} catch {}
}
} catch {}
}
/** Exclusive-open a collision-safe log file. Returns undefined on failure. */
function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined {
try {
mkdirpSync(logsDir);
pruneOldLogs(logsDir);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`;
for (let i = 0; i < 3; i++) {
const p = join(logsDir, i === 0 ? `${base}.log` : `${base}-${i}.log`);
try {
const fd = openSync(p, "ax", 0o600);
return { fd, path: p };
} catch {}
}
} catch (e: any) {
warn(`log setup failed (${e?.message ?? e}) — running unlogged`);
}
return undefined;
}
async function cmdRun(argv: string[]): Promise<number> {
let label = "default";
const li = argv.indexOf("--label");
const sep = argv.indexOf("--");
if (li >= 0 && li + 1 < argv.length && (sep < 0 || li < sep)) label = argv[li + 1];
if (sep < 0 || sep + 1 >= argv.length) {
console.error("usage: gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>");
return 2;
}
const opts = argv.slice(0, sep);
const reuse = opts.includes("--reuse") && !opts.includes("--no-reuse");
const maxAgeAt = opts.indexOf("--max-age");
const reuseMaxAge = maxAgeAt >= 0 ? Number(opts[maxAgeAt + 1]) : 24;
if (!Number.isFinite(reuseMaxAge) || reuseMaxAge <= 0) {
console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(opts[maxAgeAt + 1])}`);
return 2;
}
const cmdArgv = argv.slice(sep + 1);
// Compound/piped commands pass as ONE string via bash -c; a multi-token argv
// runs directly. The hashed command string is exact, no normalization.
const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" ");
const shell = cmdArgv.length === 1 ? bashCommandInvocation(cmdArgv[0]) : null;
const cmdSha = sha256(commandString);
label = label.replace(/[^a-zA-Z0-9._-]/g, "_");
// Bookkeeping context — every piece is optional; failures only warn.
let paths: ReturnType<typeof ledgerPath> | undefined;
try {
paths = ledgerPath();
mkdirpSync(paths.dir);
} catch (e: any) {
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
}
if (reuse && paths && reused(paths.file, label, cmdSha, reuseMaxAge)) return 0;
const log = paths ? openLog(paths.logsDir, label, cmdSha) : undefined;
// Fingerprint the content BEFORE the child runs: a working-tree edit made
// DURING a long suite must not be certified as "the tested content".
const wtreeBefore = currentWtree();
const started = Date.now();
let exitCode: number;
let proc: ReturnType<typeof Bun.spawn> | undefined;
try {
if (shell && !shell.ok) throw new Error(shell.error);
const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv;
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env });
} catch (e: any) {
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
exitCode = 127;
warn(`spawn failed: ${e?.message ?? e}`);
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore);
return exitCode;
}
// Stream-tee: forward chunks as they arrive (never buffer — E2E logs are MBs).
const tail = new OutputTail();
let logBytes = 0;
let truncated = false;
const teeToLog = (chunk: Uint8Array) => {
if (!log || truncated) return;
try {
if (logBytes + chunk.byteLength > LOG_MAX_BYTES) {
const room = LOG_MAX_BYTES - logBytes;
if (room > 0) writeSync(log.fd, chunk.subarray(0, room));
writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log truncated at 2MB — output continued on console]\n"));
truncated = true;
} else {
writeSync(log.fd, chunk);
logBytes += chunk.byteLength;
}
} catch {
truncated = true; // stop teeing on any write failure; console stream continues
try {
writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log ended early (write failure) — output continued on console]\n"));
} catch {}
}
};
const pump = async (stream: ReadableStream<Uint8Array> | undefined, out: NodeJS.WriteStream, origin: "stdout" | "stderr") => {
if (!stream) return;
for await (const chunk of stream) {
// Honor backpressure: when the console consumer is slower than the child
// (piped into a pager/log collector), wait for drain instead of queueing
// unbounded chunks in the WriteStream buffer.
if (!out.write(chunk)) {
// Race drain against error: a dying consumer (EPIPE from `| head`)
// never drains — resolve either way and stop forwarding on error.
await new Promise<void>((r) => {
const done = () => {
out.off("drain", done);
out.off("error", done);
r();
};
out.once("drain", done);
out.once("error", done);
});
}
teeToLog(chunk);
try {
tail.push(chunk, origin);
} catch (e: any) {
warn(`output tail failed (${e?.message ?? e}) — tests_ran will be unknown`);
}
}
};
try {
await Promise.all([pump(proc.stdout as any, process.stdout, "stdout"), pump(proc.stderr as any, process.stderr, "stderr")]);
exitCode = await proc.exited;
if (exitCode === null || exitCode === undefined) exitCode = 1;
} catch (e: any) {
warn(`stream error: ${e?.message ?? e}`);
try {
exitCode = await proc.exited;
} catch {
exitCode = 1;
}
} finally {
if (log) {
try {
closeSync(log.fd);
} catch {}
}
}
// TRANSPARENCY INVARIANT: the parse runs AFTER the exit code is fixed and
// can only change the receipt, never the code this wrapper returns.
let testsRan: number | "unknown" = "unknown";
try {
testsRan = await parseTestsRan(tail.end());
} catch (e: any) {
warn(`tests_ran parse failed (${e?.message ?? e}) — recorded as unknown; the command result stands`);
}
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore, testsRan);
return exitCode;
}
function record(
paths: { dir: string; file: string } | undefined,
logPath: string | undefined,
label: string,
commandString: string,
cmdSha: string,
exitCode: number,
startedMs: number,
wtreeBefore: string | undefined,
testsRan: number | "unknown" = "unknown",
): void {
if (!paths) return;
try {
const { command, redacted } = safeCommandForRecord(commandString);
const rec: EvidenceRecord = {
ts: new Date().toISOString(),
label,
command,
cmd_sha256: cmdSha,
exit: exitCode,
duration_s: Math.round((Date.now() - startedMs) / 100) / 10,
tests_ran: testsRan,
};
if (redacted) rec.redacted = true;
const commit = git(["rev-parse", "HEAD"]);
if (commit) {
rec.commit = commit;
rec.tree = git(["rev-parse", "HEAD^{tree}"]);
rec.dirty = (git(["status", "--porcelain", "-uno"]) ?? "") !== "";
// TOCTOU guard: the fingerprint is only trustworthy when the content was
// IDENTICAL before and after the run. A mid-run edit omits wtree, so
// check grades STALE instead of certifying content the suite never ran.
const wtreeAfter = currentWtree();
if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) {
rec.wtree = wtreeAfter;
rec.identity = identityDigest();
} else if (wtreeBefore || wtreeAfter) {
const changed = wtreeBefore && wtreeAfter ? (git(["diff", "--name-only", wtreeBefore, wtreeAfter]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean) : [];
if (changed.length) rec.wtree_changed = changed.slice(0, 5);
const named = changed.length ? `: ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}` : "";
warn(`working-tree content changed during the run${named} — evidence recorded without a content fingerprint (will grade STALE)`);
}
}
if (logPath) rec.log_path = logPath;
appendJsonl(paths.file, rec, { mode: 0o600 });
try {
chmodSync(paths.file, 0o600);
} catch {}
// Summary line on stderr so calling agents get the exit + log path even
// when the lane ran backgrounded. Never on stdout (stays transparent).
console.error(`gstack-evidence: recorded label=${label} exit=${exitCode} tests_ran=${testsRan} log=${logPath ?? "-"}`);
} catch (e: any) {
warn(`ledger append failed (${e?.message ?? e}) — the command result stands`);
}
}
/**
* sha256 of the runtime a lane ran under: the bun and node on PATH, OS, architecture and runner image. The lockfiles and
* pins the execution identity also names (lib/evidence-bundle.ts) are working-tree content, so the fingerprint binds them.
*/
function identityDigest(): string {
const version = (b: string) => {
const r = spawnSync(b, ["--version"], { encoding: "utf-8", timeout: 15000 });
return r.status === 0 ? (r.stdout || "").trim().replace(/^v/, "") || null : null;
};
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
}
/**
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
*/
function reused(file: string, label: string, cmdSha: string, maxAgeHours: number): boolean {
const wtree = currentWtree();
if (!wtree) return false;
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(file);
} catch {
return false;
}
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(newest.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
if (newest.identity !== identityDigest()) return false;
try {
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
} catch (e: any) {
warn(`ledger append failed (${e?.message ?? e}) — running the command instead`);
return false;
}
console.error(`gstack-evidence: reused label=${label} tree=${wtree.slice(0, 12)} from=${newest.ts}`);
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${newest.tests_ran} log=${newest.log_path ?? "-"}`);
return true;
}
/**
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
*/
function cmdRecordCi(argv: string[]): number {
const sep = argv.indexOf("--");
const opts = sep < 0 ? argv : argv.slice(0, sep);
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_");
const base = get("--base");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!label || !base || !command || opts.length !== 4) {
console.error("usage: gstack-evidence record-ci --label <L> --base <base> -- <command>");
return 2;
}
const none = (reason: string, detail: string) => {
console.error(`CARRY_NONE: ${reason}: ${detail}`);
console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`);
return 1;
};
const cwd = process.cwd();
const loaded = loadConfigFromBase(cwd, base);
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
const from = loaded.config.ship_lanes_from;
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
const m = from[command]!;
const check = buildRunCheck(cwd, null, true, base);
if ("error" in check) return none("profiles_unavailable", check.error);
const head = git(["rev-parse", "HEAD"]);
const tree = git(["rev-parse", "HEAD^{tree}"]);
if (!head || !tree) return none("no_head", "HEAD is unreadable");
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
if (!v.ok) return none(v.reason, v.detail);
let paths: ReturnType<typeof ledgerPath>;
try {
paths = ledgerPath();
mkdirpSync(paths.dir);
} catch (e: any) {
console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`);
return 1;
}
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
const rec: EvidenceRecord = {
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label,
};
appendJsonl(paths.file, rec, { mode: 0o600 });
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`);
return 0;
}
/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */
function cmdCarried(argv: string[]): number {
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(ledgerPath().file);
} catch {
records = [];
}
const wtree = currentWtree();
const latest = new Map<string, EvidenceRecord>();
for (const r of records) latest.set(r.label, r);
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs))
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
if (argv.includes("--json")) console.log(JSON.stringify(carried));
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
return 0;
}
function cmdCheck(argv: string[]): number {
// Parse: repeated --label, each optionally followed (anywhere later) by its
// own --expect-cmd; pairing is positional — an --expect-cmd binds to the most
// recent --label before it.
const wanted: { label: string; expectCmd?: string }[] = [];
let all = false;
let maxAgeHours: number | undefined;
let allowPaths: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--label") wanted.push({ label: argv[++i] ?? "" });
else if (a === "--expect-cmd") {
if (wanted.length === 0) {
console.error("gstack-evidence: --expect-cmd requires a preceding --label");
return 2;
}
wanted[wanted.length - 1].expectCmd = argv[++i] ?? "";
} else if (a === "--all") all = true;
else if (a === "--max-age") {
maxAgeHours = Number(argv[++i]);
if (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0) {
// A typo must never silently drop the age gate (fail open) on a
// freshness checker: it is a usage error.
console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(argv[i])}`);
return 2;
}
}
else if (a === "--allow-paths") allowPaths = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
}
if (!all && wanted.length === 0) {
console.error("usage: gstack-evidence check [--label <L> [--expect-cmd <s>]]... | --all [--max-age <hrs>] [--allow-paths <csv>]");
return 2;
}
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(ledgerPath().file);
} catch {
records = [];
}
const labels = all
? [...new Set(records.map((r) => r.label))].map((label) => ({ label, expectCmd: undefined as string | undefined }))
: wanted;
if (all && labels.length === 0) {
console.log("EVIDENCE: MISSING (ledger empty — no labels recorded)");
return 1;
}
const wtreeNow = currentWtree();
let allFresh = true;
for (const { label, expectCmd } of labels) {
const latest = records.findLast((r) => r.label === label);
if (!latest) {
console.log(`EVIDENCE: MISSING label=${label}`);
allFresh = false;
continue;
}
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
verdict = "STALE";
reason = "recorded run failed";
} else if (testsRan === 0) {
// Zero-run green: exit 0 with nothing executed (all skipped, empty
// selection, "No tests found"). Graded like STALE under its own label;
// "unknown" (no recognised runner summary) stays non-blocking.
verdict = "ZERO-RUN";
reason = "runner reported 0 executed tests";
} else if (maxAgeHours !== undefined) {
const ageMs = Date.now() - Date.parse(latest.ts);
if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) {
verdict = "STALE";
reason = `older than ${maxAgeHours}h`;
}
}
if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) {
verdict = "STALE";
reason = "command changed (cmd_sha256 mismatch)";
}
if (verdict === "FRESH") {
// Content binding: identical working-tree fingerprint, or a diff confined
// to the allow-list. Any git failure (gc'd tree, not a repo) → STALE —
// never an error into the calling flow.
if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) {
// Stored fingerprints are re-validated before reaching git argv — a
// forged/corrupt ledger line must degrade, never inject options.
verdict = "STALE";
reason = !latest.wtree
? changedDuringRun.length
? `content changed during the run: ${changedDuringRun.join(", ")}`
: "record has no content fingerprint"
: !/^[0-9a-f]{40}$/.test(latest.wtree)
? "record has malformed fingerprint"
: "current fingerprint unavailable";
} else if (latest.wtree !== wtreeNow) {
const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]);
if (diff === undefined) {
verdict = "STALE";
reason = "content changed (fingerprint diff unavailable)";
} else {
const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean);
const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/")));
if (changed.length === 0 || outside.length === 0) {
reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : "";
} else {
verdict = "STALE";
reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`;
}
}
}
}
console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`);
if (verdict !== "FRESH") allFresh = false;
}
return allFresh ? 0 : 1;
}
/** C3: export the ledger's latest record per label plus the execution identity as a portable bundle. */
function cmdBundle(argv: string[]): number {
const labels: string[] = [];
const selections: Record<string, string> = {};
const runUrls: Record<string, string> = {};
let out: string | undefined;
let required: string[] | undefined;
let imported: Record<string, unknown>[] = [];
let base: string | undefined;
const kv = (into: Record<string, string>, raw: string) => { const i = raw.indexOf("="); if (i > 0) into[raw.slice(0, i)] = raw.slice(i + 1); };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--label") labels.push(argv[++i] ?? "");
else if (a === "--out") out = argv[++i];
else if (a === "--require") required = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
else if (a === "--selection") kv(selections, argv[++i] ?? "");
else if (a === "--run-url") kv(runUrls, argv[++i] ?? "");
else if (a === "--base") base = argv[++i];
else if (a === "--import") {
try { const parsed = JSON.parse(readFileSync(argv[++i] ?? "", "utf-8")); imported = Array.isArray(parsed) ? parsed : [parsed]; }
catch (e: any) { console.error(`gstack-evidence bundle: --import unreadable (${e?.message ?? e})`); return 2; }
}
}
if (!out) { console.error("usage: gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]... [--run-url L=<url>]... [--import <json>]"); return 2; }
const repoRoot = git(["rev-parse", "--show-toplevel"]) ?? process.cwd();
let records: Record<string, unknown>[] = [];
try { records = readJsonl<Record<string, unknown>>(ledgerPath().file); } catch { records = []; }
const policy = loadPolicyFromBase(repoRoot, resolveBaseBranch(repoRoot, base));
const p = isUnavailable(policy) ? null : policy.policy;
const head = versionAtRef(repoRoot, "HEAD");
const comparison = {
version_rel: "rel" in head ? head.rel : "VERSION", version: "version" in head ? head.version : null,
changelog: p?.changelog ?? "CHANGELOG.md", stamp_paths: p?.stamp_paths ?? [], release_outputs: p?.release_outputs ?? [],
};
let gstackVersion: string | null = null;
try { gstackVersion = readFileSync(join(BIN_DIR, "..", "VERSION"), "utf-8").trim(); } catch {}
const built = buildBundle({ repoRoot, ledgerRecords: records, labels, required, selections, runUrls, imported, comparison, gstackVersion });
for (const e of built.errors) console.error(resultLine("gstack-evidence bundle", e.code, e.message));
if (!built.bundle) return 1;
mkdirpSync(dirname(out));
const { writeFileSync } = require("fs") as typeof import("fs");
writeFileSync(out, JSON.stringify(built.bundle, null, 2) + "\n");
const verified = built.bundle.lanes.filter((l) => l.verified).length;
console.log(`EVIDENCE_BUNDLE: ${out} tree=${built.bundle.repo.tree} lanes=${built.bundle.lanes.length} verified=${verified} imported=${built.bundle.imported.length}${built.bundle.repo.dirty ? " dirty=yes (committed heads only; lanes on a dirty tree are not verified)" : ""}`);
return built.errors.length ? 1 : 0;
}
/** C7: every preregistration sha must be an ancestor of HEAD. */
function cmdAncestor(argv: string[]): number {
const repoRoot = git(["rev-parse", "--show-toplevel"]) ?? process.cwd();
let shas = argv.filter((a) => !a.startsWith("--") && argv[argv.indexOf(a) - 1] !== "--base");
if (argv.includes("--from-policy")) {
const loaded = loadPolicyFromBase(repoRoot, resolveBaseBranch(repoRoot, argv[argv.indexOf("--base") + 1]));
if (isUnavailable(loaded)) { console.error(resultLine("gstack-evidence ancestor", "POLICY_SOURCE_UNAVAILABLE", loaded.detail)); return 3; }
shas = [...shas, ...loaded.policy.preregistration_shas];
}
if (shas.length === 0) { console.log("ANCESTOR: none (no preregistration_shas)"); return 0; }
const results = checkAncestors(repoRoot, shas);
for (const r of results) {
if (r.ok) console.log(`ANCESTOR: ok ${r.sha} (${r.detail})`);
else console.log(`ANCESTOR: missing ${r.sha} (${r.detail}) — fix: merge or rebase so it is in the branch history, or drop it from the policy (PREREGISTRATION_NOT_ANCESTOR)`);
}
return results.every((r) => r.ok) ? 0 : 1;
}
/** E2: rerun each lane's probe on the bundle's tree, or mark each claim from this machine's ledger. */
function cmdVerify(argv: string[]): number {
const file = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--out");
if (!file) { console.error("usage: gstack-evidence verify <bundle.json> [--rerun] [--out <file>] [--json]"); return 2; }
const read = readBundle(file);
if (!read.bundle) { console.error(resultLine("gstack-evidence verify", read.error!.code, read.error!.message)); return 1; }
const repoRoot = git(["rev-parse", "--show-toplevel"]) ?? process.cwd();
let records: Record<string, unknown>[] = [];
try { records = readJsonl<Record<string, unknown>>(ledgerPath().file); } catch { records = []; }
const v = verifyBundle({ repoRoot, bundle: read.bundle, bundlePath: file, rerun: argv.includes("--rerun"), ledgerRecords: records });
const outIdx = argv.indexOf("--out");
if (outIdx >= 0 && argv[outIdx + 1]) {
const { writeFileSync } = require("fs") as typeof import("fs");
mkdirpSync(dirname(argv[outIdx + 1]!));
writeFileSync(argv[outIdx + 1]!, JSON.stringify({ ...read.bundle, verification: v }, null, 2) + "\n");
}
if (argv.includes("--json")) console.log(JSON.stringify(v)); else process.stdout.write(renderVerification(v));
for (const c of v.claims) if (c.verdict === "unverified" && c.required) console.error(resultLine("gstack-evidence verify", "EVIDENCE_UNVERIFIED", `${c.label}: ${c.reason}`));
return v.required_unverified.length ? 1 : 0;
}
const [, , sub, ...rest] = process.argv;
try {
if (sub === "run") {
process.exit(await cmdRun(rest));
} else if (sub === "record-ci") {
process.exit(cmdRecordCi(rest));
} else if (sub === "carried") {
process.exit(cmdCarried(rest));
} else if (sub === "check") {
process.exit(cmdCheck(rest));
} else if (sub === "bundle") {
process.exit(cmdBundle(rest));
} else if (sub === "ancestor") {
process.exit(cmdAncestor(rest));
} else if (sub === "verify") {
process.exit(cmdVerify(rest));
} else {
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
process.exit(2);
}
} catch (e: any) {
// Never let the wrapper's own failure look like a command failure in a way
// that breaks a skill flow: `run` propagates the child's code from inside
// cmdRun; reaching here means bookkeeping blew up outside it.
warn(`unexpected error: ${e?.message ?? e}`);
process.exit(1);
}