From 0bf9d5080897250a97b2f68229bf59ddc2a2e3f6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 16 Oct 2026 16:09:23 -0700 Subject: [PATCH] v1.91.107.0 fix: the GBRA-40 field list (one Bun for install and check, `rec` gate approval, pregate never silent) (#3190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(autoplan): empty findings fences, repo-relative rows, PLAN_REALITY flags, pending/--all/provider (Lane B: B3 B6 B7 B8 B9 B10 B11) B3 parseFences: the body group is optional (lazy), so an LF/CRLF empty fence is zero findings in both the canonical and record grammars; opener without closer still RESULT_FINDINGS_MISSING. B6 plan-reality/3: item/section references are not quantities; backtick phrases are exempt only from the plan-scope rule-phrase check (B-f); a measured number inside backticks stays flagged in review scope. vaguePlanTerms() feeds an advisory `flagged: PLAN_REALITY ...` line under the p1 approval item (never blocking). B7 canonical rows: absolute repo paths become repo-relative; snapshot/active-plan citations become the plan file name with the source line mapped (or dropped). B8 `status` prints `PENDING: phase=

voice= result=` per open attempt. B9 `gstack-gate render --all`; paged output says to relay every page. B10 `gstack-plan-reality check --plan `: a number in a "quoted" span copied verbatim from the reviewed plan needs no receipt (reviewer scope only). B11 submit defaults the provider to host-subagent when --runner is omitted. Lane B files: lib/autoplan-reconcile.ts lib/plan-reality.ts lib/autoplan-gate.ts lib/gate-list.ts lib/autoplan-plan-text.ts lib/autoplan-run.ts bin/gstack-gate bin/gstack-plan-reality scripts/resolvers/plan-reality.ts autoplan/sections/ceo-phase.md autoplan/sections/eng-phase.md test/plan-reality.test.ts test/autoplan-gate-artifacts.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ubicloud): ubi-runner up/run -S accepts only Ubicloud's storage sizes, before any API call (A11) Files: scripts/ubicloud/ubi-runner.sh, test/ubicloud-runner.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(issue-links): promotion candidates, Related PRs, validated flake issues, body/receipt consistency (Lane D: D1-D3) D1: the GBRA-40 fix wave's gbrain #6279 body had no `## Issues` section and no receipt (it was not composed by /ship, so gstack-issue-links never ran), and #6252 appeared only as a subject parenthetical `(#6252)`, which the closure rule correctly treats as Refs. The rule is kept. Same-repo references are now printed as promotion candidates (`(promote: fix #N)`, `candidates` in --json), and `gstack-ship-receipt write --issues ... --body` refuses a body that lacks a line its issues object records (ISSUE_LINKS_MISSING), so a /ship body can no longer drop its closing lines. D2: open PRs by other authors whose closing set overlaps this PR's Fixes are `Related: #N (@author)` (paged GraphQL closedByPullRequestsReferences; viewer and this branch excluded; cross-repo PRs keep owner/repo#N). `Supersedes` and `Contributed by` only with --supersedes/--credit; --no-auto-links skips the lookup; the stderr preview names each added line's source. A failed lookup is ISSUE_LOOKUP_FAILED (installer form with docs anchor) and keeps the other lines. D3: a --ci-run match becomes a Flake line only for a bot-opened issue of this repository that names that run (flake:json first_seen.run_id or this repo's actions/runs URL) and a test; duplicates of one test keep the lowest number; every refused match prints its reason. Files: bin/gstack-issue-links, bin/gstack-ship-receipt, lib/issue-links.ts, lib/ship-receipt.ts, lib/headless-artifacts.ts (ship-receipt issues.related), lib/result-codes.ts (ISSUE_LOOKUP_FAILED, ISSUE_LINKS_MISSING, resultLineWithDocs), bin/gstack-doctor-components.sh (regenerated), docs/pregate.md, docs/troubleshooting.md, test/ship-issue-intent.test.ts, test/helpers/pregate-fixture.ts (gh shim: repo view, api graphql, full issue rows). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): link issues by intent at publish and handoff; Fixes: trailer at commit (Lane D: D1, D2, D-a) - Step 15: a commit fixing a user-named issue carries a `Fixes: #` trailer (offset by tightening Step 15 so the skeleton stays at 80,998 <= 81,000). - pr-body: an explicit "Issue links" step runs gstack-issue-links before the body is composed (previously only a placeholder inside the body template); interactive runs ask once about `(promote: fix #N)` references and add `--supersedes`/`--credit` only on the owner's confirmation; unattended runs keep Refs and list unpromoted candidates, Related lines and lookup failures in the completion message; `ISSUE_LINKS_MISSING` from the receipt write. - handoff (no-PR lanes): runs gstack-issue-links and passes --issues to the receipt; the completion message gains an `issues:` line. - Regenerated ship/SKILL.md and sections; refreshed the four ship goldens. - Parity: union 309,431 / 187,706 = 1.6485 (> 1.6450 by 655 bytes); maxSizeRatio raised to the measured 1.6485 with its justification, as previous waves did. Files: ship/SKILL.md.tmpl, ship/SKILL.md, ship/sections/pr-body.md.tmpl, ship/sections/pr-body.md, ship/sections/handoff.md.tmpl, ship/sections/handoff.md, test/fixtures/golden/{claude,codex,factory,agy}-ship-SKILL.md, test/helpers/carve-guards.ts (ship maxSizeRatio). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): one Bun resolution contract, versioned runtime store, verdict per (checkout, project), drift and wrapper rows (A1-A3, A5, A6, A8, A-c, A-d, A-f, A-h, A-i) - bin/gstack-bun-version.sh: GSTACK_BUN_DEFAULT=1.4.3 beside the 1.4.2 floor, gstack_bun_install_version (GSTACK_BUN_INSTALL_VERSION override), gstack_bun_store, portable gstack_realpath, and gstack_bun_resolve (pin and default in the store, $BUN_INSTALL/bin, ~/.bun/bin, PATH; first candidate satisfying every pin; shim-marked files never returned). gstack_bun_launch_dir is replaced by it. - lib/pregate/runtime.ts: resolveBun, the TypeScript twin; launchRuntime runs the Bun the install verdict recorded under the admission condition and explains the skip otherwise. - bin/gstack-capy-install: install and --check resolve through the contract; verified archives publish atomically to /runtimes/bun//bun under ~/.gstack-runtime.lock and ~/.bun/bin is never written; the "no Bun satisfies" line names the candidates judged; --check reports BUN_RESOLUTION_DRIFT with one converging fix. - bin/gstack-install-verdict.sh, lib/install-verdict.ts: schema 2 verdicts keyed by (checkout, project) with pin, bun_path, bun_version, bun_rule; schema 1 path still read; admission binds test-running workflows to the project and refuses a running Bun the verdict did not record. - bin/gstack-doctor-check.sh, lib/doctor-components.ts: wrapper core row (CAPY_WRAPPER_STALE by active provenance, WARN for stale Drive copies), runtime drift row, shim note, install-version note. - lib/result-codes.ts: BUN_RESOLUTION_DRIFT, BUN_INSTALL_VERSION_INVALID; CAPY_WRAPPER_STALE fix names the workaround and the owner action. Files: bin/gstack-bun-version.sh, bin/gstack-capy-install, bin/gstack-doctor-check.sh, bin/gstack-doctor-components.sh (generated), bin/gstack-install-verdict.sh, lib/doctor-components.ts, lib/install-verdict.ts, lib/pregate/runtime.ts, lib/result-codes.ts, test/bun-resolve.test.ts, test/gstack-capy-install.test.ts, test/gstack-doctor.test.ts, test/install-verdict.test.ts, test/pregate-guards.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(issue-links): --author for the Related lookup when gh runs as an app (Lane D: D2) On a Capy machine the gh viewer is `capy-ai[bot]`, not the owner (checked live against garrytan/gbrain #6252), so "other authors" needs the PR author named: `--author ` overrides the viewer; the pr-body Issue links step says to pass it when gh runs as an app. Files: bin/gstack-issue-links, docs/pregate.md, ship/sections/pr-body.md.tmpl, ship/sections/pr-body.md, test/ship-issue-intent.test.ts, test/helpers/carve-guards.ts (ship maxSizeRatio re-measured: 309,483 / 187,706 = 1.6488). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(troubleshooting): BUN_RESOLUTION_DRIFT and BUN_INSTALL_VERSION_INVALID anchors; CAPY_WRAPPER_STALE covers the wrapper row (A5, A8, A-f, G1) Files: docs/troubleshooting.md, bin/gstack-capy-install, bin/gstack-doctor-check.sh, test/gstack-capy-install.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(install): marked per-directory bun and bunx shims for fresh Capy shells (A4, A-e) gstack-capy-install (install mode, Capy machines only, GSTACK_BUN_SHIM=0 opts out) writes ~/.local/bin/bun and bunx under ~/.gstack-runtime.lock. The shim bakes the store, verdict directory, checkout and install default, walks up to .git without spawning git, runs the Bun the (checkout, project) verdict recorded, else a store Bun matching the .bun-version/package.json subset (stderr names the --check to run when none matches), else the install default, else the next unmarked bun on PATH. A foreign file or dangling link is never touched. GSTACK_BUN_SHIM_EXPLAIN=1 prints project, record, choice and rule; the doctor's runtime row shows the shim and its target. Files: bin/gstack-capy-install, test/gstack-capy-install.test.ts, test/gstack-doctor.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): test stage never hangs silently; small machines record the remote gate (Lane C, GBRA-95) C1 diagnosis (gbrain 92f0ea87, a core edit selecting 3,683 local files, 358 serial; Bun 1.4.2 and 1.4.3 identical): --timeout was applied per runner spawn (unit group plus one per serial file), so the check was unbounded, and spawnSync buffered all output until it returned: 360 s with 0 bytes on stdout and stderr. A backgrounded child holding stdout made spawnSync wait the full timeout and then report exit 0 (a silent wait, then green). Not the Bun 1.4.2 spawnSync hang. C2/C-b: test-tier commands (lanes runner, guards, the --remote gate) run via lib/pregate/supervise.ts: own process group (setsid), stdout/stderr appended to a log file (no pipes), async supervision; CheckDef.run may return a promise and runPregate/withFingerprint await it (a rejection is incomplete). Leftover group members are killed after exit; SIGINT/SIGTERM to pregate SIGKILLs running groups, writes no pregate.json, exits 130/143. Windows: taskkill /T /F (reparented or detached descendants can survive; documented). C3/C-a: --timeout bounds the whole check; groups past the deadline are not started. --stall-timeout (default stall_timeout_s, else 300; 0 disables) kills a group whose log stops growing and records PREGATE_STALLED naming the last file started. Live stderr: PREGATE_TEST per file (bun test adapter; any runner for one-file runs), PREGATE_HEARTBEAT after 60 s without progress, PREGATE: tests . --json stdout unchanged. C4/C-c: under 8 cores, a base-branch remote_gate or a non-local test_backend records the local tier as a requires-remote obligation with its command (pregate.json/remote_gate or test_backend/). --remote runs the declared remote_gate now (needs --allow-repo-commands, never --policy-from head) under --spend-cap/GSTACK_SPEND_CAP_USD; it clears only on PREGATE_REMOTE: tree= tests>0 failed=0 with exit 0, and reports spent/reserved/unknown. G1: new code PREGATE_STALLED has its result-codes row, troubleshooting anchor and the installer-form error line (CODE; url#anchor). Files (G2): lib/pregate/supervise.ts (new), lib/pregate/types.ts, lib/pregate/context.ts, lib/pregate/index.ts, lib/pregate/checks/lanes.ts, lib/pregate/checks/guards.ts, lib/pregate/config.ts, lib/headless-artifacts.ts, lib/result-codes.ts, bin/gstack-pregate, bin/gstack-doctor-components.sh (generated), docs/pregate.md, docs/troubleshooting.md, test/pregate-supervise.test.ts (new), test/pregate-remote.test.ts (new). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 5 pre-gate text for stalls and the small-machine remote gate (Lane C-c) The pre-gate paragraph now says `incomplete` blocks (a stall is incomplete) and that `requires-remote` covers the remote gate under 8 cores, cleared by `gstack-pregate clear` or `--remote --spend-cap ` only under a cap the user gave. Kept within the ship parity size cap (union 308,771 of 308,776 bytes; base 308,767), so the `--explain` hint moved to docs/pregate.md. The handoff's escalation line already names any `requires-remote` lane; unchanged. Files (G2): ship/sections/tests.md.tmpl, ship/sections/tests.md (generated). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): one ok-trailer grammar with requested, pins-skipped and latest notes; bootstrapper revision 2 exports origin's VERSION (A9, A-a) Notes ride only on the ok trailer in fixed order; the failure head is unchanged, pinned by a test that runs the Capy skill's dispatch examples against real doctor output. The latest note comes only from GSTACK_CAPY_REMOTE_VERSION, which the bootstrapper sets from a fetch in this run and unsets otherwise (a stale FETCH_HEAD never reaches the installer). Files: bin/gstack-capy-install, bin/gstack-capy-session, capy/skill/gstack/scripts/install.sh, capy/skill/gstack/SKILL.md, test/gstack-capy-install.test.ts, test/capy-skill-wrapper.test.ts, test/gstack-doctor.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(upgrade): every success path certifies the caller's project; a pins failure keeps the move, ends on gstack: fail pins, exit 1, no pending marker (A7, A-b) gstack-upgrade captures the caller's directory and git toplevel before any cd. Already-current, fast-forwarded and resumed-pending paths run bin/gstack-capy-install --project on Capy (installing a verified pinned Bun) or gstack-doctor --check --require runtime,pins elsewhere. The reinstall's own trailer is held back when a project certification follows, so no ok precedes a pins failure; a RUNTIME_PIN_CONFLICT stop becomes gstack: fail pins — . --check and --ff-only-no-reinstall certify nothing. Files: bin/gstack-upgrade, test/gstack-upgrade-bin.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): fresh-install log lines name the clone and every host installed (A10) gstack-capy-install prints `cloned at ()` for a checkout it cloned, or one the bootstrapper (revision 2, GSTACK_CAPY_CLONED) cloned in this run, instead of `reusing`. setup's final line is `gstack ready ()` from the same rows as its summary table, including the --refresh-registered upgrade summary. Files: bin/gstack-capy-install, bin/gstack-capy-session, capy/skill/gstack/scripts/install.sh, setup, test/gstack-capy-install.test.ts, test/host-conformance.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): --remote runs the gate only when local tests are selected (Lane C4) With only platform lanes selected, --remote no longer launches the remote gate with an empty file list; the existing no-local-lane path applies. Files (G2): lib/pregate/checks/lanes.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): one-word rec, verified pairing, sealed attempt results, deferred reopen (Lane B: B1 B2 B4 B5) B1/B-b/B-c `rec` (aliases: all, approve all, accept all, take all recommendations) takes every recommendation, plan changes included; `as-is` keeps every direction. The render offers `rec` first and prints what it resolves to with gate_rev and rec_hash. `gstack-autoplan recommend` changes one recommendation before relay (provenance recorded, p1 and decisions follow) and bumps rec_rev, so a `rec` without the new --rec-hash is refused (GATE_REC_STALE). An agent answer needs the owner's words verbatim (--owner-reply |-, --owner-reply-file; OWNER_REPLY_REQUIRED); a parseable owner reply that resolves differently from the relay is refused (GATE_OWNER_REPLY_MISMATCH). Supersedes field-wave R1 per the owner's gate_rev 1 decision. B2/B-a reconcile associates by title first; a file:line-only match is `unverified` (associated, never confirmed). `b` is recommended only for a confirmed pair with the same `changes` and one shared proposal (override) or the same normalized remedy; the basis says which. Negative control and positives copied from run-20261014225954 (test/fixtures/autoplan-pairing). B4/B-e each attempt writes -..md; submit refuses another attempt's file (ATTEMPT_MISMATCH) and binds a sealed 0444 -..bound.md copy, so a late write changes nothing bound; reopen keeps sealed copies in place. B5/B-d answer records accepted changes as amendments (status amend_pending) and reopens nothing; revise reopens what changed. `revise --settle ` discharges a packaging/ordering change on an unchanged plan (AMENDMENT_NOT_SETTLEABLE otherwise). APPLY/NEXT/status lines say which. DRIVER.md and the reply table move to the rec grammar. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): durable checkpoint and resume --from; Capy checklist (Lane B: B12) With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every command that leaves a run at rest (gate_pending, amend_pending, complete, incomplete) publishes

//: checkpoint.json listing every file's sha256, the run under run/, the owner's plan file under plan-input/ when it lives outside the run, and restore.md as the human index. Publication swaps in a fully written sibling. Checkpointing at every rest state, not only gate_pending, keeps accepted decisions from rolling back on restore. `gstack-autoplan resume --out --from ` checks containment (no absolute or .. paths, regular files only) and every hash before publishing the run with one rename. It restores a missing plan file into the run, journals `restored` with both hosts, and takes the lock. CHECKPOINT_INVALID and CHECKPOINT_TARGET_EXISTS each get a result-codes row and a troubleshooting anchor. Journal and revise staging paths are now run-relative, so a moved run recovers. DRIVER.md and the Capy skill's autoplan section get a short Capy checklist: always drive gstack-autoplan, never present a hand review as an autoplan run, relay every gate page with rec as the default, pass --owner-reply, and point the checkpoint at the private project-user Drive folder. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * A-g: run the quick free tier on GSTACK_BUN_DEFAULT in CI; drift test pins it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): unattended contract and reply-unparsed fix move to rec, sealed results, settle and checkpoints (Lane B) docs/unattended.md: rec/as-is/rec except grammar, --owner-reply and recommend/--rec-hash, unverified pairing and the shared-proposal rule for b, amend_pending and revise --settle, attempt-owned result paths with the sealed .bound.md copy, render --all, and the checkpoint/resume --from paragraph. GATE_REPLY_UNPARSED's fix names rec/as-is (DRIVER.md regenerated). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs/capy-install: resolver, versioned store, bun shim, trailer notes; wrapper minimum 2 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): --timeout bounds each runner process again, not the whole lanes check Lane C made --timeout a deadline for the whole lanes check, so a repo whose unit group and serial files each fit in 600 s but exceed it together ended incomplete and blocked /ship. --timeout is per runner process again (unit group, each serial file); the stall timeout still catches hung or silent processes. A timed-out process is incomplete naming its last file, and the next serial file still runs. Regression: three 1.5 s serial files under --timeout 3 pass; one 5 s file is incomplete. Files (G2): lib/pregate/checks/lanes.ts, bin/gstack-pregate (help), docs/pregate.md, test/pregate-supervise.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pins): CI exact pins for this platform are alternatives; the installer prefers a non-CI exact pin, else the install default among CI alternatives, else the highest verified (integration item 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): clear validates the remote gate receipt; test_backend run URLs are attested `gstack-pregate clear` accepted any --run-url for a small machine's obligation. A pregate.json/remote_gate obligation now clears only with --receipt holding the gate's PREGATE_REMOTE: line, validated as --remote validates it (tree equals the tested tree, tests > 0, failed = 0; shared parseRemoteReport/remoteReportProblem); a run URL is usage (exit 2) and a bad receipt is PREGATE_RECEIPT_INVALID (exit 1, obligation unchanged). A test_backend/ obligation has no machine-checkable receipt: its --run-url is recorded as attested: and the output says attested, not validated. Platform lanes are unchanged. Hints name the right clear form. New code PREGATE_RECEIPT_INVALID: result-codes row, troubleshooting anchor, installer-form error line, regenerated doctor components. Files (G2): lib/pregate/index.ts, lib/pregate/checks/lanes.ts, bin/gstack-pregate, lib/result-codes.ts, bin/gstack-doctor-components.sh (generated), docs/pregate.md, docs/troubleshooting.md, test/pregate-remote.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(issue-links): derive this PR's author when gh runs as an app, so handoff and pr-body exclude the owner's own PRs (Lane D fix 1) The handoff ran gstack-issue-links without --author, and the pr-body step only asked the agent to pass one, so under Capy's app identity (viewer `capy-ai[bot]`) the owner's own open PRs were listed as `Related:`. The bin now derives the author itself: --author, else the gh viewer, else (for a `…[bot]` viewer) the HEAD commit's GitHub user, else the owner of a user-owned repository; with none it keeps every PR and reports ISSUE_LOOKUP_FAILED. `--json` gains `author: {login, source}`; the stderr preview names it. Both ship steps work unchanged, so the pr-body `--author` clause is dropped (union 309,487 -> 309,435 bytes, under the unchanged 1.6488 cap; skeleton 80,998). Files: bin/gstack-issue-links, lib/result-codes.ts (ISSUE_LOOKUP_FAILED row), docs/troubleshooting.md (#issue-lookup-failed), docs/pregate.md, ship/sections/pr-body.md.tmpl, ship/sections/pr-body.md, test/helpers/pregate-fixture.ts (gh stub: PR-author query), test/ship-issue-intent.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(wrapper): GSTACK_CAPY_MIN_WRAPPER back to 1; an active wrapper below the minimum is a WARN; gstack-upgrade never leaves a pending marker for the wrapper row and maps every installer stop to a trailer (integration items 2, 9) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(shim): validate and contain store pins, read the 'none' record outside projects and in the checkout, fall back in the resolver's order, stay quiet without a pin; quote baked paths; publish bunx and replace store entries without a window (integration items 3, 4, 6, 9) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): --check fails BUN_RESOLUTION_DRIFT only when the recorded Bun is gone, changed or no longer satisfies the pins; a newer candidate is a note and the recorded Bun stays the launch Bun (integration item 5) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(install): one final 'gstack ready (claude, codex)' after both host setups (integration item 7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): lanes and guards validate the install verdict like admission and return incomplete (INSTALL_UNVERIFIED, BUN_RESOLUTION_DRIFT) before launching; the checkout binds the 'none' verdict (integration item 8) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CI pin alternatives, shim order and containment, drift note, wrapper WARN, final ready line Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): settle after or with a revise, title pairs need the same remedy, legacy gates refuse rec (integration items 1, 7, 8) 1. A gate with a scope change (uc1) and a packaging change (uc2) answered `rec` deadlocked: after `revise --plan v2 --incorporates uc1` reopened the phases, `revise --settle uc2` compared against the first phase snapshot (now undefined) and was refused forever. Settle evidence is now the plan hash recorded when the owner accepted the change (Amendment.plan_sha256) or a later plan_revised hash, plus "the plan file has no unrevised edits". `--settle` may ride the same revise as `--plan/--incorporates` and binds to the revision's hash. Settling the last amendment after a reopen leaves the run `running` (the reopened phases rerun) instead of approving it. NEXT and PLAN_AMEND_PENDING list the settle step before the edit step. 7. A title association alone no longer confirms: the same remedy confirms, opposite remedies (one keeps, drops or negates what the other changes) disagree, different or missing remedies stay `unverified`. The pairing fixture gains same-title negative controls (opposite and different remedies) and a same-remedy positive, for ordinary findings and user challenges. 8. A gate.json without rec_rev (written by 1.91.101.0, whose render said `all` leaves b items pending) refuses rec/all/approve all/`all except` with GATE_REC_STALE until a re-render binds --rec-hash; explicit tokens still answer. New gates carry rec_rev: 0. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): checkpoints never touch the live run; generations, newest-valid restore, validated staging (integration items 2-6) 2. checkpointRun canonicalizes the collection and the run directory (symlinks resolved through the deepest existing ancestor) and refuses, before touching anything, when they are the same or one contains the other (CHECKPOINT_OVERLAP); restore applies the same check to source, target and staging. Before, / equal to the live run replaced it. 3. Publication writes an immutable generation (generations/g-…), renames it into place complete, then switches current.json with one rename; the previous generation is kept, so an interruption at the publish boundary (fault hook checkpoint-before-pointer) leaves the last checkpoint current. A generation never replaces a newer one (higher run revision): "CHECKPOINT: skipped … newer". Publication and restore hold the collection's .lock; restore also holds .lock. 4. Restore checks every listed file's real path stays inside the generation (an ancestor symlink no longer copies outside files), parses run.json and runs validateRun on the staged run before publishing; a refusal leaves --out absent. readState reroots plan_input and plan_source for moved runs, and a restore with the owner's plan file gone points both at the restored copy, so a second restore cycle and a later revise work. 5. A checkpoint failure after a committed command prints "CHECKPOINT: failed for : … ; the command's result stands" on stderr and keeps the command's exit code. 6. `gstack-autoplan checkpoints --from ` lists generations newest first (run, gate_rev, status, revision, time, hash ok|bad, current). `resume --from ` restores the newest generation that verifies, printing each corrupt newer one it skipped; a root holding several runs must name one. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: budget the Capy skill's autoplan checklist and trim its install notes Files: capy/skill/gstack/SKILL.md, test/fixtures/context-budget.json (capy/skill/gstack ceiling only: 2364 tok x 1.10) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: release 1.91.102.0 (tentative stamp) — GBRA-40 field list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: supply _setup_ready_line to setup-tail harnesses, register the pairing fixture, make the admission test runner-Bun independent; announce shim writes Files: test/setup-hook-syntax-gate.test.ts, test/redact-prepush-hook.test.ts, test/setup-playwright-best-effort.test.ts, test/helpers/free-fixtures-data.ts, test/pregate-guards.test.ts, bin/gstack-capy-install Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci: run quick-bun-default on the ubicloud-standard-8 class the free suite uses The ubuntu-24.04 runner timed out two Swift typecheck tests at 30 s (render @Observable marker, ios-qa-regen); both pass on Bun 1.4.3 on Ubicloud. Files: .github/workflows/free-tests.yml Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): read bun test's GitHub Actions group headers; exclude pregate-remote from the Windows subset; give quick-bun-default the free suite's flake policy Files: lib/pregate/supervise.ts, test/pregate-supervise.test.ts, scripts/lib/windows-curation.ts, .github/workflows/free-tests.yml Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run the Capy wrapper and shim doctor-row tests on POSIX only (Git Bash prints MSYS paths) Files: test/gstack-doctor.test.ts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: restamp 1.91.107.0 (1.91.106.0 is claimed by #3209) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): spawn the supervised shell through lib/script-invocation (#3197 tripwire) The supervisor now resolves bash via bashCommandInvocation, so Windows uses Git Bash instead of a PATH bash, and an unresolvable bash reports spawn-failed. guards.ts and lanes.ts no longer spawn bash (they run through supervise.ts), so their KNOWN_SITES entries are removed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-functional): disable auto gc and maintenance in the fixture repo On eval-slices (9), qa-functional-webhook-fix failed mid-run with 100+ 'directory watch lost: .git/objects/..' entries and forbidden writes to .git/info/refs and .git/objects/pack. That is git's detached auto maintenance repacking the fixture under the write observer. This applies the same gc.auto=0 / maintenance.auto=false config qa-callers-fixture uses. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan-guard-pty): say Phase 2 starts without waiting for user input On eval-slices (1), haiku published Phase 1 and then ended its turn with 'Waiting for next invocation to continue with step 8'. Step 5 tells it to end its turn while the reviewer runs, so step 8's 'In a later message' read as a later user turn. Phase 2 never started, the guard logged 2 of the 4 expected allows, and the run hit its deadline. Step 8 now says to continue in the next assistant message, still separate from the step 7 publication message. 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> --- .github/workflows/free-tests.yml | 60 ++- CHANGELOG.md | 27 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/DRIVER.md | 50 +- autoplan/DRIVER.md.tmpl | 21 +- autoplan/sections/ceo-phase.md | 2 +- autoplan/sections/eng-phase.md | 2 +- bin/gstack-autoplan | 87 +++- bin/gstack-bun-version.sh | 115 ++++- bin/gstack-capy-install | 433 +++++++++++++---- bin/gstack-capy-session | 3 + bin/gstack-doctor-check.sh | 95 +++- bin/gstack-doctor-components.sh | 19 +- bin/gstack-gate | 10 +- bin/gstack-install-verdict.sh | 70 ++- bin/gstack-issue-links | 147 +++++- bin/gstack-plan-reality | 12 +- bin/gstack-pregate | 86 +++- bin/gstack-ship-receipt | 18 +- bin/gstack-upgrade | 93 +++- capy/skill/gstack/SKILL.md | 6 +- capy/skill/gstack/scripts/install.sh | 14 +- docs/capy-install.md | 130 ++++- docs/pregate.md | 129 ++++- docs/troubleshooting.md | 219 ++++++++- docs/unattended.md | 81 ++-- lib/autoplan-checkpoint.ts | 228 +++++++++ lib/autoplan-export.ts | 2 +- lib/autoplan-gate.ts | 195 ++++++-- lib/autoplan-plan-text.ts | 37 ++ lib/autoplan-prompts.ts | 11 +- lib/autoplan-reconcile.ts | 89 ++-- lib/autoplan-revise.ts | 85 +++- lib/autoplan-run.ts | 57 ++- lib/autoplan-state.ts | 10 +- lib/doctor-components.ts | 7 +- lib/gate-list.ts | 89 ++-- lib/headless-artifacts.ts | 21 +- lib/install-verdict.ts | 102 +++- lib/issue-links.ts | 128 ++++- lib/plan-reality.ts | 61 ++- lib/pregate/checks/guards.ts | 30 +- lib/pregate/checks/lanes.ts | 133 ++++- lib/pregate/config.ts | 22 +- lib/pregate/context.ts | 32 +- lib/pregate/index.ts | 49 +- lib/pregate/runtime.ts | 198 +++++++- lib/pregate/supervise.ts | 189 ++++++++ lib/pregate/types.ts | 14 +- lib/result-codes.ts | 79 ++- lib/runtime-pins.ts | 35 +- lib/ship-receipt.ts | 15 + package.json | 2 +- scripts/lib/windows-curation.ts | 1 + scripts/resolvers/autoplan-driver.ts | 15 +- scripts/resolvers/plan-reality.ts | 2 +- scripts/ubicloud/ubi-runner.sh | 18 +- setup | 13 + ship/SKILL.md | 13 +- ship/SKILL.md.tmpl | 13 +- ship/sections/handoff.md | 6 +- ship/sections/handoff.md.tmpl | 6 +- ship/sections/pr-body.md | 15 +- ship/sections/pr-body.md.tmpl | 15 +- ship/sections/tests.md | 8 +- ship/sections/tests.md.tmpl | 8 +- test/autoplan-answer-invalidation.test.ts | 39 +- test/autoplan-capy-e2e.test.ts | 10 +- test/autoplan-checkpoint.test.ts | 183 +++++++ test/autoplan-driver.test.ts | 2 +- test/autoplan-gate-artifacts.test.ts | 139 ++++++ test/autoplan-gate-judgment.test.ts | 200 +++++--- test/autoplan-pairing.test.ts | 63 +++ test/autoplan-record.test.ts | 11 +- test/autoplan-residuals.test.ts | 144 ++++-- test/autoplan-revise.test.ts | 12 +- test/autoplan-run-concurrency.test.ts | 8 +- test/autoplan-run-recovery.test.ts | 82 +++- test/bun-resolve.test.ts | 134 +++++ test/bun-version-drift.test.ts | 39 +- test/capy-skill-wrapper.test.ts | 29 +- test/cso-distribution.test.ts | 2 +- .../autoplan-pairing/run-20261014-pairs.json | 181 +++++++ test/fixtures/context-budget.json | 2 +- test/fixtures/golden/agy-ship-SKILL.md | 13 +- test/fixtures/golden/claude-ship-SKILL.md | 13 +- test/fixtures/golden/codex-ship-SKILL.md | 13 +- test/fixtures/golden/factory-ship-SKILL.md | 13 +- test/gate-list.test.ts | 39 +- test/gstack-capy-install.test.ts | 456 ++++++++++++++++-- test/gstack-doctor.test.ts | 116 ++++- test/gstack-upgrade-bin.test.ts | 118 +++++ test/helpers/autoplan-capy.ts | 6 +- test/helpers/autoplan-guard-pty.ts | 2 +- test/helpers/autoplan-run-fixture.ts | 13 +- test/helpers/carve-guards.ts | 2 +- test/helpers/free-fixtures-data.ts | 1 + test/helpers/pregate-fixture.ts | 27 +- test/helpers/qa-functional-fixture.ts | 2 + test/host-conformance.test.ts | 2 + test/install-verdict.test.ts | 67 ++- test/plan-reality.test.ts | 56 ++- test/pregate-guards.test.ts | 61 ++- test/pregate-remote.test.ts | 179 +++++++ test/pregate-supervise.test.ts | 227 +++++++++ test/redact-prepush-hook.test.ts | 3 + test/runtime-pins.test.ts | 48 ++ test/script-invocation-tripwire.test.ts | 2 - test/setup-hook-syntax-gate.test.ts | 1 + test/setup-playwright-best-effort.test.ts | 1 + test/ship-issue-intent.test.ts | 190 +++++++- test/ubicloud-runner.test.ts | 25 + 113 files changed, 6041 insertions(+), 921 deletions(-) create mode 100644 lib/autoplan-checkpoint.ts create mode 100644 lib/pregate/supervise.ts create mode 100644 test/autoplan-checkpoint.test.ts create mode 100644 test/autoplan-gate-artifacts.test.ts create mode 100644 test/autoplan-pairing.test.ts create mode 100644 test/bun-resolve.test.ts create mode 100644 test/fixtures/autoplan-pairing/run-20261014-pairs.json create mode 100644 test/pregate-remote.test.ts create mode 100644 test/pregate-supervise.test.ts diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 9cfb037ab..5a51c9a68 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -227,6 +227,60 @@ jobs: - name: CSO source formatting run: bun run format:cso:check + # Capy machines install GSTACK_BUN_DEFAULT (bin/gstack-bun-version.sh) when + # no project pin decides, above the 1.4.2 minimum every other lane pins, so + # the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT; + # test/bun-version-drift.test.ts checks this job exists and agrees. + quick-bun-default: + runs-on: ubicloud-standard-8 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.3 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Configure the bundled Chromium sandbox helper + run: | + set -euo pipefail + chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))') + case "$chrome" in + "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;; + *) echo "Unexpected Chromium installation path" >&2; exit 1 ;; + esac + helper="${chrome%/*}/chrome_sandbox" + installed="${chrome%/*}/chrome-sandbox" + test -f "$helper" && test ! -L "$helper" + sudo install -T -o root -g root -m 4755 "$helper" "$installed" + - name: Install Xvfb + X11 utilities + gate tools + run: sudo apt-get install -y --no-install-recommends xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh + - name: Configure git identity (tests init temp repos) + run: | + git config --global user.email "free-tests-ci@gstack.test" + git config --global user.name "Free Tests CI" + git config --global init.defaultBranch main + git config --global --add safe.directory '*' + - name: Generate host SKILL.md outputs (.agents, .factory) + run: bun run gen:skill-docs --host all + - name: Vendor xterm assets into the extension + run: bun run vendor:xterm + - name: Build server-node bundle (loaded by browse cli imports) + run: bash browse/scripts/build-node-server.sh + - name: Build gate binaries (make-pdf e2e gates) + run: bun run build:gates + - name: Build trusted CSO helper used by free contract tests + run: bun run build:cso + - name: Run the quick free tier on the install-default Bun + env: + GSTACK_FREE_RETRY_FLAKY: "1" + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + run: xvfb-run -a bun run test:quick + free-suite: needs: free-plan runs-on: ubicloud-standard-8 @@ -396,14 +450,15 @@ jobs: # gate is merge-blocking without a separate branch-protection migration. free-tests: if: always() - needs: [free-suite, typecheck, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] + needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - name: Require the free suite, typecheck, macOS regressions, and every CSO platform gate + - name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate env: FREE_SUITE_RESULT: ${{ needs.free-suite.result }} TYPECHECK_RESULT: ${{ needs.typecheck.result }} + QUICK_BUN_DEFAULT_RESULT: ${{ needs.quick-bun-default.result }} CSO_MACOS_RESULT: ${{ needs.cso-macos-launcher.result }} MACOS_REGRESSIONS_RESULT: ${{ needs.macos-named-regressions.result }} CSO_WINDOWS_RESULT: ${{ needs.cso-windows-launcher.result }} @@ -412,6 +467,7 @@ jobs: set -eu test "$FREE_SUITE_RESULT" = success test "$TYPECHECK_RESULT" = success + test "$QUICK_BUN_DEFAULT_RESULT" = success test "$CSO_MACOS_RESULT" = success test "$MACOS_REGRESSIONS_RESULT" = success test "$CSO_WINDOWS_RESULT" = success diff --git a/CHANGELOG.md b/CHANGELOG.md index 75563f124..e7c0852ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.91.107.0] - 2026-10-16 + +**The GBRA-40 field list is fixed: install and check agree on one Bun, `rec` approves an autoplan gate in one word, and pregate never sits silent.** Eighteen Capy threads upgraded to v1.91.101.0 and reported 21 problems (`GBRA-40`, October 14). Each gstack item was reproduced with a test that failed first; the plan went through `/autoplan` (two gate revisions, Claude and GPT-6.1 Sol voices per phase) and two independent code reviews. Two items are Capy's (skills not mounted on some crew threads, Drive copies not re-synced) and are handed off, not worked around. + +### What this means for you + +- **Install, `--check` and upgrade resolve the same Bun.** One resolution contract (`gstack_bun_resolve` in `bin/gstack-bun-version.sh`, twinned in `lib/pregate/runtime.ts`) picks, in order, the project's exact verified pin in gstack's runtime store, the install default, `$BUN_INSTALL/bin`, `~/.bun/bin`, then PATH, and only a Bun that satisfies every applicable pin. The install verdict is keyed by checkout and project (`install-verdicts//.json`) and records the Bun's path and version; `--check` fails `BUN_RESOLUTION_DRIFT` only when that Bun is gone, changed or no longer fits the pins. +- **Fresh machines get Bun 1.4.3; the floor stays 1.4.2.** `GSTACK_BUN_DEFAULT="1.4.3"` is installed when no pin decides (`GSTACK_BUN_INSTALL_VERSION` overrides it with another verified version). Verified Buns unpack into `$GSTACK_STATE_ROOT/runtimes/bun//` with `bunx`; `~/.bun/bin/bun` is never overwritten. +- **Fresh Capy shells run the right Bun without a PATH export.** On Capy cloud machines the installer writes marked `~/.local/bin/bun` and `bunx` shims that pick the Bun recorded for the current project (or the no-project record), validate and contain any `.bun-version` pin, and stay quiet unless a pin cannot be served. `GSTACK_BUN_SHIM_EXPLAIN=1` says why; `GSTACK_BUN_SHIM=0` opts out; a foreign `~/.local/bin/bun` is never touched. +- **CI pins in different jobs are alternatives.** A project whose CI tests Bun 1.4.2 and 1.4.3 in different jobs is satisfied by either; package.json, `.tool-versions` and `.bun-version` pins are still all required. This also explains GBRA-39's backwards `outside CI 1.4.2` report. +- **`gstack-upgrade` certifies the project you ran it from**, on the no-op, fast-forward and pending-repair paths alike. A pins failure prints `gstack: fail pins …` after a kept move and leaves no pending-reinstall marker; any other installer stop prints `gstack: fail runtime — …`. +- **The ok trailer says what it skipped and what is newer:** `gstack: ok for= (pins: skipped (planning-only|no project); latest: available)`. The `gstack: fail ` head is unchanged. +- **A stale Drive wrapper is visible.** With no active-wrapper provenance the doctor reads the Drive copies itself and fails `CAPY_WRAPPER_STALE` only when every copy is stale; an active copy below the minimum is a `WARN` owner action. +- **`rec` takes every recommendation, plan changes included.** `all`, `approve all`, `accept all` and `take all recommendations` are aliases; `as-is` keeps the plan as written; `rec except d4a` overrides one item. The render shows `rec` first with a `rec_hash`; the new `gstack-autoplan recommend` lets the relaying parent set an item's recommendation, after which a stale `rec` is refused (`GATE_REC_STALE`). An agent's answer needs `--owner-reply` with the owner's words, and words that parse to different choices are refused. Gates rendered by an older gstack refuse alias replies until re-rendered. +- **Two-voice recommendations need a shared remedy.** A file:line or title match alone is `unverified`; opposite remedies are a disagreement. +- **Autoplan artifacts survive revisions, late edits and machine loss.** An empty findings fence means no findings. Each attempt writes its own result file and `submit` binds a sealed `.bound.md` copy. `answer` reopens nothing (`amend_pending`); `revise` reopens what actually changed, and `revise --settle ` closes a packaging-only change, in the same call as `--incorporates` if needed. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, each rest state publishes an immutable checkpoint generation; `gstack-autoplan checkpoints --from ` lists them and `resume --from ` restores the newest valid one after validating it. +- **Smaller autoplan fixes:** `status` lists `PENDING:` attempts; `gstack-gate render --all`; findings' `file` fields are repo-relative; numbers quoted from the plan are not `NUMBER_UNRECEIPTED`; an outside voice without `--runner` is `(host-subagent)`; PLAN_REALITY flags an unquantified "minimum effect / margin / decision rule" beside the approval item without blocking. +- **`gstack-pregate` never hangs silently.** Test commands run in their own process group with output to a log; `PREGATE_TEST:` progress and heartbeats go to stderr live; `--stall-timeout` (default 300 s, `stall_timeout_s` in `.gstack/pregate.json`) kills a silent group (`PREGATE_STALLED`); `--timeout` stays per runner process. Under 8 cores with a declared remote gate the tier becomes a `requires-remote` obligation; `--remote --spend-cap ` runs it and clears it only on a matching `PREGATE_REMOTE:` receipt. +- **`/ship` issue links.** `gstack-issue-links` lists commit mentions as `Refs` candidates (`fix #N` promotes one), adds `Related: #N (@author)` for other authors' open PRs closing the same issues (Supersedes and credit only with `--supersedes`/`--credit`), and maps a CI run id only to a bot-filed flake issue naming that run and a test. `gstack-ship-receipt write` refuses a body missing its issue lines (`ISSUE_LINKS_MISSING`). +- **Also:** the Capy installer ends with one `gstack ready (claude, codex)` line and says `cloned … at ` for a fresh clone; `ubi-runner -S` accepts only 320 or 640 unless `UBI_ALLOWED_STORAGE` says otherwise. + +### For contributors + +- New CI job `quick-bun-default` runs the quick tier on `GSTACK_BUN_DEFAULT`; `test/bun-version-drift.test.ts` pins it and `free-tests` requires it. +- New tests: `test/bun-resolve.test.ts`, `test/autoplan-checkpoint.test.ts`, `test/autoplan-pairing.test.ts` (with this run's real pairs as fixtures), `test/pregate-supervise.test.ts`, `test/pregate-remote.test.ts`; many existing suites gained regressions. +- The ship parity union cap is 1.6488 (measured), and the Capy skill's eager budget is 2601 tokens for its new autoplan checklist. + ## [1.91.106.0] - 2026-10-16 **The 2026-10-15 issue wave is fixed, with every adopted contributor patch kept under its author.** Twenty-odd field reports and community PRs from the week after v1.91.101.0 (`docs/designs/FIX_WAVE_2026_10_15.md`): an `/autoplan` run locked by a Claude Code 2.1.300 compaction, `/cso` scans that could not finish, auto-updates that left conflict markers or held every Windows install, Windows pregate and restamp unable to run gstack's own scripts, plan reviews that lost their native second opinion on Claude Code 2.1.277+, and a batch of maintenance fixes. Each was reproduced with a test that failed first on v1.91.101.0. diff --git a/VERSION b/VERSION index 71f208571..ba334a60f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.106.0 +1.91.107.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e2b1b7dd5..694e2eaca 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.106.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.107.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 3f29a58c0..48cad0438 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -28,15 +28,23 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=`); render it with - `gstack-gate render /gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render /gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`. + ### Commands - `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--scaffold]` @@ -51,16 +59,22 @@ line yourself; gstack never writes the plan. bind a canonical result (the RESULT FORMAT below). - `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"` close the phase native-only when no outside voice can run. -- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"` - answer the gate with the reply grammar below. +- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]` + answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim. +- `gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"` + change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`). - `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>` bind the amended plan; reopens every phase whose reviewed plan changed, Eng last. +- `gstack-autoplan revise --out <dir> --settle <decision id|d4>` + discharge a packaging or ordering change with no plan edit; reopens nothing. - `gstack-autoplan revise --out <dir> --withdraw <decision id>` drop a pending amendment instead of applying it. - `gstack-autoplan export --out <dir>` write the artifact set; then `gstack-artifact validate <dir>/run.json`. - `gstack-autoplan status|resume --out <dir>` - read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). + read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). +- `gstack-autoplan resume --out <new dir> --from <checkpoint>` + restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them. ## Reviewer result format @@ -73,22 +87,23 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c ```gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} ``` - severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. + severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. 3. Write the whole result to this file: <result file> ## Gate replies A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed. -- `all`: every item takes its recommendation (an agreed change prints APPLY: and needs revise). -- `all as-is`: keep every direction; no plan change. +- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias. +- `as-is`: keep every direction; no plan change. - `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default. -- `all except d3b`: recommendations, with the listed overrides. +- `rec except d3b`: recommendations, with the listed overrides. ## Run directory Runner state: `run.json`, `attempts.jsonl`, `run.lock`, `spend.json`, `plan-active.md`, `restore.md`, `plan-source.md`. Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. @@ -107,8 +122,13 @@ is the completion check. Copy the whole directory, never single files. - `MODEL_FAMILY_CONFLICT`: pass `--model <id>` of a different family (anthropic, openai, google, xai, deepseek, qwen, zai, moonshot, meta, mistral); a same-family result is not an outside voice - `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary - `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev -- `GATE_REPLY_UNPARSED`: reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous -- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `GATE_REPLY_UNPARSED`: reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous +- `GATE_REC_STALE`: render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash` +- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>` +- `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree +- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews +- `CHECKPOINT_INVALID`: run `gstack-autoplan checkpoints --from <dir>` to see each generation's hash status and restore an intact one; nothing was written to --out - `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade ## Coordinator contract (inherited by every lane) diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 46a0ef6ce..11b00c2ee 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -25,15 +25,23 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with - `gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:<id>` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. +### On Capy + +- Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by + hand is not an autoplan run: do not present it as one. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest + survives machine replacement; restore with `resume --out <new dir> --from <dir>/<run>`. + ### Commands {{AUTOPLAN_DRIVER_COMMANDS}} @@ -54,7 +62,8 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa ## Run directory {{AUTOPLAN_DRIVER_FILES}} Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. diff --git a/autoplan/sections/ceo-phase.md b/autoplan/sections/ceo-phase.md index de9d0d1bd..a878f308b 100644 --- a/autoplan/sections/ceo-phase.md +++ b/autoplan/sections/ceo-phase.md @@ -176,7 +176,7 @@ Sections 1-11 — for EACH section, run the evaluation criteria from the loaded **Mandatory outputs from Phase 1:** - Reality rows: the loaded skill's `REALITY:` lines, one per applicable row; - `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase ceo --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes (unattended: `incomplete` keeps it open). - "NOT in scope" section with deferred items and rationale - "What already exists" section mapping sub-problems to existing code diff --git a/autoplan/sections/eng-phase.md b/autoplan/sections/eng-phase.md index ad9da2018..d9d3856f1 100644 --- a/autoplan/sections/eng-phase.md +++ b/autoplan/sections/eng-phase.md @@ -105,7 +105,7 @@ Retain the historical review-log skill ID; add `"host":"claude","outside_provide 1. Step 0 (Scope Challenge): Read actual code referenced by the plan. Map each sub-problem to existing code. Run the complexity check. Produce concrete findings. Reality rows: the loaded skill's `REALITY:` lines, one per applicable row; - `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes + `~/.claude/skills/gstack/bin/gstack-plan-reality check --phase eng --plan <reviewed snapshot> <review-file|->` prints `PLAN_REALITY: … verdict=complete` before the phase closes (unattended: `incomplete` keeps it open). 2. Step 0.5 (Dual Voices): Present the completed calls above under Codex SAYS diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index eb91f849a..becab9f1d 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -7,25 +7,28 @@ * gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>] * --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json] * gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json] - * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--json] review a revised plan in this run + * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run * gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] * [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices - * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" [--json] - * gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN + * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json] + * gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] + * gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes * gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran + * gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad * gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2) * * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), * lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts. * Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here. - * Mutating verbs (next, submit, answer, revise, export, run) pass admission first (lib/autoplan-admission.ts) + * Mutating verbs (next, submit, answer, recommend, revise, export, run) pass admission first (lib/autoplan-admission.ts) * and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock. */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { answerGate } from '../lib/autoplan-gate'; +import { amendmentSteps, answerGate, recommendGate } from '../lib/autoplan-gate'; +import { checkpointLines, checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; import { migrateRun } from '../lib/autoplan-migrate'; @@ -55,13 +58,22 @@ DRIVER: ${driverFile()} without its INPUT line it binds only with --attest <reason> and --attempt (receipt: attested) submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> the outside voice could not run: closes the phase native_only once native is bound - revise --out <dir> --plan <file> [--incorporates <decision id,...>] [--withdraw <decision id>] - review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (plus Eng); - --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one + revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] | --settle <id,...> + review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (Eng last); + --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one; + --settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4) run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ... next in a loop, only when a runner executes both voices here; --deadline cancels the children it started - answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json] - resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> + answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json] + --by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim; + --rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending + recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] + set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards + resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first + (a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command + leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr) + checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current + status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) The first next needs --plan. The native voice is dispatched by the parent from the printed prompt file (a subagent on the @@ -87,10 +99,9 @@ function num(args: string[], name: string): number | undefined { } const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=autoplan status=${status} run=${out}`; const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : [])); -/** The NEXT line after a reopen or revise: revise while accepted changes are pending, else next. */ +/** The NEXT line after an answer or revise: per pending change, settle it or edit the plan and revise; else next. */ function nextLine(state: RunState, out: string): string { - const pending = pendingAmendments(state).map(a => a.decision); - return pending.length ? `NEXT: gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')}` : `NEXT: gstack-autoplan next --out ${out}`; + return pendingAmendments(state).length ? `NEXT: ${amendmentSteps(state, out)}` : `NEXT: gstack-autoplan next --out ${out}`; } /** @@ -116,10 +127,21 @@ function mutating(out: string, json: boolean, fn: () => number): number { if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n'); const code = fn(); if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); } + if (code === EXIT.ok) checkpointQuietly(out); return code; }); } +/** A checkpoint never changes a committed command's outcome: a failure is a stderr warning, the exit code stands. */ +function checkpointQuietly(out: string): void { + try { + const line = checkpointRun(out); + if (line) process.stderr.write(line + '\n'); + } catch (e: any) { + process.stderr.write(`CHECKPOINT: failed for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands, the previous checkpoint stays current\n`); + } +} + function emit(json: boolean, lines: string[], payload: Record<string, unknown>): void { if (json) process.stdout.write(JSON.stringify(payload) + '\n'); else process.stdout.write(lines.join('\n') + '\n'); @@ -166,12 +188,12 @@ function doSubmit(out: string, rest: string[], json: boolean): number { } function doRevise(out: string, rest: string[], json: boolean): number { - const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); - if (!plan && !withdraw.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), or --withdraw <decision id>'); + const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle'); + if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>'); if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>'); - const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() }); + const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() }); const lines = [...r.lines]; - if (r.state.status === 'running') lines.push(nextLine(r.state, out)); + if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out)); lines.push(resultLineFor(r.state.status, out)); emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out }); return EXIT.ok; @@ -224,6 +246,12 @@ async function main(argv: string[]): Promise<number> { 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 === 'contract') { process.stdout.write(COORDINATOR_CONTRACT + '\n'); return EXIT.ok; } + if (cmd === 'checkpoints') { + const from = flag(rest, '--from'); + if (!from) return usage('checkpoints needs --from <checkpoint dir>'); + try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; } + catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; } + } const outFlag = flag(rest, '--out'); if (!outFlag) return usage(`${cmd} needs --out <dir>`); const out = path.resolve(outFlag); @@ -238,9 +266,12 @@ async function main(argv: string[]): Promise<number> { return EXIT.ok; } if (cmd === 'resume') { + const from = flag(rest, '--from'); + const restored = from !== undefined ? restoreCheckpoint(from, out) : []; return withRunLock(out, () => { const r = resumeRun(out); - const lines = [...r.lines, resultLineFor(r.state.status, out)]; + checkpointQuietly(out); + const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)]; emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out }); return EXIT.ok; }, { steal: true }); @@ -249,17 +280,31 @@ async function main(argv: string[]): Promise<number> { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by'); if (reply === undefined) return usage('answer needs --reply "<text>"'); if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; } + const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply'); + if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive'); + const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag; return mutating(out, json, () => { const state = readState(out); - const r = answerGate(state, Number(rev), reply, by); + const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply }); writeState(state); const lines = [...r.lines]; - if (r.outcome === 'reopened') lines.push(nextLine(state, out)); + if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out)); lines.push(resultLineFor(state.status, out)); emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out }); return EXIT.ok; }); } + if (cmd === 'recommend') { + const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis'); + if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"'); + return mutating(out, json, () => { + const state = readState(out); + const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent'); + writeState(state); + emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out }); + return EXIT.ok; + }); + } if (cmd === 'export') { return mutating(out, json, () => { const state = readState(out); @@ -283,7 +328,7 @@ async function main(argv: string[]): Promise<number> { } catch (e: any) { if (e instanceof RunError) { process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n'); - if (['next', 'submit', 'answer', 'resume', 'run', 'revise'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); + if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); return e.exit === 3 ? EXIT.refused : EXIT.fail; } process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`); diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index a8c769f9b..e4a6d28fd 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -13,8 +13,12 @@ # compiled probe (a binary built with all four no-autoload flags, run beside a # .env): Bun 1.3.2 loads it, 1.3.3 and 1.4.0 do not. # GSTACK_BUN_TESTED is the supported minimum: the CI pin and engines.bun. +# GSTACK_BUN_DEFAULT is what the Capy installer downloads when no project pin +# decides (GSTACK_BUN_INSTALL_VERSION overrides it); it is a verified archive +# at or above the minimum, and one CI job runs the quick tier on it. GSTACK_BUN_FLOOR="1.3.3" GSTACK_BUN_TESTED="1.4.2" +GSTACK_BUN_DEFAULT="1.4.3" # gstack_bun_archive_sha256 TARGET [VERSION] — the SHA-256 of # https://github.com/oven-sh/bun/releases/download/bun-v$VERSION/bun-TARGET.zip @@ -115,28 +119,95 @@ gstack_bun_incoming_hold() { echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" } -# gstack_bun_launch_dir [exact] — the directory to put first on PATH so -# gstack's `#!/usr/bin/env bun` bins run a supported Bun: the first of -# $BUN_INSTALL/bin and $HOME/.bun/bin whose bun is ok (and equals <exact> -# when given), unless the bun already first on PATH qualifies. Prints the -# directory (callers read its version with "<dir>/bun --version"); prints -# nothing and returns 1 when PATH's bun suffices or no candidate qualifies. Shared by -# gstack-capy-install (the GSTACK_LAUNCH line) and gstack-doctor --check (the -# runtime row's `found at ... (not on PATH)`). -gstack_bun_launch_dir() { - _gb_path_bun=$(command -v bun 2>/dev/null || true) - if [ -n "$_gb_path_bun" ]; then - _gb_v=$(bun --version </dev/null 2>/dev/null | head -1) - if [ "$(gstack_bun_status "$_gb_v")" = ok ] && { [ -z "${1:-}" ] || [ "$_gb_v" = "$1" ]; }; then return 1; fi - fi - for _gb_dir in "${BUN_INSTALL:+$BUN_INSTALL/bin}" "${HOME:+$HOME/.bun/bin}"; do - [ -n "$_gb_dir" ] && [ -x "$_gb_dir/bun" ] || continue - [ "$_gb_dir/bun" = "$_gb_path_bun" ] && continue - _gb_v=$("$_gb_dir/bun" --version </dev/null 2>/dev/null | head -1) - [ "$(gstack_bun_status "$_gb_v")" = ok ] || continue - [ -z "${1:-}" ] || [ "$_gb_v" = "$1" ] || continue - printf '%s\n' "$_gb_dir" - return 0 +# gstack_bun_install_version — the Bun the installer downloads when no exact +# project pin decides: GSTACK_BUN_INSTALL_VERSION when set, else +# GSTACK_BUN_DEFAULT. Prints the version; returns 1 when it has no verified +# archive or sits below the supported minimum (the caller refuses it). +gstack_bun_install_version() { + _gb_iv="${GSTACK_BUN_INSTALL_VERSION:-$GSTACK_BUN_DEFAULT}" + printf '%s\n' "$_gb_iv" + case " $GSTACK_BUN_VERIFIED " in *" $_gb_iv "*) ;; *) return 1 ;; esac + [ "$(gstack_bun_status "$_gb_iv")" = ok ] +} + +# gstack_bun_store STATE_ROOT — the versioned runtime store: verified archives +# unpack to <store>/<version>/bun and are never replaced in place; +# ~/.bun/bin and $BUN_INSTALL/bin are never written. +gstack_bun_store() { printf '%s/runtimes/bun\n' "$1"; } + +# GSTACK_BUN_SHIM_MARKER — the second line of the per-directory `bun` and +# `bunx` shims gstack-capy-install writes to ~/.local/bin. A file carrying it +# is never a resolution candidate and is the only kind of file gstack replaces. +GSTACK_BUN_SHIM_MARKER="# gstack-bun-shim: gstack-owned; delete this file to remove it" +_gstack_bun_is_shim() { [ -f "$1" ] && head -c 512 "$1" 2>/dev/null | grep -aqF "$GSTACK_BUN_SHIM_MARKER"; } + +# gstack_realpath FILE — FILE with every symlink resolved (Bash 3.2: a +# readlink loop and cd -P, no realpath(1)). Returns 1 when unresolvable. +gstack_realpath() { + _gb_rp="$1" _gb_n=0 + while [ -L "$_gb_rp" ]; do + [ "$_gb_n" -lt 40 ] || return 1 + _gb_lk=$(readlink "$_gb_rp") || return 1 + case "$_gb_lk" in /*) _gb_rp="$_gb_lk" ;; *) _gb_rp="$(dirname "$_gb_rp")/$_gb_lk" ;; esac + _gb_n=$((_gb_n + 1)) done + _gb_rd=$(cd -P "$(dirname "$_gb_rp")" 2>/dev/null && pwd -P) || return 1 + printf '%s/%s\n' "${_gb_rd%/}" "$(basename "$_gb_rp")" +} + +_gstack_bun_try() { # RULE FILE ACCEPT + [ -n "$2" ] && [ -f "$2" ] && [ -x "$2" ] || return 1 + _gstack_bun_is_shim "$2" && return 1 + _gb_rp=$(gstack_realpath "$2") || return 1 + case "$_gb_seen" in *"|$_gb_rp|"*) return 1 ;; esac + _gb_seen="$_gb_seen$_gb_rp|" + _gb_v=$("$_gb_rp" --version </dev/null 2>/dev/null | head -1 | tr -cd 'A-Za-z0-9.+-') + if [ "$(gstack_bun_status "${_gb_v:-0}")" != ok ] || { [ -n "$3" ] && ! "$3" "$_gb_v" "$_gb_rp"; }; then + _gb_judged="${_gb_judged:+$_gb_judged, }bun ${_gb_v:-unknown} at $_gb_rp ($1)" + return 1 + fi + _gb_bin="$_gb_rp" _gb_ver="$_gb_v" _gb_rule="$1" +} + +# gstack_bun_resolve STORE [EXACT_PIN] [ACCEPT] — the one Bun resolution +# contract, shared by gstack-capy-install (install and --check), the doctor, +# the bun shim and install admission; resolveBun in lib/pregate/runtime.ts is +# the TypeScript twin and test/bun-resolve.test.ts runs both on one layout. +# Candidates, first eligible wins: +# pin STORE/<EXACT_PIN>/bun (the project's exact pin) +# default STORE/<gstack_bun_install_version>/bun +# BUN_INSTALL $BUN_INSTALL/bin/bun +# home $HOME/.bun/bin/bun +# PATH every bun on PATH, in order +# Eligible: it runs, reports a version at or above the supported minimum, and +# `ACCEPT <version> <realpath>` (the caller's project-pin check) succeeds. A +# shim-marked file is never a candidate; a realpath is judged once. Sets +# _gb_bin (realpath), _gb_ver, _gb_rule and _gb_judged (the candidates that ran +# and were refused, for messages); returns 1 with _gb_bin empty when none qualifies. +gstack_bun_resolve() { + _gb_bin="" _gb_ver="" _gb_rule="" _gb_judged="" _gb_seen="|" + _gb_store="$1" _gb_accept="${3:-}" + [ -n "${2:-}" ] && _gstack_bun_try pin "$_gb_store/$2/bun" "$_gb_accept" && return 0 + _gb_dv=$(gstack_bun_install_version) && _gstack_bun_try default "$_gb_store/$_gb_dv/bun" "$_gb_accept" && return 0 + [ -n "${BUN_INSTALL:-}" ] && _gstack_bun_try BUN_INSTALL "$BUN_INSTALL/bin/bun" "$_gb_accept" && return 0 + [ -n "${HOME:-}" ] && _gstack_bun_try home "$HOME/.bun/bin/bun" "$_gb_accept" && return 0 + _gb_path="${PATH:-}:" + while [ -n "$_gb_path" ]; do + _gb_dir="${_gb_path%%:*}" _gb_path="${_gb_path#*:}" + [ -n "$_gb_dir" ] && _gstack_bun_try PATH "$_gb_dir/bun" "$_gb_accept" && return 0 + done + _gb_bin="" _gb_ver="" _gb_rule="" return 1 } + +# gstack_bun_rule_text RULE — why a resolution was chosen, for summaries. +gstack_bun_rule_text() { + case "$1" in + pin) echo "the project's exact pin, from gstack's runtime store" ;; + default) echo "gstack's install default, from gstack's runtime store" ;; + BUN_INSTALL) echo "\$BUN_INSTALL/bin" ;; + home) echo "~/.bun/bin" ;; + PATH) echo "the first eligible bun on PATH" ;; + *) echo "$1" ;; + esac +} diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 78a0bd854..4f112da2f 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -10,14 +10,21 @@ # Steps: clone or reuse the checkout under a lock (never reset, never pulled # here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and # hands its held lock over with GSTACK_CAPY_LOCK_HELD=1); preflight git, curl, -# node, jq and Bun (a supported Bun already in $BUN_INSTALL/bin is reused; the -# exact release archive, SHA-256 verified, is installed there only when -# gstack's supported minimum and the project's pins agree on it); ./setup +# node, jq and Bun through gstack_bun_resolve (an installed Bun that satisfies +# gstack's minimum and the project's pins is reused; otherwise the exact +# release archive, SHA-256 verified, is published in the runtime store +# <state root>/runtimes/bun/<version>/bun, GSTACK_BUN_INSTALL_VERSION +# overriding the default); ./setup # --host claude --no-prefix; ./setup --host codex --model gpt-6-astra (both # skipped when this HEAD was already set up and the check passes); # gstack-doctor --check with the launch PATH; the install verdict -# (bin/gstack-install-verdict.sh). --check certifies only: no clone, no Bun -# install, no setup. Chromium is lazy by default (bin/gstack-browser-ensure +# (bin/gstack-install-verdict.sh), keyed by checkout and project and holding +# the resolved Bun. --check certifies only: no clone, no Bun install, no +# setup. On a Capy machine (CAPY_MACHINE_CONFIG or ~/.capy/system) install also writes the marked +# per-directory shims ~/.local/bin/bun and ~/.local/bin/bunx, which run the Bun +# recorded for the project around the working directory in any fresh shell; +# GSTACK_BUN_SHIM=0 opts out, a foreign ~/.local/bin/bun is never touched, and +# deleting the files removes them. Chromium is lazy by default (bin/gstack-browser-ensure # installs it on first use); --with-browser installs it now. The last lines # are the GSTACK_LAUNCH line the parent supplies to every gstack command it # launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`; @@ -31,7 +38,7 @@ ROOT="${GSTACK_CAPY_ROOT:-$SELF}" REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}" DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md" FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}" -usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; } need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; } while [ $# -gt 0 ]; do case "$1" in @@ -84,102 +91,209 @@ elif [ ! -f "$ROOT/setup" ] || [ ! -f "$ROOT/VERSION" ]; then else git clone --quiet --single-branch --depth 1 "$REPO" "$ROOT" || stop INSTALL_PREFLIGHT_FAILED "could not clone $REPO" "check network access to github.com, then re-run" fi + say "cloned $REPO at $(git -C "$ROOT" rev-parse --short=12 HEAD 2>/dev/null) ($(head -1 "$ROOT/VERSION"))" +elif [ -n "${GSTACK_CAPY_CLONED:-}" ]; then + say "cloned $GSTACK_CAPY_CLONED at $(git -C "$ROOT" rev-parse --short=12 HEAD 2>/dev/null) ($(head -1 "$ROOT/VERSION"))" else say "reusing $ROOT ($(head -1 "$ROOT/VERSION")); never reset or pulled" fi . "$ROOT/bin/gstack-bun-version.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-bun-version.sh is missing" "re-clone gstack" . "$ROOT/bin/gstack-install-verdict.sh" || stop INSTALL_PREFLIGHT_FAILED "$ROOT/bin/gstack-install-verdict.sh is missing" "re-clone gstack" -# 2. Preflight: tools, then Bun against gstack's minimum and the project's pins. -# The launch directory is where the Bun the trailer certifies lives when it is -# not first on PATH; the GSTACK_LAUNCH line carries it to every later command. -LAUNCH_DIR="" WANT="$GSTACK_BUN_TESTED" -BUN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin" -launch_with() { LAUNCH_DIR="$1"; export PATH="$1:$PATH"; } -install_bun() { # REASON — download, verify and install Bun $WANT into $BUN_DIR - say "$1; installing bun $WANT from its release archive (SHA-256 verified)" +# 2. Preflight: tools, then Bun through the one resolution contract +# (gstack_bun_resolve: the project's exact pin and gstack's install default in +# the versioned runtime store, $BUN_INSTALL/bin, ~/.bun/bin, PATH; the first +# that satisfies gstack's minimum and every project pin wins). A verified +# archive is downloaded into the store only when no candidate qualifies; the +# store is published atomically and ~/.bun/bin is never written. --check reads +# the pins with the Bun the verdict recorded (any eligible Bun when that one is +# gone; the jq subset when nothing is recorded) and reports drift from the +# recorded resolution (BUN_RESOLUTION_DRIFT) with one converging fix. +STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:=$STATE_ROOT}"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" +ROOT_REAL=$(cd "$ROOT" && pwd -P) +STORE=$(gstack_bun_store "$GSTACK_STATE_ROOT") +[ "$CHECK_ONLY" -eq 1 ] || for tool in curl node jq; do + command -v "$tool" >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: $tool not found" "install $tool (apt-get install -y jq; Node from https://nodejs.org or nvm), then re-run" +done +INSTALL_VERSION=$(gstack_bun_install_version) || stop BUN_INSTALL_VERSION_INVALID "GSTACK_BUN_INSTALL_VERSION=$INSTALL_VERSION is not a verified Bun at or above gstack's supported minimum $GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED)" "unset GSTACK_BUN_INSTALL_VERSION, or set it to one of: $GSTACK_BUN_VERIFIED" +[ -n "$PROJECT" ] || { PROJECT=$(cd "$ORIG_PWD" && git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT_REAL" ] && PROJECT=""; } +PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" 2>/dev/null && pwd -P) +VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL") +REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" +if [ -f "$VERDICT" ]; then + REC_BUN=$(gstack_install_verdict_field "$VERDICT" bun_path) REC_VER=$(gstack_install_verdict_field "$VERDICT" bun_version) + REC_RULE=$(gstack_install_verdict_field "$VERDICT" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$VERDICT" bun_install) +fi +LAUNCH_DIR="" RES_BIN="" RES_VER="" RES_RULE="" +PINS_OUT="" PINS_MODE=none RUNNER="" PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" +# read_pins RUNNER — the project's pins through the doctor's parser +# (lib/runtime-pins.ts: engines, packageManager, .tool-versions, .bun-version, +# CI lanes) when RUNNER can run it, else the package.json / .bun-version subset +# read with jq. Sets PINS_MODE (parser|jq|none) and PIN_EXACT, the exact Bun +# to prefer: the one non-CI exact pin (two different ones conflict), else +# among this platform's CI exact pins (alternatives; any one satisfies CI) +# the install version when listed, else the highest verified one, else the +# highest one. +read_pins() { + RUNNER="$1" PINS_OUT="" PINS_MODE=none PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" + [ -n "$PROJECT" ] || return 0 + [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) + if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then + PINS_MODE=parser + _exact=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: bun \([^ ]*\) \([^ ]*\)$/\1 \2/p') + if [ "$(printf '%s\n' "$_exact" | grep -c .)" -gt 1 ]; then + stop RUNTIME_PIN_CONFLICT "Bun conflict: $PROJECT pins more than one exact Bun ($(printf '%s\n' "$_exact" | sed 's/^\([^ ]*\) \(.*\)$/\2 pins \1/' | tr '\n' ';' | sed 's/;$//; s/;/; /g'))" "make the project's exact Bun pins agree" + fi + if [ -n "$_exact" ]; then + PIN_EXACT=${_exact%% *} PIN_SRC="$PROJECT/$(printf '%s' "${_exact#* }" | sed 's/:[0-9]*$//')" + return 0 + fi + _alts=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_CI_ALTERNATIVES: bun //p') + [ -n "$_alts" ] || return 0 + case "$_alts" in *' '*) PIN_ALTS="$_alts" ;; esac + PIN_SRC="$PROJECT/.github/workflows (CI exact pins: $_alts)" + case " $_alts " in *" $INSTALL_VERSION "*) PIN_EXACT="$INSTALL_VERSION"; return 0 ;; esac + for _v in $(printf '%s\n' $_alts | sort -t. -k1,1nr -k2,2nr -k3,3nr); do + gstack_bun_archive_sha256 linux-x64 "$_v" >/dev/null && [ "$(gstack_bun_status "$_v")" = ok ] && { PIN_EXACT="$_v"; return 0; } + done + PIN_EXACT=$(printf '%s\n' $_alts | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -1) + return 0 + fi + command -v jq >/dev/null 2>&1 || return 0 + PINS_MODE=jq + if [ -f "$PROJECT/package.json" ]; then + PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1) + [ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json" + fi + if [ -z "$PIN" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi + case "$PIN" in + '') PINS_MODE=none ;; + [0-9]*.[0-9]*.[0-9]*) PIN_EXACT="$PIN" ;; + '>='*) ;; + *) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;; + esac +} +# pins_accept VERSION — the resolver's eligibility hook: VERSION satisfies every project pin. +pins_accept() { + case "$PINS_MODE" in + parser) "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$' ;; + jq) case "$PIN" in + '>='*) _gstack_bun_below "$1" "${PIN#>=}"; [ $? -eq 1 ] ;; + [0-9]*.[0-9]*.[0-9]*) [ "$1" = "$PIN" ] ;; + *) return 0 ;; + esac ;; + *) return 0 ;; + esac +} +pins_text() { + case "$PINS_MODE" in + parser) printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^bun' | tr '\n' ';' | sed 's/;$//; s/;/; /g' ;; + *) printf '%s' "${PIN:+$PIN ($PIN_SRC)}" ;; + esac +} +resolve() { # — RES_* from the contract under the project's pins + if gstack_bun_resolve "$STORE" "$PIN_EXACT" pins_accept; then RES_BIN="$_gb_bin" RES_VER="$_gb_ver" RES_RULE="$_gb_rule"; return 0; fi + RES_BIN="" RES_VER="" RES_RULE="" + return 1 +} +install_bun() { # VERSION REASON — download, verify and publish Bun VERSION at $STORE/VERSION/bun + WANT="$1" + say "$2; installing bun $WANT from its release archive (SHA-256 verified) into $STORE/$WANT" case "$(uname -ms)" in 'Linux x86_64') TARGET=linux-x64; grep -q avx2 /proc/cpuinfo 2>/dev/null || TARGET=linux-x64-baseline ;; 'Linux aarch64'|'Linux arm64') TARGET=linux-aarch64 ;; *) stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $(uname -ms) (this installer is for Capy cloud Ubuntu)" "install Bun $WANT by hand (https://bun.sh), then re-run" ;; esac SHA=$(gstack_bun_archive_sha256 "$TARGET" "$WANT") || stop INSTALL_PREFLIGHT_FAILED "no verified Bun $WANT archive for $TARGET" "install Bun $WANT by hand, then re-run" - TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-bun.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir" "point TMPDIR at a writable directory" - curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run" - echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; } - mkdir -p "$BUN_DIR" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$BUN_DIR/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $BUN_DIR" "install unzip, or set BUN_INSTALL to a writable prefix"; } - rm -rf "$TMP" - LD_BIN="$BUN_DIR/bun" LD_VER=$("$BUN_DIR/bun" --version 2>/dev/null | head -1) - [ "$LD_VER" = "$WANT" ] || stop INSTALL_PREFLIGHT_FAILED "installed bun reports $LD_VER, expected $WANT" "check $BUN_DIR/bun, then re-run" - launch_with "$BUN_DIR" - say "bun $WANT installed at $BUN_DIR/bun (the GSTACK_LAUNCH line puts $BUN_DIR first on PATH)" + mkdir -p "$STORE" || stop INSTALL_PREFLIGHT_FAILED "cannot create the runtime store $STORE" "set GSTACK_STATE_ROOT to a writable directory" + exec 8>"$HOME/.gstack-runtime.lock" + if command -v flock >/dev/null 2>&1; then flock -w 300 8 || stop INSTALL_PREFLIGHT_FAILED "another install holds $HOME/.gstack-runtime.lock" "wait for it to finish"; fi + if [ ! -x "$STORE/$WANT/bun" ] || [ "$("$STORE/$WANT/bun" --version 2>/dev/null | head -1)" != "$WANT" ]; then + TMP=$(mktemp -d "$STORE/.tmp.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir in $STORE" "set GSTACK_STATE_ROOT to a writable directory" + curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run"; } + echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; } + mkdir "$TMP/pub" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$TMP/pub/bun" && ln -s bun "$TMP/pub/bunx" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $STORE" "install unzip, or set GSTACK_STATE_ROOT to a writable directory"; } + _got=$("$TMP/pub/bun" --version 2>/dev/null | head -1) + [ "$_got" = "$WANT" ] || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "the downloaded bun reports $_got, expected $WANT; nothing installed" "re-run; report it if it repeats"; } + # Publish without a window: a new version directory is renamed into + # place; in an existing one each file is renamed over its predecessor. + if [ -d "$STORE/$WANT" ] && [ ! -L "$STORE/$WANT" ]; then + mv -fT "$TMP/pub/bun" "$STORE/$WANT/bun" && mv -fT "$TMP/pub/bunx" "$STORE/$WANT/bunx" + else + rm -f "$STORE/$WANT" && mv -T "$TMP/pub" "$STORE/$WANT" + fi || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not publish $STORE/$WANT" "check $STORE, then re-run"; } + rm -rf "$TMP" + fi + [ -e "$STORE/$WANT/bunx" ] || [ -L "$STORE/$WANT/bunx" ] || ln -s bun "$STORE/$WANT/bunx" 2>/dev/null || true + exec 8>&- + say "bun $WANT installed at $STORE/$WANT/bun" +} +conflict() { # DETAIL + stop RUNTIME_PIN_CONFLICT "Bun conflict: $1" "raise the project's pin to a verified version ($GSTACK_BUN_VERIFIED), or install gstack on a machine the project already provisions" } if [ "$CHECK_ONLY" -eq 1 ]; then - LAUNCH_DIR=$(gstack_bun_launch_dir) && export PATH="$LAUNCH_DIR:$PATH" + _runner="" + if [ -n "$REC_BUN" ] && [ -x "$REC_BUN" ]; then _runner="$REC_BUN" + elif [ -n "$REC_BUN" ] && gstack_bun_resolve "$STORE"; then _runner="$_gb_bin" + fi + read_pins "$_runner" + resolve || true else -for tool in curl node jq; do - command -v "$tool" >/dev/null 2>&1 || stop INSTALL_PREFLIGHT_FAILED "preflight: $tool not found" "install $tool (apt-get install -y jq; Node from https://nodejs.org or nvm), then re-run" -done -[ -n "$PROJECT" ] || { PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT" ] && PROJECT=""; } -HAVE_BIN=$(command -v bun 2>/dev/null || true) -HAVE=$([ -n "$HAVE_BIN" ] && bun --version 2>/dev/null | head -1) -HAVE_OK=0; [ "$(gstack_bun_status "${HAVE:-0.0.0}")" = ok ] && HAVE_OK=1 -LD_BIN="" LD_VER="" -for _d in "$BUN_DIR" "$HOME/.bun/bin"; do - [ -x "$_d/bun" ] && [ "$_d/bun" != "$HAVE_BIN" ] || continue - _v=$("$_d/bun" --version 2>/dev/null | head -1) - [ "$(gstack_bun_status "${_v:-0.0.0}")" = ok ] && { LD_BIN="$_d/bun" LD_VER="$_v"; break; } -done -# follow_pin VERSION SOURCE — an exact project pin becomes the Bun to install -# when it is at or above gstack's minimum and has a verified archive; anything -# else is a conflict naming both constraints and the verified versions. -follow_pin() { - [ "$1" = "$WANT" ] && return 0 - if [ "$WANT" = "$GSTACK_BUN_TESTED" ] && [ "$(gstack_bun_status "$1")" = ok ] && gstack_bun_archive_sha256 linux-x64 "$1" >/dev/null; then WANT="$1"; return 0; fi - stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED), $2 pins exactly $1" "raise the project's pin to a verified version, or install gstack on a machine the project already provisions" -} -reuse_ld() { launch_with "$(dirname "$LD_BIN")"; say "reusing bun $LD_VER at $LD_BIN (not first on PATH; the GSTACK_LAUNCH line puts it first)"; } -# No Bun at or above the security floor anywhere: install the verified one -# first, so the project's pins can be read with it. -case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) [ -z "$LD_BIN" ] && install_bun "bun ${HAVE:-absent} cannot read the project's pins" ;; esac -RUNNER="$HAVE_BIN"; case "$(gstack_bun_status "${HAVE:-0.0.0}")" in too-old|malformed) RUNNER="$LD_BIN" ;; esac -PINS_OUT="" -[ -n "$PROJECT" ] && [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) -pins_ok() { "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$'; } -if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then - # The project's pins, read by the doctor's own parser (lib/runtime-pins.ts): - # engines, packageManager, .tool-versions, .bun-version and CI lanes. - if [ "$HAVE_OK" -eq 1 ] && pins_ok "$HAVE"; then say "bun $HAVE satisfies gstack's supported minimum $WANT and the project pins" - elif [ -n "$LD_BIN" ] && pins_ok "$LD_VER"; then reuse_ld + # A Bun to read the pins with: the contract without project pins, else + # any Bun at or above the security floor (the parser runs there). + _runner="" + if gstack_bun_resolve "$STORE"; then _runner="$_gb_bin" + elif command -v bun >/dev/null 2>&1 && [ "$(gstack_bun_status "$(bun --version 2>/dev/null | head -1)")" = untested ]; then _runner=$(command -v bun) + fi + read_pins "$_runner" + if ! resolve; then + JUDGED="${_gb_judged:-none}" + if [ -n "$PIN_EXACT" ]; then + [ "$(gstack_bun_status "$PIN_EXACT")" = ok ] && gstack_bun_archive_sha256 linux-x64 "$PIN_EXACT" >/dev/null \ + || conflict "gstack needs >=$GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED), $PIN_SRC pins exactly $PIN_EXACT" + WANT="$PIN_EXACT" + else + WANT="$INSTALL_VERSION" + fi + pins_accept "$WANT" || conflict "gstack needs >=$GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED), $PROJECT pins $(pins_text)" + if [ -n "$PROJECT" ] && [ "$PINS_MODE" != none ]; then + install_bun "$WANT" "no installed Bun satisfies gstack's supported minimum $GSTACK_BUN_TESTED and the project pins $(pins_text) (judged: $JUDGED)" + else + install_bun "$WANT" "no installed Bun satisfies gstack's supported minimum $GSTACK_BUN_TESTED (judged: $JUDGED)" + fi + [ "$PINS_MODE" = parser ] || read_pins "$STORE/$WANT/bun" + resolve || conflict "bun $WANT in $STORE does not satisfy $PROJECT's pins $(pins_text) (judged: ${_gb_judged:-none})" + fi + [ -n "${GSTACK_BUN_INSTALL_VERSION:-}" ] && say "install version $INSTALL_VERSION from GSTACK_BUN_INSTALL_VERSION (default $GSTACK_BUN_DEFAULT)" + say "bun $RES_VER at $RES_BIN ($(gstack_bun_rule_text "$RES_RULE"); satisfies gstack's supported minimum $GSTACK_BUN_TESTED$([ -n "$PIN_ALTS" ] && printf " and the project's CI pins (alternatives: %s)" "$PIN_ALTS" || printf '%s' "${PIN_EXACT:+ and the project pin $PIN_EXACT}")$([ -z "$PIN_EXACT" ] && [ -n "$PIN" ] && printf ' and the project pin %s' "$PIN")$([ -z "$PIN_EXACT" ] && [ "$PINS_MODE" = parser ] && printf ' and the project pins'))" +fi +# Drift (--check only). The recorded Bun stays certified while it exists, +# reports the recorded version and satisfies gstack's minimum and the pins; +# a different candidate that would now resolve first is only a note. Drift +# (BUN_RESOLUTION_DRIFT) is the recorded Bun gone, changed or no longer +# satisfying the pins. +if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then + _why="" _wrapper="${GSTACK_CAPY_WRAPPER_PATH:+bash $GSTACK_CAPY_WRAPPER_PATH}"; _wrapper="${_wrapper:-$ROOT/bin/gstack-capy-install}${FOR:+ --for $FOR}" + _recnow=""; [ -x "$REC_BUN" ] && _recnow=$("$REC_BUN" --version 2>/dev/null | head -1) + if [ ! -x "$REC_BUN" ]; then _why="the recorded binary is gone" + elif [ "$_recnow" != "$REC_VER" ]; then _why="$REC_BUN now reports ${_recnow:-nothing} (replaced in place)" + elif [ "$(gstack_bun_status "$REC_VER")" != ok ] || ! pins_accept "$REC_VER"; then _why="bun $REC_VER no longer satisfies gstack's supported minimum $GSTACK_BUN_TESTED and the project pins $(pins_text)" + fi + if [ -z "$_why" ]; then + [ "$REC_BUN" != "$RES_BIN" ] && say "note: the recorded bun $REC_VER at $REC_BUN stays certified; ${RES_BIN:+bun $RES_VER at $RES_BIN ($RES_RULE) would now resolve first; }run $_wrapper to record the current resolution" + RES_BIN="$REC_BUN" RES_VER="$REC_VER" RES_RULE="$REC_RULE" else - while read -r _c _src; do - [ -z "$_c" ] || follow_pin "$_c" "$PROJECT/${_src%%:*}" - done <<EOF_PINS -$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN: bun \([^ ]*\) exact \([^ ]*\) lane=[a-z]*$/\1 \2/p') -EOF_PINS - pins_ok "$WANT" || stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PROJECT pins $(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^bun' | tr '\n' ';' | sed 's/;$//')" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" - if [ "$LD_VER" = "$WANT" ]; then reuse_ld; else install_bun "bun ${HAVE:-absent} does not satisfy the project pins (bun $WANT) or gstack's minimum $GSTACK_BUN_TESTED"; fi - fi -else - # No pin report (no project, or a Bun that cannot run the parser): the - # package.json / .bun-version subset, read with jq. - PIN="" PIN_SRC="" EXACT=0 - if [ -n "$PROJECT" ] && [ -f "$PROJECT/package.json" ]; then - PIN=$(jq -r '.engines.bun // (.packageManager // "" | select(startswith("bun@")) | ltrimstr("bun@")) // empty' "$PROJECT/package.json" 2>/dev/null | head -1) - [ -n "$PIN" ] && PIN_SRC="$PROJECT/package.json" - fi - if [ -z "$PIN" ] && [ -n "$PROJECT" ] && [ -s "$PROJECT/.bun-version" ]; then PIN=$(head -1 "$PROJECT/.bun-version" | tr -d ' v'); PIN_SRC="$PROJECT/.bun-version"; fi - case "$PIN" in - '') ;; - '>='*) _gstack_bun_below "$WANT" "${PIN#>=}" && stop RUNTIME_PIN_CONFLICT "Bun conflict: gstack needs >=$WANT, $PIN_SRC pins $PIN" "raise the project's pin to $WANT or newer, or install gstack on a machine the project accepts" ;; - [0-9]*.[0-9]*.[0-9]*) follow_pin "$PIN" "$PIN_SRC"; EXACT=1 ;; - *) say "note: project pin '$PIN' ($PIN_SRC) is not evaluated here; gstack-doctor's pins row checks it after install" ;; - esac - if [ "$HAVE_OK" -eq 1 ] && { [ "$EXACT" -eq 0 ] || [ "$HAVE" = "$WANT" ]; }; then say "bun $HAVE satisfies gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}" - elif [ -n "$LD_BIN" ] && { [ "$EXACT" -eq 0 ] || [ "$LD_VER" = "$WANT" ]; }; then reuse_ld - elif [ -z "$LAUNCH_DIR" ] || [ "$LD_VER" != "$WANT" ]; then install_bun "bun ${HAVE:-absent} does not satisfy gstack's supported minimum $GSTACK_BUN_TESTED${PIN:+ and the project pin $PIN}" + if [ -n "$RES_BIN" ]; then _fix="$_wrapper (records the current resolution: bun $RES_VER at $RES_BIN)" + else _fix="$_wrapper (reinstalls bun ${PIN_EXACT:-$INSTALL_VERSION} into $STORE)" + fi + _now="no eligible bun"; [ -n "$RES_BIN" ] && _now="bun $RES_VER at $RES_BIN ($RES_RULE)" + export GSTACK_DOCTOR_RUNTIME_DRIFT="recorded bun $REC_VER at $REC_BUN ($REC_RULE), now $_now; changed: $_why" + export GSTACK_DOCTOR_RUNTIME_DRIFT_FIX="$_fix" fi fi +if [ -n "$RES_BIN" ] && [ "$(gstack_realpath "$(command -v bun 2>/dev/null || echo /nonexistent)" 2>/dev/null)" != "$RES_BIN" ]; then + LAUNCH_DIR=$(dirname "$RES_BIN") + export PATH="$LAUNCH_DIR:$PATH" fi # 3. Install both host profiles; Chromium lazy unless --with-browser. Skipped @@ -189,18 +303,18 @@ fi cd "$ROOT" || exit 1 export GSTACK_SKIP_COREUTILS="${GSTACK_SKIP_COREUTILS:-1}" GSTACK_SKIP_ASIDE="${GSTACK_SKIP_ASIDE:-1}" BROWSER_FLAG="--no-browser"; [ "$WITH_BROWSER" -eq 1 ] && BROWSER_FLAG="" -STATE_ROOT=$("$ROOT/bin/gstack-paths" --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:=$STATE_ROOT}"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" -ROOT_REAL=$(pwd -P) HEAD_SHA=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true) SETUP_STATE="$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null || echo "$ROOT/.git")/gstack-capy-install.state" SETUP_RECORD="head=$HEAD_SHA hosts=claude,codex model=gpt-6-astra browser=$WITH_BROWSER" -VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL") if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi [ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser" certify() { OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} ${MIN_REVISION:+--min-revision "$MIN_REVISION"} 2>&1); RC=$?; } -record_verdict() { # TRAILER OK +record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) + if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then set -- "$1" "$2" "$REC_BUN" "$REC_VER" "$REC_RULE" "$REC_BUN_INSTALL" + else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \ "$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ + "$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" \ || say "could not write the install verdict $VERDICT" } setup_failed() { # MESSAGE @@ -218,6 +332,7 @@ if [ "$CHECK_ONLY" -eq 0 ] && [ "$SKIPPED" -eq 0 ]; then T1=$(now) ./setup --host claude --no-prefix $BROWSER_FLAG </dev/null || setup_failed "./setup --host claude failed (exit $?)" ./setup --host codex --model gpt-6-astra $BROWSER_FLAG </dev/null || setup_failed "./setup --host codex failed (exit $?)" + echo "gstack ready (claude, codex)" T2=$(now) [ -n "$HEAD_SHA" ] && printf '%s\n' "$SETUP_RECORD" > "$SETUP_STATE" 2>/dev/null say "planning-only install: $((T2 - T1))s (setup both hosts; Chromium not downloaded)" @@ -232,16 +347,148 @@ if [ "$CHECK_ONLY" -eq 0 ] && [ "$SKIPPED" -eq 0 ]; then fi [ "$CHECK_ONLY" -eq 1 ] && certify "$@" -# 4. Certify. The trailer is the contract; `(requested <ref>; ...)` when the -# existing checkout is not at --revision (it is never moved here). +# write_shim — the per-directory bun and bunx shims (Capy only, install mode). +# The store, verdict directory, checkout and install default are baked in, so +# a fresh shell needs no environment; rewritten when they change. Only a +# missing file or one carrying the marker is replaced. +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +write_shim() { + local dir="$HOME/.local/bin" name tmp STORE_REAL + [ "${GSTACK_BUN_SHIM:-1}" != 0 ] || return 0 + [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ] || return 0 + mkdir -p "$dir" "$STORE" || { say "could not create $dir or $STORE; no bun shim written"; return 0; } + STORE_REAL=$(cd -P "$STORE" && pwd -P) || return 0 + exec 8>"$HOME/.gstack-runtime.lock" + command -v flock >/dev/null 2>&1 && flock -w 60 8 + for name in bun bunx; do + if { [ -e "$dir/$name" ] || [ -L "$dir/$name" ]; } && ! { [ -f "$dir/$name" ] && [ ! -L "$dir/$name" ] && _gstack_bun_is_shim "$dir/$name"; }; then + say "$dir/$name is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix" + continue + fi + tmp="$dir/.$name.gstack.$$" + if [ "$name" = bunx ]; then + printf '#!/bin/sh\n%s\n# The Capy bunx shim: bun x through the bun shim beside it (docs/capy-install.md).\nexec %s x "$@"\n' "$GSTACK_BUN_SHIM_MARKER" "$(shq "$dir/bun")" > "$tmp" + else + { + printf '#!/bin/sh\n%s\n' "$GSTACK_BUN_SHIM_MARKER" + printf '# The Capy bun shim, written by %s.\n' "$(printf '%s' "$ROOT/bin/gstack-capy-install" | tr -d '\n')" + printf 'store=%s\n' "$(shq "$STORE_REAL")" + printf 'verdicts=%s\n' "$(shq "$(dirname "$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "")")")" + printf 'checkout=%s\n' "$(shq "$ROOT_REAL")" + printf 'default=%s\nminimum=%s\nfloor=%s\n' "$(shq "$INSTALL_VERSION")" "$(shq "$GSTACK_BUN_TESTED")" "$(shq "$GSTACK_BUN_FLOOR")" + printf 'marker=%s\n' "$(shq "$GSTACK_BUN_SHIM_MARKER")" + printf 'fix=%s\n' "$(shq "${GSTACK_CAPY_WRAPPER_PATH:+bash }${GSTACK_CAPY_WRAPPER_PATH:-$ROOT/bin/gstack-capy-install} --check")" + cat <<'EOF_SHIM' +# Runs the Bun recorded for the project around the working directory, or the +# 'none' record outside a project and inside the gstack checkout; else the +# resolver's order: the store copy of an exact project pin, the store default, +# $BUN_INSTALL/bin, ~/.bun/bin, then PATH, first at or above the supported +# minimum, then at or above the security floor (docs/capy-install.md). +# GSTACK_BUN_SHIM_EXPLAIN=1 prints the project root, record, choice and rule. +set -f +explain() { [ "${GSTACK_BUN_SHIM_EXPLAIN:-}" = 1 ] && echo "gstack bun shim: $*" >&2; return 0; } +sha() { if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -d' ' -f1; else printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1; fi; } +is_shim() { head -c 512 "$1" 2>/dev/null | grep -aqF "$marker"; } +ge() { # A B: version A is at or above B (a prerelease sorts below its release) + case "$1" in *-*) pre=1 ;; *) pre=0 ;; esac + gifs=$IFS; IFS=.; set -- ${1%%[-+]*} $2; IFS=$gifs + [ $# -eq 6 ] || return 1 + for n in "$@"; do case "$n" in ''|*[!0-9]*) return 1 ;; esac; done + [ "$1" -ne "$4" ] && { [ "$1" -gt "$4" ]; return; } + [ "$2" -ne "$5" ] && { [ "$2" -gt "$5" ]; return; } + [ "$3" -ne "$6" ] && { [ "$3" -gt "$6" ]; return; } + [ "$pre" = 0 ] +} +in_store() { # VERSION: the store's VERSION/bun when VERSION is x.y.z and the file is a real binary inside the store + printf '%s\n' "$1" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+' || return 1 + d=$(cd -P "$store/$1" 2>/dev/null && pwd -P) || return 1 + [ "$d" = "$store/$1" ] && [ -f "$d/bun" ] && [ ! -L "$d/bun" ] && [ -x "$d/bun" ] && ! is_shim "$d/bun" && printf '%s/bun' "$d" +} +root="" record="" chose="" rule="" pin="" +dir=$(pwd -P 2>/dev/null) || dir="" +while [ -n "$dir" ]; do + [ -e "$dir/.git" ] && { root=$dir; break; } + [ "$dir" = / ] && break + dir=${dir%/*}; [ -n "$dir" ] || dir=/ +done +key=${root:-none}; [ "$root" = "$checkout" ] && key=none +record="$verdicts/$(sha "$key").json" +if [ -f "$record" ]; then + b=$(sed -n 's/^ "bun_path": "\(.*\)",$/\1/p' "$record" | head -1) + [ -n "$b" ] && [ -f "$b" ] && [ -x "$b" ] && ! is_shim "$b" && chose=$b rule=record +else + record="" +fi +if [ -z "$chose" ] && [ "$key" != none ]; then + [ -f "$root/.bun-version" ] && pin=$(head -1 "$root/.bun-version" | tr -d ' v\r') + [ -n "$pin" ] || pin=$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"bun@\([^"]*\)".*/\1/p' "$root/package.json" 2>/dev/null | head -1) + [ -n "$pin" ] || pin=$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "$root/package.json" 2>/dev/null | head -1) + if [ -n "$pin" ]; then + if b=$(in_store "$pin"); then chose=$b rule=pin + else echo "gstack bun shim: $root pins bun $pin, which is not in gstack's runtime store; run $fix here" >&2 + fi + else + explain "$root has no recorded Bun and no exact pin; run $fix here to record one" + fi +fi +[ -n "$chose" ] || { b=$(in_store "$default") && chose=$b rule=default; } +if [ -z "$chose" ]; then + cands="BUN_INSTALL:${BUN_INSTALL:+$BUN_INSTALL/bin/bun} +home:${HOME:+$HOME/.bun/bin/bun}" + oifs=$IFS; IFS=: + for d in $PATH; do [ -n "$d" ] && cands="$cands +PATH:$d/bun"; done + IFS=$oifs + for want in "$minimum" "$floor"; do + nl=' +' + oifs=$IFS; IFS=$nl + for cand in $cands; do + r=${cand%%:*} f=${cand#*:} + [ -n "$f" ] && [ -f "$f" ] && [ -x "$f" ] && ! is_shim "$f" || continue + ge "$("$f" --version 2>/dev/null | head -1)" "$want" && { chose=$f rule=$r; break 2; } + done + IFS=$oifs + done + IFS=$oifs +fi +explain "project=${root:-none} record=${record:-none} chose=${chose:-none} rule=${rule:-none}" +[ -n "$chose" ] || { echo "gstack bun shim: no bun at or above $floor found (store $store, \$BUN_INSTALL/bin, ~/.bun/bin, PATH)" >&2; exit 127; } +exec "$chose" "$@" +EOF_SHIM + } > "$tmp" + fi + chmod 0755 "$tmp" && if cmp -s "$tmp" "$dir/$name"; then rm -f "$tmp"; else mv -f "$tmp" "$dir/$name" && say "wrote $dir/$name (gstack's per-directory $name shim; GSTACK_BUN_SHIM_EXPLAIN=1 shows its choice, GSTACK_BUN_SHIM=0 opts out)"; fi + done + exec 8>&- +} + +# 4. Certify. The trailer is the contract, in one grammar: +# gstack: ok <rev> for=<skill>[ (requested: …; pins: skipped (planning-only|no project); latest: <ver> available)] +# Notes, in that fixed order, ride only on an ok trailer: `requested` when the +# existing checkout is not at --revision (it is never moved here), `pins` when +# the doctor skipped the project pins, `latest` when the bootstrapper read a +# newer VERSION from origin in this run, exported as GSTACK_CAPY_REMOTE_VERSION. The +# failure head (`gstack: fail <ids>`) is unchanged. INSTALLED=$(head -1 "$ROOT/VERSION") -REV_NOTE="" +TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)" +NOTES="" +note() { NOTES="${NOTES:+$NOTES; }$1"; } if [ -n "$REVISION" ] && [ "$REVISION" != "$INSTALLED" ] && [ "$REVISION" != "v$INSTALLED" ] \ && [ "$(git -C "$ROOT" rev-parse --verify --quiet "$REVISION^{commit}" 2>/dev/null)" != "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null)" ]; then - REV_NOTE=" (requested $REVISION; run $ROOT/bin/gstack-upgrade to move)" + note "requested: $REVISION (run $ROOT/bin/gstack-upgrade to move)" fi -TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)$REV_NOTE" +if printf '%s\n' "$OUT" | grep -q '^SKIP pins (planning-only'; then note "pins: skipped (planning-only)" +elif printf '%s\n' "$OUT" | grep -Eq '^SKIP pins \((no project|project directory )'; then note "pins: skipped (no project)" +fi +REMOTE_VERSION="${GSTACK_CAPY_REMOTE_VERSION:-}" +if [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$INSTALLED" ] \ + && [ "$(printf '%s\n%s\n' "$INSTALLED" "$REMOTE_VERSION" | sort -V | head -1)" = "$INSTALLED" ]; then + note "latest: $REMOTE_VERSION available" +fi +case "$TRAILER" in "gstack: ok "*) [ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;; esac record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" +[ "$CHECK_ONLY" -eq 1 ] || write_shim printf '%s\n' "$OUT" | grep -v '^gstack: ' say "total: $(( $(now) - T0 ))s" echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT" diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index eba0e1a52..ac04de7ac 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -5,7 +5,10 @@ # and execs this script with its own arguments plus: # GSTACK_CAPY_WRAPPER_REV / GSTACK_CAPY_WRAPPER_PATH the bootstrapper that ran # GSTACK_CAPY_REMOTE_MIN the supported minimum from origin's capy/support.env, or `unknown` +# GSTACK_CAPY_REMOTE_VERSION origin's VERSION read in this run (wrapper 2+; the +# installer's `latest: <ver> available` trailer note) # GSTACK_CAPY_UPGRADED_FROM the version it fast-forwarded from (setup is never skipped then) +# GSTACK_CAPY_CLONED the repo it just cloned (wrapper 2+; the installer's `cloned` line) # GSTACK_CAPY_LOCK_HELD=1 install mode: .gstack-capy-install.lock is held on an # inherited descriptor; nothing here takes it again # Because this file moves with the checkout, session-start behavior can change diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index b028323b8..8c5a49cf7 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -36,7 +36,8 @@ _dc_has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; } # _dc_url CODE — the (CODE; docs anchor) suffix for a FAIL row. _dc_url() { printf '(%s; %s#%s)' "$1" "$DOCS" "$(gstack_result_anchor "$1")"; } -# _dc_row STATUS ID DETAIL [FIX] [CODE] — record and (in text mode) print one row. +# _dc_row STATUS ID DETAIL [FIX] [CODE] — record and (in text mode) print one +# row. WARN is an owner action that never fails the run. _dc_row() { local status="$1" id="$2" detail="$3" fix="${4:-}" code="${5:-}" _dc_rows="$_dc_rows$status $id $detail $fix $code @@ -46,6 +47,7 @@ _dc_row() { case "$status" in PASS) printf 'PASS %s%s\n' "$id" "${detail:+ $detail}" ;; SKIP) printf 'SKIP %s (%s)\n' "$id" "$detail" ;; + WARN) printf 'WARN %s — %s; owner action: %s %s\n' "$id" "$detail" "$fix" "$(_dc_url "$code")" ;; FAIL) printf 'FAIL %s — %s; fix: %s %s\n' "$id" "$detail" "$fix" "$(_dc_url "$code")" ;; esac } @@ -234,31 +236,50 @@ _dc_check_cso() { } # _dc_launch_note — when a supported Bun sits off PATH (gstack-capy-install -# leaves one in $BUN_INSTALL/bin), sets _dc_ln_dir/_dc_ln_ver/_dc_ln_note and -# _dc_ln_fix so a fresh shell's FAIL says where it is and how to launch with it. +# keeps verified ones in the runtime store), sets _dc_ln_dir/_dc_ln_ver/_dc_ln_note +# and _dc_ln_fix so a fresh shell's FAIL says where it is and how to launch with it. _dc_launch_note() { - _dc_ln_dir=$(gstack_bun_launch_dir 2>/dev/null) || _dc_ln_dir="" - _dc_ln_ver="" _dc_ln_note="" _dc_ln_fix="" - [ -n "$_dc_ln_dir" ] || return 1 - _dc_ln_ver=$("$_dc_ln_dir/bun" --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40) + _dc_ln_dir="" _dc_ln_ver="" _dc_ln_note="" _dc_ln_fix="" + gstack_bun_resolve "$(gstack_bun_store "$STATE_ROOT")" 2>/dev/null || return 1 + [ "$_gb_bin" != "$(gstack_realpath "$(command -v bun 2>/dev/null || echo /nonexistent)" 2>/dev/null)" ] || return 1 + _dc_ln_dir=$(dirname "$_gb_bin") _dc_ln_ver="$_gb_ver" _dc_ln_note="; verified bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH)" _dc_ln_fix="export PATH=\"$_dc_ln_dir:\$PATH\" or prefix commands with the GSTACK_LAUNCH line" } +# _dc_shim_note — the per-directory bun shim in ~/.local/bin: gstack's (the +# marker line) and where it resolves from here, or a foreign file left alone. +_dc_shim_note() { + local shim="$HOME/.local/bin/bun" target + [ -e "$shim" ] || [ -L "$shim" ] || return 0 + if _gstack_bun_is_shim "$shim"; then + target=$(GSTACK_BUN_SHIM_EXPLAIN=1 "$shim" --version 2>&1 >/dev/null </dev/null | sed -n 's/.* chose=\([^ ]*\).*/\1/p' | head -1) + printf '; shim %s -> %s' "$shim" "${target:-nothing (no eligible bun)}" + else + printf '; %s is not gstack'"'"'s shim (no marker), left untouched: launch with the GSTACK_LAUNCH line' "$shim" + fi +} + _dc_check_runtime() { - local where found fix="install Bun $GSTACK_BUN_TESTED with the checksum-verified recipe in $SRC/bin/gstack-capy-install (or: bun upgrade), then cd $SRC && ./setup" + local where found fix="install Bun $GSTACK_BUN_TESTED with the checksum-verified recipe in $SRC/bin/gstack-capy-install (or: bun upgrade), then cd $SRC && ./setup" extra _dc_launch_note && fix="$_dc_ln_fix" + if [ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ]; then + _dc_row FAIL runtime "bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT" "${GSTACK_DOCTOR_RUNTIME_DRIFT_FIX:-re-run the Capy skill installer}" BUN_RESOLUTION_DRIFT + return + fi if ! where=$(command -v bun 2>/dev/null); then _dc_row FAIL runtime "bun not found on PATH (gstack needs $GSTACK_BUN_TESTED; floor $GSTACK_BUN_FLOOR)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM return fi found=$(bun --version </dev/null 2>/dev/null | clamp 'A-Za-z0-9.+-' 40) _dc_bun="$found" + extra="$(_dc_shim_note)" + [ -n "${GSTACK_BUN_INSTALL_VERSION:-}" ] && extra="$extra; install version $GSTACK_BUN_INSTALL_VERSION from GSTACK_BUN_INSTALL_VERSION" case "$(gstack_bun_status "$found")" in - ok) _dc_row PASS runtime "bun $found at $where (floor $GSTACK_BUN_FLOOR, supported minimum $GSTACK_BUN_TESTED)" ;; - untested) _dc_row FAIL runtime "bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED (above the security floor $GSTACK_BUN_FLOOR)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;; - too-old) _dc_row FAIL runtime "bun $found at $where is below the security floor $GSTACK_BUN_FLOOR (supported minimum $GSTACK_BUN_TESTED)$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;; - *) _dc_row FAIL runtime "could not read the version of $where$_dc_ln_note" "$fix" RUNTIME_BELOW_MINIMUM ;; + ok) _dc_row PASS runtime "bun $found at $where (floor $GSTACK_BUN_FLOOR, supported minimum $GSTACK_BUN_TESTED)$extra" ;; + untested) _dc_row FAIL runtime "bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED (above the security floor $GSTACK_BUN_FLOOR)$_dc_ln_note$extra" "$fix" RUNTIME_BELOW_MINIMUM ;; + too-old) _dc_row FAIL runtime "bun $found at $where is below the security floor $GSTACK_BUN_FLOOR (supported minimum $GSTACK_BUN_TESTED)$_dc_ln_note$extra" "$fix" RUNTIME_BELOW_MINIMUM ;; + *) _dc_row FAIL runtime "could not read the version of $where$_dc_ln_note$extra" "$fix" RUNTIME_BELOW_MINIMUM ;; esac } @@ -401,6 +422,55 @@ _dc_check_revision() { _dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or run $SRC/bin/gstack-upgrade to move to the latest release (interactive: /gstack-upgrade)" REVISION_UNMET } +# _dc_wrapper_rev FILE — the GSTACK_CAPY_WRAPPER revision a Capy skill copy +# declares; 0 for a copy without one (the pre-bootstrapper wrapper). +_dc_wrapper_rev() { + local rev + rev=$(sed -n 's/^GSTACK_CAPY_WRAPPER=\([0-9][0-9]*\).*/\1/p' "$1" 2>/dev/null | head -1) + printf '%s' "${rev:-0}" +} + +# _dc_check_wrapper — the Capy skill copy. The active wrapper's provenance +# (GSTACK_CAPY_WRAPPER_REV/PATH, exported by the bootstrapper) decides: current +# passes; an active copy below the minimum, or a stale Drive copy elsewhere, is +# a WARN for the owner (an older copy still works, capy/support.env). Without +# provenance (a direct doctor run, or a wrapper too old to export it) the Drive +# copies decide: GSTACK_CAPY_WRAPPER_PATH when it names one, else every +# ~/.capy/drive/*/skills/gstack/scripts/install.sh, failing only when each is +# stale. The workaround runs the checkout's own copy, which certifies. +_dc_check_wrapper() { + local min copy rev stale="" current="" active="${GSTACK_CAPY_WRAPPER_PATH:-}" workaround owner + min=$(sed -n 's/^GSTACK_CAPY_MIN_WRAPPER=\([0-9][0-9]*\)[[:space:]]*$/\1/p' "$SRC/capy/support.env" 2>/dev/null | head -1); min="${min:-1}" + workaround="bash $SRC/capy/skill/gstack/scripts/install.sh --check${CHECK_FOR:+ --for $CHECK_FOR} (this session)" + owner="copy $SRC/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes" + for copy in "$HOME"/.capy/drive/*/skills/gstack/scripts/install.sh; do + [ -f "$copy" ] || continue + [ -n "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ "$copy" = "$active" ] && continue + [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -n "$active" ] && [ -f "$active" ] && [ "$copy" != "$active" ] && continue + rev=$(_dc_wrapper_rev "$copy") + if [ "$rev" -lt "$min" ] 2>/dev/null; then stale="${stale:+$stale, }$copy (wrapper $rev)"; else current="${current:+$current, }$copy (wrapper $rev)"; fi + done + if [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -n "$active" ] && [ -f "$active" ] && [ -z "$stale$current" ]; then + rev=$(_dc_wrapper_rev "$active") + if [ "$rev" -lt "$min" ] 2>/dev/null; then stale="$active (wrapper $rev)"; else current="$active (wrapper $rev)"; fi + fi + if [ -n "${GSTACK_CAPY_WRAPPER_REV:-}" ]; then + if [ "$GSTACK_CAPY_WRAPPER_REV" -lt "$min" ] 2>/dev/null; then + _dc_row WARN wrapper "the active Capy skill copy ${active:-<unknown>} is wrapper $GSTACK_CAPY_WRAPPER_REV, minimum $min (it still works)" "$owner" CAPY_WRAPPER_STALE + elif [ -n "$stale" ]; then + _dc_row WARN wrapper "active wrapper $GSTACK_CAPY_WRAPPER_REV (${active:-unknown path}) is current; stale Drive copies (minimum $min): $stale" "$owner" CAPY_WRAPPER_STALE + else + _dc_row PASS wrapper "$GSTACK_CAPY_WRAPPER_REV (${active:-unknown path}; minimum $min)" + fi + elif [ -n "$current" ]; then + _dc_row PASS wrapper "Drive copy $current (minimum $min)${stale:+; stale copies: $stale}" + elif [ -n "$stale" ]; then + _dc_row FAIL wrapper "every Capy skill copy is stale (minimum wrapper $min): $stale" "$workaround; owner: $owner" CAPY_WRAPPER_STALE + else + _dc_row SKIP wrapper "no Capy skill copy in ~/.capy/drive and no active wrapper (not a Capy machine)" + fi +} + _dc_print_json() { local status id detail fix code first=1 ok=false [ -z "$_dc_failed" ] && ok=true @@ -453,6 +523,7 @@ gstack_doctor_check() { privacy) _dc_check_privacy ;; cores) _dc_check_cores ;; revision) _dc_check_revision ;; + wrapper) _dc_check_wrapper ;; *) _dc_row FAIL "$id" "no check implemented for component '$id'" "report this gstack bug" COMPONENT_MISSING ;; esac done diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 6afe69f95..2399f6a84 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -4,8 +4,8 @@ # freshness test is test/doctor-components.test.ts. Sourced by # bin/gstack-doctor-check.sh; Bash 3.2 builtins only. -GSTACK_DOCTOR_COMPONENTS="claude codex patch codex-cli browse-bundle browser cso runtime pins state-root privacy cores revision" -GSTACK_DOCTOR_CORE="runtime pins state-root privacy cores revision" +GSTACK_DOCTOR_COMPONENTS="claude codex patch codex-cli browse-bundle browser cso runtime pins state-root privacy cores revision wrapper" +GSTACK_DOCTOR_CORE="runtime pins state-root privacy cores revision wrapper" GSTACK_DOCTOR_HOSTS="claude codex" GSTACK_DOCTOR_OPTIONAL="codex-cli cso" GSTACK_DOCTOR_SKILLS="autoplan plan-ceo-review plan-eng-review plan-design-review plan-devex-review plan-tune office-hours spec review ship investigate codex cso browse qa qa-only design-review design-consultation design-html devex-review scrape skillify canary benchmark make-pdf diagram pair-agent open-gstack-browser setup-browser-cookies connect-chrome land-and-deploy benchmark-models careful claude-code context-restore context-save design-shotgun deslop-shared-libs document-generate document-release eval-plan freeze gstack-upgrade guard health ios-clean ios-design-review ios-fix ios-qa ios-sync landing-report learn retro setup-deploy setup-gbrain sync-gbrain test-audit unfreeze" @@ -27,6 +27,7 @@ gstack_doctor_component_title() { privacy) printf '%s' 'privacy' ;; cores) printf '%s' 'cores' ;; revision) printf '%s' 'revision' ;; + wrapper) printf '%s' 'Capy skill copy (bootstrapper revision)' ;; *) return 1 ;; esac } @@ -47,6 +48,7 @@ gstack_doctor_component_kind() { privacy) printf '%s' 'core' ;; cores) printf '%s' 'core' ;; revision) printf '%s' 'core' ;; + wrapper) printf '%s' 'core' ;; *) return 1 ;; esac } @@ -160,9 +162,13 @@ gstack_result_anchor() { PREGATE_NO_LANE) printf '%s' 'pregate-no-lane' ;; PREGATE_LANE_FAILED) printf '%s' 'pregate-lane-failed' ;; PREGATE_INCOMPLETE) printf '%s' 'pregate-incomplete' ;; + PREGATE_STALLED) printf '%s' 'pregate-stalled' ;; + PREGATE_RECEIPT_INVALID) printf '%s' 'pregate-receipt-invalid' ;; PREGATE_STALE) printf '%s' 'pregate-stale' ;; PREGATE_REMOTE_UNCLEARED) printf '%s' 'pregate-remote-uncleared' ;; ISSUE_UNREADABLE) printf '%s' 'issue-unreadable' ;; + ISSUE_LOOKUP_FAILED) printf '%s' 'issue-lookup-failed' ;; + ISSUE_LINKS_MISSING) printf '%s' 'issue-links-missing' ;; PREREG_INCOMPLETE) printf '%s' 'prereg-incomplete' ;; EVAL_PRICE_MISSING) printf '%s' 'eval-price-missing' ;; EVAL_DRY_RUN_FAILED) printf '%s' 'eval-dry-run-failed' ;; @@ -185,6 +191,13 @@ gstack_result_anchor() { PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;; PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;; AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; + AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;; + GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;; + CHECKPOINT_INVALID) printf '%s' 'checkpoint-invalid' ;; + CHECKPOINT_OVERLAP) printf '%s' 'checkpoint-overlap' ;; + CHECKPOINT_TARGET_EXISTS) printf '%s' 'checkpoint-target-exists' ;; + OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;; + GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;; GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;; ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;; UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;; @@ -192,6 +205,8 @@ gstack_result_anchor() { UPGRADE_FETCH_FAILED) printf '%s' 'upgrade-fetch-failed' ;; REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;; CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;; + BUN_RESOLUTION_DRIFT) printf '%s' 'bun-resolution-drift' ;; + BUN_INSTALL_VERSION_INVALID) printf '%s' 'bun-install-version-invalid' ;; INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;; BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;; BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;; diff --git a/bin/gstack-gate b/bin/gstack-gate index 6e4e10007..59e7abcd0 100755 --- a/bin/gstack-gate +++ b/bin/gstack-gate @@ -2,7 +2,7 @@ /** * gstack-gate — render and parse the one gate list (plan B9). * - * gstack-gate render <gate.json> [--page N] + * gstack-gate render <gate.json> [--page N | --all] * gstack-gate parse <gate.json> --reply "<text>" --gate-rev N [--json] * gstack-gate decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO] * @@ -15,7 +15,8 @@ import { resultLine } from '../lib/result-codes'; const HELP = `gstack-gate — one gate list, one reply grammar - render <gate.json> [--page N] numbered items with stable ids, auto/approval, the reply shape + render <gate.json> [--page N | --all] numbered items with stable ids, auto/approval, the reply shape; + --all prints every item on one page (relay every page) parse <gate.json> --reply "<text>" --gate-rev N [--json] resolved choices as JSON; unparsed tokens are listed, never guessed decisions <gate.json> [--reply "<text>" --gate-rev N] [--answered-at ISO] @@ -67,14 +68,15 @@ function parseWith(list: GateList, rest: string[], json: boolean) { function main(argv: string[]): number { const [cmd, ...rest] = argv; if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } - const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && rest[i - 1] !== '--json')); + const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1]!.startsWith('--') && !['--json', '--all'].includes(rest[i - 1]!))); const list = loadList(positional[0]); if (typeof list === 'number') return list; if (cmd === 'render') { const page = flag(rest, '--page'); if (page !== undefined && !/^[0-9]+$/.test(page)) return usage('--page needs a number'); - process.stdout.write(renderGate(list, page ? Number(page) : 1)); + if (page !== undefined && rest.includes('--all')) return usage('--page and --all are exclusive'); + process.stdout.write(rest.includes('--all') ? renderGate(list, 1, list.items.length) : renderGate(list, page ? Number(page) : 1)); return EXIT.ok; } if (cmd === 'parse') { diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 239ddef9b..0de36bbea 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -4,19 +4,26 @@ # twin gstack-autoplan's mutating verbs call; test/install-verdict.test.ts # pins both. Bash 3.2 plus POSIX tools (sha256sum or shasum). # -# The verdict is what the last install check certified on this machine: -# <state root>/install-verdicts/<sha256 of the source checkout realpath>.json +# The verdict is what the last install check certified on this machine for +# one checkout and one project (the caller's git toplevel, or none): +# <state root>/install-verdicts/<sha256(checkout realpath)>/<sha256(project realpath | "none")>.json # written atomically by bin/gstack-capy-install (install and --check) with -# schema_version, checkout, head, for, required[], pins_planning_only, ok, -# trailer, min_revision, launch_path, time -# and named in the GSTACK_LAUNCH line as GSTACK_INSTALL_VERDICT=<path>. +# schema_version 2, checkout, head, for, required[], pins_planning_only, ok, +# trailer, min_revision, launch_path, time, project, pin (the exact Bun pin), +# bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install +# and named in the GSTACK_LAUNCH line as GSTACK_INSTALL_VERDICT=<path>. It is +# the one record the bun shim, lib/pregate/runtime.ts and admission read. +# Readers fall back to the schema 1 file keyed by the checkout alone, +# <state root>/install-verdicts/<sha256(checkout realpath)>.json. # # Admission (gstack-skill-start, after SESSION_KIND): an unattended session, # or an explicit GSTACK_SESSION_KIND=spawned session with GSTACK_CAPY=1, # refuses when the Bun first on PATH is below gstack's supported minimum and, # with GSTACK_CAPY=1 only, when the verdict is missing, failing, for another # checkout or HEAD, or narrower than the workflow (a component it needs, or a -# planning-only pins SKIP for a workflow that runs the repo's tests). Other +# planning-only pins SKIP for a workflow that runs the repo's tests), when a +# workflow that runs the repo's tests starts in a project the verdict did not +# check, and when the Bun running is not the one the verdict recorded. Other # spawned sessions (ambient OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 # admits anyway and says so. @@ -42,12 +49,25 @@ _gstack_iv_sha256() { else printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1; fi } -# gstack_install_verdict_path STATE_ROOT CHECKOUT_REALPATH -gstack_install_verdict_path() { printf '%s/install-verdicts/%s.json\n' "$1" "$(_gstack_iv_sha256 "$2")"; } +# gstack_install_verdict_path STATE_ROOT CHECKOUT_REALPATH [PROJECT_REALPATH] +gstack_install_verdict_path() { printf '%s/install-verdicts/%s/%s.json\n' "$1" "$(_gstack_iv_sha256 "$2")" "$(_gstack_iv_sha256 "${3:-none}")"; } +# gstack_install_verdict_legacy_path STATE_ROOT CHECKOUT_REALPATH — the schema 1 file. +gstack_install_verdict_legacy_path() { printf '%s/install-verdicts/%s.json\n' "$1" "$(_gstack_iv_sha256 "$2")"; } + +# gstack_install_project CHECKOUT_REALPATH — the project a verdict is keyed by +# for this working directory: its git toplevel (realpath), empty outside a +# repository or inside the checkout itself. +gstack_install_project() { + local top + top=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + top=$(cd "$top" 2>/dev/null && pwd -P) || return 0 + [ "$top" = "$1" ] || printf '%s\n' "$top" +} _gstack_iv_json() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\000-\037')"; } # gstack_install_verdict_write FILE CHECKOUT HEAD FOR REQUIRED(space list) PINS_PLANNING_ONLY(0|1) OK(0|1) TRAILER MIN LAUNCH_PATH +# PROJECT PIN BUN_PATH BUN_VERSION BUN_RULE BUN_INSTALL gstack_install_verdict_write() { local file="$1" tmp id req="" first=1 for id in $5; do @@ -58,9 +78,11 @@ gstack_install_verdict_write() { mkdir -p "$(dirname "$file")" 2>/dev/null || return 1 tmp="$file.tmp.$$" { - printf '{\n "schema_version": 1,\n "checkout": %s,\n "head": %s,\n "for": %s,\n' "$(_gstack_iv_json "$2")" "$(_gstack_iv_json "$3")" "$(_gstack_iv_json "$4")" + printf '{\n "schema_version": 2,\n "checkout": %s,\n "head": %s,\n "for": %s,\n' "$(_gstack_iv_json "$2")" "$(_gstack_iv_json "$3")" "$(_gstack_iv_json "$4")" printf ' "required": [%s],\n "pins_planning_only": %s,\n "ok": %s,\n' "$req" "$([ "$6" = 1 ] && echo true || echo false)" "$([ "$7" = 1 ] && echo true || echo false)" - printf ' "trailer": %s,\n "min_revision": %s,\n "launch_path": %s,\n "time": %s\n}\n' "$(_gstack_iv_json "$8")" "$(_gstack_iv_json "$9")" "$(_gstack_iv_json "${10}")" "$(_gstack_iv_json "$(date -u +%Y-%m-%dT%H:%M:%SZ)")" + printf ' "trailer": %s,\n "min_revision": %s,\n "launch_path": %s,\n' "$(_gstack_iv_json "$8")" "$(_gstack_iv_json "$9")" "$(_gstack_iv_json "${10}")" + printf ' "project": %s,\n "pin": %s,\n "bun_path": %s,\n "bun_version": %s,\n' "$(_gstack_iv_json "${11:-}")" "$(_gstack_iv_json "${12:-}")" "$(_gstack_iv_json "${13:-}")" "$(_gstack_iv_json "${14:-}")" + printf ' "bun_rule": %s,\n "bun_install": %s,\n "time": %s\n}\n' "$(_gstack_iv_json "${15:-}")" "$(_gstack_iv_json "${16:-}")" "$(_gstack_iv_json "$(date -u +%Y-%m-%dT%H:%M:%SZ)")" } > "$tmp" && mv -f "$tmp" "$file" } @@ -71,7 +93,7 @@ gstack_install_verdict_field() { flat=$(tr '\n' ' ' < "$1" 2>/dev/null) || return 1 case "$(printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\(.\).*/\1/p")" in '"') printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" ;; - '[') printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p" | tr -d '",' | tr -s ' ' | sed 's/^ //; s/ $//' ;; + '[') printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p" | tr ',' ' ' | tr -d '"' | tr -s ' ' | sed 's/^ //; s/ $//' ;; *) printf '%s' "$flat" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([A-Za-z0-9.]*\).*/\1/p" ;; esac } @@ -81,7 +103,7 @@ gstack_install_verdict_field() { # _ia_line (the INSTALL: or INSTALL_VERDICT: line to print, may be empty). # Needs gstack-bun-version.sh and gstack-doctor-components.sh sourced. gstack_install_admission() { - local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id + local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun _ia_applies=0 _ia_refuse=0 _ia_warn=0 _ia_status=ok _ia_line="" case "$kind" in unattended|spawned) _ia_applies=1 ;; *) return 0 ;; esac [ "${GSTACK_CAPY:-}" = 1 ] && capy=1 @@ -91,13 +113,15 @@ gstack_install_admission() { if [ "$(gstack_bun_status "${found:-0.0.0}")" != ok ]; then _ia_status=runtime code=RUNTIME_BELOW_MINIMUM if [ -n "$where" ]; then detail="bun $found at $where is below the supported minimum $GSTACK_BUN_TESTED"; else detail="bun not found on PATH (gstack needs $GSTACK_BUN_TESTED)"; fi - file=$(gstack_bun_launch_dir 2>/dev/null) || file="" + file=""; gstack_bun_resolve "$(gstack_bun_store "${_GH:-$HOME/.gstack}")" 2>/dev/null && file=$(dirname "$_gb_bin") if [ -n "$file" ]; then fix="launch with the GSTACK_LAUNCH line (PATH=$file:\$PATH)" else fix="run the Capy skill installer (scripts/install.sh --for $skill), which installs the verified Bun $GSTACK_BUN_TESTED, then launch with its GSTACK_LAUNCH line"; fi elif [ "$capy" -eq 1 ]; then src=$(gstack_source_root "$bin") || src="" - file="${GSTACK_INSTALL_VERDICT:-}" - [ -n "$file" ] || file=$(gstack_install_verdict_path "${_GH:-$HOME/.gstack}" "$src") + project=$(gstack_install_project "$src") + file=$(gstack_install_verdict_path "${_GH:-$HOME/.gstack}" "$src" "$project") + [ -f "$file" ] || [ -z "${GSTACK_INSTALL_VERDICT:-}" ] || file="$GSTACK_INSTALL_VERDICT" + [ -f "$file" ] || file=$(gstack_install_verdict_legacy_path "${_GH:-$HOME/.gstack}" "$src") code=INSTALL_UNVERIFIED fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints" if [ ! -f "$file" ]; then @@ -114,14 +138,28 @@ gstack_install_admission() { detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})" elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests" + elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \ + && v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then + detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests" + fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints" else for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac done fi + v_bun=$(gstack_install_verdict_field "$file" bun_path) + if [ -z "$detail" ] && [ -n "$v_bun" ]; then + running=$(gstack_realpath "$where" 2>/dev/null) + _gstack_bun_is_shim "$where" && running=$(bun -p process.execPath </dev/null 2>/dev/null | head -1) + if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then + _ia_status=runtime code=BUN_RESOLUTION_DRIFT + detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" + fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed" + fi + fi [ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; } fi - _ia_status=verdict + [ "$_ia_status" = runtime ] || _ia_status=verdict else return 0 fi diff --git a/bin/gstack-issue-links b/bin/gstack-issue-links index afbaa07ec..1cb8da4d5 100755 --- a/bin/gstack-issue-links +++ b/bin/gstack-issue-links @@ -2,38 +2,54 @@ /** * gstack-issue-links — issue links by intent for /ship Step 18 (plan C6). * - * gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--repo <owner/repo>] [--out <issues.json>] [--json] + * gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]... + * [--no-auto-links] [--author <login>] [--repo <owner/repo>] [--out <issues.json>] [--json] * * Reads `git log origin/<base>..HEAD` and classifies every issue mention: * `Fixes #N` only on explicit closure intent (a Fixes/Closes/Resolves trailer * or --issue), `Refs #N` for everything else (bare mentions, partial fixes, * deferred asks, cross-repo ids). Each closure issue is read through * gstack-issue-guard; unreadable ones are downgraded to Refs - * (ISSUE_UNREADABLE). Each --ci-run id is searched among open issues whose - * body names it (bot flake issues) and rendered `Flake: #N (run <id>)`. + * (ISSUE_UNREADABLE). Same-repo references are listed as promotion + * candidates (`fix #N` → --issue N). Each --ci-run id is searched among open + * issues whose body names it and kept only for bot flake issues of this repo + * naming that run and a test: `Flake: #N (run <id>)`. Open PRs by other + * authors closing a fixed issue are `Related: #N (@author)` unless + * --no-auto-links; --supersedes / --credit record the owner's confirmation. * Prints the `## Issues` lines; --json / --out emit the receipt's `issues` - * object for `gstack-ship-receipt write --issues @file`. Logic: lib/issue-links.ts. + * object for `gstack-ship-receipt write --issues @file`, which refuses a body + * missing any of its lines (ISSUE_LINKS_MISSING). + * Logic: lib/issue-links.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; -import { mentionsIn, receiptIssues, renderIssueLines, resolveLinks, type IssueMention } from '../lib/issue-links'; -import { resultLine } from '../lib/result-codes'; +import { lineSources, mentionsIn, promotionCandidates, receiptIssues, relatedPrs, renderIssueLines, resolveLinks, selectFlakes, type ClosingPr, type FlakeLink, type IssueMention, type IssueRow } from '../lib/issue-links'; +import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-issue-links — Fixes only on explicit closure intent; everything else is Refs (docs/pregate.md#issue-links) --base <ref> base branch (default: origin/HEAD, else main); commits origin/<base>..HEAD are read --issue <n> explicit closure intent (repeatable); still read through gstack-issue-guard - --ci-run <id> a failing CI run id (repeatable); open issues naming it become Flake: #N (run <id>) + --ci-run <id> a failing CI run id (repeatable); bot flake issues of this repo naming it and a test become Flake: #N (run <id>) + --supersedes <pr> owner confirmed this PR supersedes a Related PR: Supersedes #N (repeatable) + --credit <pr> owner confirmed crediting a Related PR's author: Contributed by @author (repeatable) + --no-auto-links no Related lookup (open PRs by other authors closing a fixed issue) + --author <login> this PR's author for that lookup (default: the gh viewer; for an app viewer, the HEAD commit's + GitHub user, else a user-owned repository's owner) --repo <owner/repo> repository for gh (default: the current remote) - --out <file> write the issues object (fixes, refs, flakes) for gstack-ship-receipt write --issues @file + --out <file> write the issues object (fixes, refs, flakes, related) for gstack-ship-receipt write --issues @file --json JSON only on stdout -Exit 0 with links or none; 1 when a closure issue was unreadable (its link is Refs); 2 usage. +Same-repo references print as promotion candidates; the owner's \`fix #N\` reply reruns with --issue N. +Exit 0 with links or none (a failed lookup prints ISSUE_LOOKUP_FAILED); 1 when a closure issue was unreadable (its link is Refs); 2 usage. ${exitTableHelp()}`; +const MAX_PAGES = 10; +const CLOSING_QUERY = 'query($owner:String!,$name:String!,$number:Int!,$after:String){viewer{login} repository(owner:$owner,name:$name){issue(number:$number){closedByPullRequestsReferences(first:50,after:$after,includeClosedPrs:false){nodes{number state headRefName author{login} repository{nameWithOwner}} pageInfo{hasNextPage endCursor}}}}}'; + function flags(args: string[], name: string): string[] { const out: string[] = []; for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1] !== undefined) out.push(args[++i]!); @@ -44,6 +60,70 @@ function git(args: string[], cwd: string): string | undefined { return r.status === 0 ? r.stdout : undefined; } +/** gh calls whose failures are reported once as ISSUE_LOOKUP_FAILED and collected for --json. */ +function ghLookups(cwd: string) { + const unavailable: string[] = []; + const failed = (what: string, why: string) => { + unavailable.push(`${what}: ${why}`); + process.stderr.write(resultLineWithDocs('gstack-issue-links', 'ISSUE_LOOKUP_FAILED', `${what}: ${why}`) + '\n'); + }; + function json<T>(what: string, args: string[]): T | undefined { + const r = spawnSync('gh', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) { failed(what, (r.stderr ?? '').trim().split('\n')[0] || `gh exited ${r.status}`); return undefined; } + try { return JSON.parse(r.stdout) as T; } catch { failed(what, 'gh returned no JSON'); return undefined; } + } + return { unavailable, failed, json }; +} + +type ClosingPage = { data?: { viewer?: { login?: string }; repository?: { issue?: { closedByPullRequestsReferences?: { nodes?: ClosingPr[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string } } } } } }; + +/** Every open PR closing each fixed issue, page by page; undefined when any page fails. */ +function closingPrs(gh: ReturnType<typeof ghLookups>, repo: string, fixes: number[]): { closing: Map<number, ClosingPr[]>; viewer: string } | undefined { + const [owner, name] = repo.split('/'); + const closing = new Map<number, ClosingPr[]>(); + let viewer = ''; + for (const issue of fixes) { + const what = `open PRs closing #${issue}`; + const prs: ClosingPr[] = []; + let after: string | undefined; + for (let page = 1; ; page++) { + if (page > MAX_PAGES) { gh.failed(what, `more than ${MAX_PAGES} pages`); return undefined; } + const res = gh.json<ClosingPage>(what, ['api', 'graphql', '-f', `query=${CLOSING_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${issue}`, ...(after ? ['-f', `after=${after}`] : [])]); + if (!res) return undefined; + const conn = res.data?.repository?.issue?.closedByPullRequestsReferences; + if (!conn) { gh.failed(what, 'the reply has no closedByPullRequestsReferences'); return undefined; } + viewer ||= res.data?.viewer?.login ?? ''; + prs.push(...(conn.nodes ?? [])); + if (!conn.pageInfo?.hasNextPage || !conn.pageInfo.endCursor) break; + after = conn.pageInfo.endCursor; + } + closing.set(issue, prs); + } + return { closing, viewer }; +} + +const AUTHOR_QUERY = 'query($owner:String!,$name:String!,$head:String!){repository(owner:$owner,name:$name){owner{__typename login} object(expression:$head){... on Commit{author{user{login}}}}}}'; +type AuthorReply = { data?: { repository?: { owner?: { __typename?: string; login?: string }; object?: { author?: { user?: { login?: string } | null } } | null } } }; + +/** + * Whose PRs are not "other authors": --author, else the gh viewer unless it is + * an app (`…[bot]`), in which case the HEAD commit's GitHub user, else the + * owner of a user-owned repository. With none, the app viewer stays and the + * gap is reported, since the owner's own PRs would then be listed as Related. + */ +function prAuthor(gh: ReturnType<typeof ghLookups>, repo: string, viewer: string, explicit: string | undefined, head: string | undefined): { login: string; source: string } { + if (explicit) return { login: explicit, source: '--author' }; + if (!/\[bot\]$/.test(viewer)) return { login: viewer, source: 'gh viewer' }; + const [owner, name] = repo.split('/'); + const res = head ? gh.json<AuthorReply>('this PR\'s author', ['api', 'graphql', '-f', `query=${AUTHOR_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-f', `head=${head}`]) : undefined; + const commitUser = res?.data?.repository?.object?.author?.user?.login; + if (commitUser) return { login: commitUser, source: 'HEAD commit author' }; + const repoOwner = res?.data?.repository?.owner; + if (repoOwner?.__typename === 'User' && repoOwner.login) return { login: repoOwner.login, source: 'repository owner' }; + if (res) gh.failed('this PR\'s author', `gh runs as ${viewer} and neither the HEAD commit nor the repository owner names a user; pass --author <login>`); + return { login: viewer, source: 'gh viewer' }; +} + function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const json = argv.includes('--json'); @@ -56,8 +136,9 @@ function main(argv: string[]): number { } const runs = flags(argv, '--ci-run'); if (runs.some(r => !/^[A-Za-z0-9_.-]{1,64}$/.test(r))) { process.stderr.write('gstack-issue-links: --ci-run ids are alphanumeric\n'); return EXIT.usage; } - const repoFlag = flags(argv, '--repo')[0]; - const repoArgs = repoFlag ? ['--repo', repoFlag] : []; + const confirmed = { supersedes: flags(argv, '--supersedes'), credit: flags(argv, '--credit') }; + if ([...confirmed.supersedes, ...confirmed.credit].some(r => !/^\d+$/.test(r))) { process.stderr.write('gstack-issue-links: --supersedes and --credit take a PR number\n'); return EXIT.usage; } + const autoLinks = !argv.includes('--no-auto-links'); const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const log = git(['log', '--format=%H%n%B%x00', `origin/${base}..HEAD`], repoRoot) ?? git(['log', '--format=%H%n%B%x00', `${base}..HEAD`], repoRoot); @@ -72,22 +153,48 @@ function main(argv: string[]): number { const r = spawnSync(path.join(binDir, 'gstack-issue-guard'), ['issue', String(issue)], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 && r.stdout.length > 0; }; - const flakes: Array<{ issue: number; run: string }> = []; - for (const run of runs) { - const r = spawnSync('gh', ['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${run}" in:body`, '--limit', '20', '--json', 'number'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }); - if (r.status !== 0) { process.stderr.write(`gstack-issue-links: flake search for run ${run} failed: ${(r.stderr ?? '').trim()}\n`); continue; } - try { for (const row of JSON.parse(r.stdout) as Array<{ number: number }>) flakes.push({ issue: row.number, run }); } - catch { process.stderr.write(`gstack-issue-links: flake search for run ${run} returned no JSON\n`); } + const gh = ghLookups(repoRoot); + const needRepo = runs.length > 0 || explicit.length > 0 || (autoLinks && mentions.some(m => m.intent === 'fixes')); + const repo = flags(argv, '--repo')[0] ?? (needRepo ? gh.json<{ nameWithOwner?: string }>('repository', ['repo', 'view', '--json', 'nameWithOwner'])?.nameWithOwner : undefined); + + const flakes: FlakeLink[] = []; + const rejectedFlakes: Array<{ issue: number; run: string; reason: string }> = []; + for (const run of repo ? runs : []) { + const rows = gh.json<IssueRow[]>(`flake issues naming run ${run}`, ['issue', 'list', '--repo', repo!, '--state', 'open', '--search', `"${run}" in:body`, '--limit', '100', '--json', 'number,title,body,author']); + if (!rows) continue; + const picked = selectFlakes(run, repo!, rows); + for (const f of picked.flakes) if (!flakes.some(x => x.issue === f.issue)) flakes.push(f); + rejectedFlakes.push(...picked.rejected.map(x => ({ ...x, run }))); } const links = resolveLinks(mentions, explicit, readable, flakes); + const found = autoLinks && repo && links.fixes.length ? closingPrs(gh, repo, links.fixes) : undefined; + const author = found ? prAuthor(gh, repo!, found.viewer, flags(argv, '--author')[0], git(['rev-parse', 'HEAD'], repoRoot)?.trim()) : undefined; + if (found && author) { + const related = relatedPrs(found.closing, { repo: repo!, viewer: author.login, branch: git(['branch', '--show-current'], repoRoot)?.trim() || undefined }); + if (related.length) links.related = related; + } + for (const [kind, list] of Object.entries(confirmed)) { + for (const raw of list) { + const row = links.related?.find(r => !r.repo && r.pr === Number(raw)); + if (!row) { process.stderr.write(`gstack-issue-links: --${kind} #${raw} is not a Related PR (${(links.related ?? []).map(r => `${r.repo ?? ''}#${r.pr}`).join(', ') || 'none found'})\n`); return EXIT.usage; } + if (kind === 'supersedes') row.supersedes = true; else row.credit = true; + } + } for (const n of links.unreadable) process.stderr.write(resultLine('gstack-issue-links', 'ISSUE_UNREADABLE', `#${n}`) + '\n'); + const candidates = promotionCandidates(mentions, links); + const lines = renderIssueLines(links); const out = flags(argv, '--out')[0]; if (out) { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(receiptIssues(links), null, 2) + '\n'); } - if (json) process.stdout.write(JSON.stringify({ ...receiptIssues(links), cross: links.cross, unreadable: links.unreadable, lines: renderIssueLines(links) }) + '\n'); + if (json) process.stdout.write(JSON.stringify({ ...receiptIssues(links), cross: links.cross, unreadable: links.unreadable, candidates, flake_tests: links.flakes, rejected_flakes: rejectedFlakes, ...(author ? { author } : {}), unavailable: gh.unavailable, lines }) + '\n'); else { - const lines = renderIssueLines(links); process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'ISSUE_LINKS: none\n'); - for (const m of mentions) process.stderr.write(` ${m.intent === 'fixes' ? 'closure' : 'reference'} ${m.repo ? `${m.repo}#` : '#'}${m.issue} ← ${m.source}\n`); + for (const m of mentions) { + const promote = candidates.some(c => c.issue === m.issue) && m.intent === 'refs' && !m.repo ? ` (promote: fix #${m.issue})` : ''; + process.stderr.write(` ${m.intent === 'fixes' ? 'closure' : 'reference'} ${m.repo ? `${m.repo}#` : '#'}${m.issue} ← ${m.source}${promote}\n`); + } + if (author && author.source !== 'gh viewer') process.stderr.write(` Related excludes @${author.login} (${author.source})\n`); + for (const line of lineSources(links)) process.stderr.write(` ${line}\n`); + for (const r of rejectedFlakes) process.stderr.write(` not a flake link: #${r.issue} (run ${r.run}) ← ${r.reason}\n`); } return links.unreadable.length ? EXIT.fail : EXIT.ok; } diff --git a/bin/gstack-plan-reality b/bin/gstack-plan-reality index a5a880a75..8ca759c60 100755 --- a/bin/gstack-plan-reality +++ b/bin/gstack-plan-reality @@ -3,7 +3,7 @@ * gstack-plan-reality — the reality rows of a plan review (plan D1). * * gstack-plan-reality rows --phase <ceo|eng|dx|design> [--scope <csv>] [--deep] [--json] - * gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--json] + * gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json] * * Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING). */ @@ -15,8 +15,9 @@ import { resultLine } from '../lib/result-codes'; const HELP = `gstack-plan-reality — reality rows for plan reviews rows --phase <p> [--scope <csv>] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope) - check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--json] + check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json] parse the review's REALITY: lines; exit 1 with the missing rows + --plan: a number inside a "quoted" span copied verbatim from it needs no receipt Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha). Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair. @@ -49,13 +50,16 @@ function main(argv: string[]): number { return EXIT.ok; } if (cmd === 'check') { - const valueFlags = new Set(['--phase', '--scope', '--session-kind']); + const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']); const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!))); if (!file) return usage('a review file (or - for stdin) is required'); let text: string; try { text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8'); } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${file}: ${e.message}\n`); return EXIT.fail; } const sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; - const result = checkRealityRows(text, { phase, scopes, sessionKind }); + const planFile = flag(rest, '--plan'); + let planText: string | undefined; + try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; } + const result = checkRealityRows(text, { phase, scopes, sessionKind, planText }); if (json) process.stdout.write(JSON.stringify(result) + '\n'); else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n'); for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`); diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 3f1272690..fed93616d 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -4,9 +4,9 @@ * * gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <ref>] * [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>] - * [--timeout <s>] [--run <id>] [--explain] [--json] + * [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json] * gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared - * gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> [--out <pregate.json>] + * gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> | --receipt <file> [--out <pregate.json>] * gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows * gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json * gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json] @@ -29,10 +29,13 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts'; import { PREGATE_CONFIG_PATH, exampleConfigJson, explainConfig, loadConfigFromHead } from '../lib/pregate/config'; import { buildContext } from '../lib/pregate/context'; -import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index'; +import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index'; import { describeLane, discoverLanes } from '../lib/pregate/workflows'; +import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes'; import { ensureTmpExcluded } from '../lib/restamp'; -import { resultLine, type ResultCodeName } from '../lib/result-codes'; +import { RESULT_CODES, resultCodeSuffix, resultLine, type ResultCodeName } from '../lib/result-codes'; +import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context'; +import { cancelActive } from '../lib/pregate/supervise'; import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate'; import { GATE_AHEAD_REL } from '../lib/restamp'; import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard'; @@ -43,7 +46,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do run (default) stage 1 mechanical preflight (${CHECKS.filter(c => c.stage === 'preflight').map(c => c.id).join(', ')}), then stage 2 test execution (${CHECKS.filter(c => c.stage === 'tests').map(c => c.id).join(', ')}) verify publication check against ${PREGATE_OUT_REL}: current tree, no fail or incomplete, every requires-remote obligation cleared - clear record the receipt that clears one requires-remote obligation + clear record the receipt that clears one requires-remote obligation: pregate.json/remote_gate needs + --receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree); a + test_backend/<backend> run URL is recorded as attested, not validated lanes print every CI lane with its platform tier (from .github/workflows/*.yml) validate | init the working-tree ${PREGATE_CONFIG_PATH} sealed [--range <a..b>] [--remote <name>] [--base <branch>] @@ -59,21 +64,34 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do --allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete --policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands) --out <file> pregate.json path (default ${PREGATE_OUT_REL}) - --timeout <s> per-check timeout (default 600) + --timeout <s> timeout for each runner process (default 600): the unit-file group, each serial file, each guard + without timeout_s; the stall timeout catches a silent one sooner + --stall-timeout <s> kill a test command's process group after this long with no output and record the check + incomplete (PREGATE_STALLED); default stall_timeout_s from the base .gstack/pregate.json, + else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables + --remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands and + --policy-from base); it clears the requires-remote obligation only on its + PREGATE_REMOTE: tree=<tested tree> tests=<n> failed=0 line with n > 0 + --spend-cap <usd> the cap --remote passes to the gate (default \$GSTACK_SPEND_CAP_USD); spend is reported as + spent, reserved and unknown --explain print each selected test's provenance and each uncertain mapping --json JSON only on stdout A failing check blocks (exit 1); incomplete never passes; requires-remote lanes are persisted -obligations publication needs receipts for. Ends with GSTACK_RESULT: skill=pregate status=… run=<pregate.json>. +obligations publication needs receipts for. Under 8 cores, a base-branch remote_gate or a remote +test_backend (gstack-config) records the local test tier as a requires-remote obligation with the +command to run instead of running it. Test progress goes to stderr live (PREGATE_TEST: per file +for bun test, PREGATE_HEARTBEAT: after each 60 s without progress, then PREGATE: tests <verdict>); +--json keeps stdout to the JSON document. Ends with GSTACK_RESULT: skill=pregate status=… run=<pregate.json>. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } /** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */ const VERB_FLAGS: Record<string, [string[], string[]]> = { - run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--run'], ['--allow-repo-commands', '--explain', '--json']], + run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']], verify: [['--out'], ['--json']], - clear: [['--remote', '--run-url', '--bundle-lane', '--out'], []], + clear: [['--remote', '--run-url', '--bundle-lane', '--receipt', '--out'], []], lanes: [[], ['--explain', '--json']], validate: [[], ['--json']], init: [[], ['--explain', '--force']], @@ -101,7 +119,7 @@ function repoRoot(): string | null { } function outPath(root: string, rest: string[]): string { const o = flag(rest, '--out'); return o ? path.resolve(o) : path.join(root, PREGATE_OUT_REL); } -function cmdRun(root: string, rest: string[], json: boolean): number { +async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> { const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all'; if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; } const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean); @@ -113,8 +131,18 @@ function cmdRun(root: string, rest: string[], json: boolean): number { if (policyFrom === 'head' && !allow) { process.stderr.write('gstack-pregate: --policy-from head needs --allow-repo-commands (the working-tree pin files are unreviewed)\n'); return EXIT.refused; } const timeoutMs = Number(flag(rest, '--timeout') ?? 600) * 1000; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; } + const stallFlag = flag(rest, '--stall-timeout'); + const stallTimeoutMs = stallFlag === undefined ? undefined : Number(stallFlag) * 1000; + if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; } + let remote: { spendCapUsd: number } | null = null; + if (rest.includes('--remote')) { + if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the base branch\'s remote_gate and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; } + const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN); + if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; } + remote = { spendCapUsd: cap }; + } else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; } const base = resolveBaseBranch(root, flag(rest, '--base')); - const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] }); + const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] }); if ('refused' in loaded) { const { code, message } = loaded.refused; process.stderr.write((code === 'POLICY_SOURCE_UNAVAILABLE' ? policyUnavailableLine('gstack-pregate', base, message).replace('.gstack/ship-policy.json', 'pin files') : resultLine('gstack-pregate', code as ResultCodeName, message)) + '\n'); @@ -123,7 +151,14 @@ function cmdRun(root: string, rest: string[], json: boolean): number { } const ctx = loaded.ctx; ensureTmpExcluded(root); - const file = runPregate(ctx, { stage, only, run: flag(rest, '--run') }); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + const killed = cancelActive(); + process.stderr.write(`gstack-pregate: cancelled by ${signal}; killed ${killed} test process group(s); no pregate.json written\n`); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } + const file = await runPregate(ctx, { stage, only, run: flag(rest, '--run') }); const out = outPath(root, rest); let written: string; try { written = writePregateFile(root, out, file); } @@ -134,7 +169,11 @@ function cmdRun(root: string, rest: string[], json: boolean): number { process.stdout.write(`pregate: policy ${ctx.configLabel}; registry ${ctx.registryLabel}; base ${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}; ${ctx.touched.length} touched file(s); platform ${ctx.platform}\n`); process.stdout.write(renderTable(file)); if (rest.includes('--explain')) { process.stdout.write('--- explain ---\n'); for (const line of ctx.explain) process.stdout.write(`${line}\n`); } - for (const c of file.checks) if (c.status === 'fail' || c.status === 'incomplete') process.stderr.write(`gstack-pregate: ${c.id}: ${c.detail}; fix: ${c.fix ?? 'see the table'} (${c.code ?? 'PREGATE_INCOMPLETE'})\n`); + for (const c of file.checks) { + if (c.status !== 'fail' && c.status !== 'incomplete') continue; + if (c.code === 'PREGATE_STALLED') process.stderr.write(`gstack-pregate: ${c.id} stalled — ${c.detail}; fix: ${c.fix} ${resultCodeSuffix(c.code)}\n`); + else process.stderr.write(`gstack-pregate: ${c.id}: ${c.detail}; fix: ${c.fix ?? 'see the table'} (${c.code ?? 'PREGATE_INCOMPLETE'})\n`); + } process.stdout.write(`pregate.json: ${written}\n`); process.stdout.write(formatResult('pregate', status, written) + '\n'); return blocking ? EXIT.fail : EXIT.ok; @@ -156,16 +195,19 @@ function cmdVerify(root: string, rest: string[], json: boolean): number { function cmdClear(root: string, rest: string[]): number { const lane = flag(rest, '--remote'); - const receipt = flag(rest, '--run-url') ?? (flag(rest, '--bundle-lane') ? `bundle-lane:${flag(rest, '--bundle-lane')}` : undefined); - if (!lane || !receipt) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> and --run-url <url> (or --bundle-lane <label>) are required\n`); return EXIT.usage; } + if (!lane) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> with --run-url <url>, --bundle-lane <label> or (for ${REMOTE_GATE_LANE}) --receipt <file> is required\n`); return EXIT.usage; } const out = outPath(root, rest); const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] }; if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; } - let file: PregateFile; - try { file = clearObligation(read.file, lane, receipt, `gstack-pregate clear ${new Date().toISOString()}`); } - catch (e: any) { process.stderr.write(`gstack-pregate clear: ${e.message}\n`); return EXIT.usage; } + const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') }); + if ('refused' in decision) { + if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; } + process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`); + return EXIT.fail; + } + const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`); writePregateFile(root, out, file); - process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${receipt} verdict=${file.verdict}\n`); + process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`); return EXIT.ok; } @@ -221,7 +263,7 @@ function cmdRegate(root: string, rest: string[], json: boolean): number { return report.verdict === 'not-needed' ? EXIT.ok : EXIT.fail; } -function main(argv: string[]): number { +async function main(argv: string[]): Promise<number> { const cmd = argv[0] && !argv[0].startsWith('--') ? argv[0] : argv.includes('--against') ? 'regate' : 'run'; const rest = argv[0] && !argv[0].startsWith('--') ? argv.slice(1) : argv; const json = rest.includes('--json'); @@ -230,7 +272,7 @@ function main(argv: string[]): number { if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; } const root = repoRoot(); if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; } - if (cmd === 'run') return cmdRun(root, rest, json); + if (cmd === 'run') return await cmdRun(root, rest, json); if (cmd === 'verify') return cmdVerify(root, rest, json); if (cmd === 'clear') return cmdClear(root, rest); if (cmd === 'sealed') return cmdSealed(root, rest, json); @@ -262,4 +304,4 @@ function main(argv: string[]): number { return EXIT.usage; } -process.exitCode = main(process.argv.slice(2)); +process.exitCode = await main(process.argv.slice(2)); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 237eb1c06..f8441f1a7 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -20,13 +20,13 @@ 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 } from '../lib/result-codes'; +import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; import { resolveBaseBranch } from '../lib/ship-policy'; import { - computeCensus, extractReceipt, orderReceipt, placeReceiptFirst, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, - type CensusPr, + computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, + validateReceipt, type CensusPr, } from '../lib/ship-receipt'; const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) @@ -41,7 +41,7 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso --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 --json) --out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the + (C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line) --out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the version source at HEAD, session_kind=$GSTACK_SESSION_KIND (gstack-session-kind), tree/gate_reuse/gated_tree/predecessor from --receipt when given. --json: JSON only on stdout. @@ -94,6 +94,7 @@ function cmdWrite(rest: string[], json: boolean): number { } let input: Partial<ShipReceipt>; try { + const issuesFile = jsonArg(flag(rest, '--issues')); input = { schema_version: 1, pr: flag(rest, '--pr') !== undefined ? Number(flag(rest, '--pr')) : undefined, head, version: flag(rest, '--version') ?? ('version' in atHead ? atHead.version : undefined), base: flag(rest, '--base') ?? resolveBaseBranch(repoRoot), @@ -103,7 +104,7 @@ function cmdWrite(rest: string[], json: boolean): number { 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: jsonArg(flag(rest, '--issues')) as ShipReceipt['issues'], + ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, }; } 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; } @@ -114,10 +115,15 @@ function cmdWrite(rest: string[], json: boolean): number { process.stderr.write('gstack-ship-receipt write: nothing written\n'); return EXIT.fail; } + const body = flag(rest, '--body'); + 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); - const body = flag(rest, '--body'); 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); diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 1cc48cc20..d54425039 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -24,18 +24,31 @@ # lease on this installation (bin/gstack-lease live) it refuses with # UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints # `gstack-upgrade: upgraded <old> -> <new>` before reinstalling; a failed -# reinstall exits 1 and the next run completes it. +# reinstall exits 1 and the next run completes it (a pending marker in the git +# dir, kept only for setup or install failures). +# +# Every success path (already current, fast-forwarded, a completed pending +# reinstall) then certifies the caller's project: the git toplevel of the +# directory gstack-upgrade was started in, captured before any cd. On Capy +# that is bin/gstack-capy-install --project <it> (it installs the project's +# pinned Bun when that pin is a verified version); elsewhere gstack-doctor +# --check --require runtime,pins. A failure prints the standard trailer +# (`gstack: fail pins`) and exits 1; the checkout move is kept and no pending +# marker is left. Outside a project, or with --check, nothing is certified. # Bash 3.2 plus git; the fetch is bounded by `timeout` when present. set -u BASH_COMPAT=50 ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +CALLER=$(pwd -P) +CALLER_PROJECT=$(git rev-parse --show-toplevel 2>/dev/null) && CALLER_PROJECT=$(cd "$CALLER_PROJECT" && pwd -P) || CALLER_PROJECT="" +[ "$CALLER_PROJECT" = "$ROOT" ] && CALLER_PROJECT="" CHECK=0 REINSTALL=1 case "${1:-}" in --check) CHECK=1 ;; --ff-only-no-reinstall) REINSTALL=0 ;; '') ;; - -h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;; esac @@ -62,16 +75,71 @@ _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$ INCOMING=$(git -C "$ROOT" rev-parse FETCH_HEAD) REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending" -reinstall() { # FROM — run the install step; a pending marker survives a failure - printf '%s\n' "$1" > "$PENDING" - cd "$ROOT" || exit 1 - if [ -f "$(git rev-parse --absolute-git-dir 2>/dev/null)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ]; then - GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" </dev/null - else - ./setup --refresh-registered </dev/null +CAPY=0 +[ -f "$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ] && CAPY=1 +# Run outside a Capy session, the installer this script starts is the +# checkout's own; say so, so the doctor's wrapper row judges that copy. +if [ "$CAPY" -eq 1 ] && [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -f "$ROOT/capy/skill/gstack/scripts/install.sh" ]; then + GSTACK_CAPY_WRAPPER_REV=$(sed -n 's/^GSTACK_CAPY_WRAPPER=\([0-9][0-9]*\).*/\1/p' "$ROOT/capy/skill/gstack/scripts/install.sh" | head -1) + [ -n "$GSTACK_CAPY_WRAPPER_REV" ] && export GSTACK_CAPY_WRAPPER_REV GSTACK_CAPY_WRAPPER_PATH="$ROOT/capy/skill/gstack/scripts/install.sh" +fi +# stop_trailer ERRFILE — an installer stop line (exit 3, before ./setup) as a +# trailer: a RUNTIME_PIN_CONFLICT is `gstack: fail pins`, any other stop +# `gstack: fail runtime`. +stop_trailer() { + _stop=$(grep -E '^gstack-capy-install: .*\([A-Z][A-Z_]*;' "$1" | tail -1) + case "$_stop" in + '') ;; + *'(RUNTIME_PIN_CONFLICT;'*) echo "gstack: fail pins — ${_stop#gstack-capy-install: }" ;; + *) echo "gstack: fail runtime — ${_stop#gstack-capy-install: }" ;; + esac +} +# certify — the caller's project (see the header); exits with its outcome. +certify() { + [ -n "$CALLER_PROJECT" ] && [ "$REINSTALL" -eq 1 ] || exit 0 + if [ "$CAPY" -eq 1 ]; then + _err=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") + (cd "$CALLER" && "$ROOT/bin/gstack-capy-install" --project "$CALLER_PROJECT" </dev/null 2>"$_err") + _rc=$? + cat "$_err" >&2 + [ "$_rc" -eq 0 ] && { rm -f "$_err"; exit 0; } + [ "$_rc" -eq 3 ] && stop_trailer "$_err" + rm -f "$_err" + exit 1 fi - _rc=$? - [ "$_rc" -eq 0 ] && { rm -f "$PENDING"; exit 0; } + (cd "$CALLER" && "$ROOT/bin/gstack-doctor" --check --require runtime,pins --project "$CALLER_PROJECT" </dev/null) && exit 0 + exit 1 +} +reinstall() { # FROM — run the install step; a pending marker survives a failure. + # With a project to certify next, the installer's own trailer is held back + # (printed only when the reinstall fails), so no ok precedes a pins failure. + printf '%s\n' "$1" > "$PENDING" + _held=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") _ierr=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") + if [ "$CAPY" -eq 1 ]; then + (cd "$ROOT" && GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" </dev/null 2>"$_ierr") | while IFS= read -r _line; do + case "$_line" in "gstack: "*) printf '%s\n' "$_line" > "$_held"; continue ;; esac + printf '%s\n' "$_line" + done + _rc=${PIPESTATUS[0]} + cat "$_ierr" >&2 + else + (cd "$ROOT" && ./setup --refresh-registered </dev/null) + _rc=$? + fi + # The wrapper row is an owner action on the Drive copy, never a reinstall + # failure: no pending marker for it. + if [ "$_rc" -ne 0 ] && [ "$(cat "$_held")" = "gstack: fail wrapper" ]; then + rm -f "$PENDING" + echo "gstack-upgrade: reinstalled $(head -1 "$ROOT/VERSION"); only the wrapper row failed (an owner action on the Drive copy, CAPY_WRAPPER_STALE), so no reinstall is pending" + fi + if [ "$_rc" -eq 0 ]; then + [ -n "$CALLER_PROJECT" ] || cat "$_held" + rm -f "$PENDING" "$_held" "$_ierr"; certify + fi + [ -s "$_held" ] && cat "$_held" + [ "$_rc" -eq 3 ] && [ ! -s "$_held" ] && stop_trailer "$_ierr" + rm -f "$_held" "$_ierr" + [ -f "$PENDING" ] || exit 1 echo "gstack-upgrade: the checkout is at $(head -1 "$ROOT/VERSION") but its reinstall failed (exit $_rc); fix: re-run $ROOT/bin/gstack-upgrade (it completes the pending reinstall)" >&2 exit 1 } @@ -82,7 +150,8 @@ if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge- reinstall "$(head -1 "$PENDING")" fi echo "gstack-upgrade: current $INSTALLED" - exit 0 + [ "$CHECK" -eq 1 ] && exit 0 + certify fi if [ "$CHECK" -eq 1 ]; then echo "gstack-upgrade: behind $INSTALLED -> ${REMOTE_VER:-unknown} ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits)" diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 3be1861e6..746664a77 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -9,9 +9,9 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). -1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP <component>` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. +1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` is an owner action), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. 2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or the output carries a `gstack-capy-session: behind origin/<branch>: …` line, run the same command without `--check` (900-second timeout). For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped (<reason>)` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. -3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, the SHA-256-verified Bun 1.4.2, both host profiles without Chromium). +3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures. **The trailer rule.** No `^gstack: ok` line from this machine in this session means gstack is not installed here: do not run a workflow, and do not claim the skills work. One machine's install never covers another. @@ -38,6 +38,8 @@ If no specific skill was requested, use the router's task-to-skill mapping. Read For `/autoplan` driven from Capy, read `autoplan/DRIVER.md` in the checkout (`~/.local/share/gstack/autoplan/DRIVER.md`): the compact phase loop, artifact contract and gate protocol, without the interactive preamble. A checkout without that file predates it; follow its `autoplan/SKILL.md` instead. Run every command with the `GSTACK_LAUNCH` assignments and read outputs as files, not prose: the run directory holds `run.json`, `decisions.jsonl`, `findings.jsonl`, `tasks.jsonl`, `timing.json`, `plan.md` and `review-record.md`, and the run ends with `GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>`. `~/.local/share/gstack/bin/gstack-artifact validate <dir>/run.json` is the completion check. The gate is answered through `gstack-autoplan answer` (or `gstack-gate parse`) with the printed `gate_rev`; an unattended run never approves its own gate. `docs/unattended.md` in the checkout is the contract. +Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render <dir>/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent:<id> --owner-reply "<their words>"`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out <new dir> --from <that dir>` restores the newest valid checkpoint. + ## Before any push or pull request In a repository with a sealed configuration (a `sealed` key in its `.gstack/pregate.json`), run `~/.local/share/gstack/bin/gstack-pregate sealed` before every push and before creating a pull request: Capy's pull-request tool may not run local git hooks, so this is the check that keeps sealed or eval output out of a public repository. A refusal names the file, line and rule; do not push until it passes. diff --git a/capy/skill/gstack/scripts/install.sh b/capy/skill/gstack/scripts/install.sh index b420e7c31..4d5b021f8 100755 --- a/capy/skill/gstack/scripts/install.sh +++ b/capy/skill/gstack/scripts/install.sh @@ -8,7 +8,10 @@ # 3. in install mode, fast-forward a clean, unpinned checkout below that # minimum, under .gstack-capy-install.lock (--check never moves it); # 4. exec the checkout's full wrapper, bin/gstack-capy-session, with the same -# arguments, handing the held lock over (GSTACK_CAPY_LOCK_HELD=1). +# arguments, handing the held lock over (GSTACK_CAPY_LOCK_HELD=1) and, +# from revision 2, origin's VERSION read in this run +# (GSTACK_CAPY_REMOTE_VERSION, the trailer's `latest:` note) and the repo +# it just cloned (GSTACK_CAPY_CLONED, the installer's `cloned` line). # Everything else (Bun, setup, the doctor, the verdict, the launch line) is # the checkout's. The last line is the `gstack:` trailer; exit 0 only on # `gstack: ok`. Contract: docs/capy-install.md; tests: @@ -20,14 +23,15 @@ # Environment: GSTACK_CAPY_ROOT (default ~/.local/share/gstack), GSTACK_CAPY_REPO, # GSTACK_CAPY_AUTO_UPGRADE=0 (never fast-forward), GSTACK_CAPY_FETCH_TIMEOUT (s). set -u -GSTACK_CAPY_WRAPPER=1 +GSTACK_CAPY_WRAPPER=2 self="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" root="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}" repo="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}" echo "gstack-capy-skill: wrapper $GSTACK_CAPY_WRAPPER ($self)" -check=0 skill="" revision="" min_note="" remote_min="" +check=0 skill="" revision="" min_note="" remote_min="" remote_version="" +unset GSTACK_CAPY_CLONED args=() while [ $# -gt 0 ]; do case "$1" in @@ -80,6 +84,8 @@ if [ ! -f "$root/setup" ]; then || { echo "gstack: fail installer — could not clone $repo; fix: check network access, then $fix_cmd"; exit 1; } fi remote_min=$(support_get "$(cat "$root/capy/support.env" 2>/dev/null)" GSTACK_MIN_REVISION) + remote_version=$(head -1 "$root/VERSION" 2>/dev/null) + export GSTACK_CAPY_CLONED="$repo" else # The remote's rules: fetch the default branch into FETCH_HEAD (never --depth # into an existing checkout), read capy/support.env from it. @@ -88,6 +94,7 @@ else if [ -n "$default" ] && bounded git -C "$root" fetch --quiet origin "$default" 2>/dev/null; then support=$(git -C "$root" show FETCH_HEAD:capy/support.env 2>/dev/null) remote_min=$(support_get "$support" GSTACK_MIN_REVISION) + remote_version=$(git -C "$root" show FETCH_HEAD:VERSION 2>/dev/null | head -1) else min_note="remote minimum unknown (fetch failed)" echo "gstack-capy-skill: $min_note; checking the local install only" @@ -127,6 +134,7 @@ if [ ! -x "$session" ]; then exit 1 fi export GSTACK_CAPY_WRAPPER_REV="$GSTACK_CAPY_WRAPPER" GSTACK_CAPY_WRAPPER_PATH="$self" GSTACK_CAPY_REMOTE_MIN="${remote_min:-unknown}" +if [ -n "$remote_version" ]; then export GSTACK_CAPY_REMOTE_VERSION="$remote_version"; else unset GSTACK_CAPY_REMOTE_VERSION; fi [ "$check" -eq 0 ] && export GSTACK_CAPY_LOCK_HELD=1 if [ "$check" -eq 1 ]; then exec "$session" --check --root "$root" ${args[@]+"${args[@]}"} diff --git a/docs/capy-install.md b/docs/capy-install.md index 61258437c..c37b1ae61 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -82,41 +82,107 @@ whose pid is alive and outside its own process chain exists; 1. Reuses the checkout it lives in (or clones into `--root <dir>`), under the lock. It never resets or pulls; `--revision <ref>` pins a fresh clone, and an existing checkout at another revision is reused with the trailer note - `(requested <ref>; run <checkout>/bin/gstack-upgrade to move)`. -2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. A supported Bun already in - `$BUN_INSTALL/bin` (default `~/.bun/bin`) is reused. The project's pins + `requested: <ref> (run <checkout>/bin/gstack-upgrade to move)`. A fresh + clone prints `cloned <repo> at <sha12> (<version>)` (the bootstrapper's + clone too, through `GSTACK_CAPY_CLONED`). +2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. The project's pins (`--project <path>`, default the git toplevel of the current directory) are read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes - for this platform). When neither the Bun on PATH nor the installed one - satisfies gstack's minimum and the pins, the installer downloads one exact + for this platform). CI exact pins for this platform are alternatives: a + repository may test several Bun versions (gstack's own CI pins 1.4.2 and + runs one job on 1.4.3), so a Bun equal to any one of them satisfies CI; + CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in + `bin/gstack-bun-version.sh`; `resolveBun` in `lib/pregate/runtime.ts` is + its TypeScript twin), and the installer, `--check`, the doctor, the bun + shim and install admission all use it. Candidates, first eligible wins: + the store copy of the project's exact pin (rule `pin`), the store copy of + the install default (`default`), `$BUN_INSTALL/bin/bun`, `~/.bun/bin/bun` + (`home`), then every `bun` on PATH. Eligible means it runs, reports a + version at or above the supported minimum (1.4.2) and satisfies the + project's pins. When nothing qualifies, the installer downloads one exact release archive, verifies its SHA-256 against `bin/gstack-bun-version.sh` - and installs it into `$BUN_INSTALL/bin`, beside any different system Bun (no - symlink into a shared bin directory). That archive is the supported minimum - (1.4.2), or the project's exact pin when it is above the minimum and gstack - carries a verified archive for it (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3), so a - repo whose CI pins `bun-version: 1.4.3` gets 1.4.3. With an exact pin, only - that version satisfies it. Any other exact pin (below the minimum, two - different ones, or one without a verified archive), or a range gstack's - minimum does not satisfy, stops with both + and unpacks it into the versioned store + `<state root>/runtimes/bun/<version>/bun`, under `~/.gstack-runtime.lock`. + Each version directory also holds `bunx -> bun`. A broken entry is + repaired without a window (its files are renamed over their predecessors), + and `~/.bun/bin` is never written. The archive is the project's non-CI + exact pin when gstack carries a verified archive for it + (`GSTACK_BUN_VERIFIED`: 1.4.2, 1.4.3); with only CI exact pins, the install + default when it is one of them, else the highest verified one; else the + install default `GSTACK_BUN_DEFAULT` (1.4.3, above the 1.4.2 minimum; one + CI job runs the quick free tier on it). + `GSTACK_BUN_INSTALL_VERSION=<v>` overrides the default; a version without a + verified archive or below the minimum stops the install + ([BUN_INSTALL_VERSION_INVALID](troubleshooting.md#bun-install-version-invalid)). + With a non-CI exact pin, only that version satisfies it. Any other exact + pin (below the minimum, two different non-CI ones, or one without a verified + archive), or a range gstack's minimum does not satisfy, stops with both constraints ([RUNTIME_PIN_CONFLICT](troubleshooting.md#capy-install-runtime-conflict), exit 3) before `./setup`. With no Bun at or above the security floor, the - verified one is installed first so the pins can be read. + default is installed first so the pins can be read. 3. `./setup --host claude --no-prefix`, then `./setup --host codex --model gpt-6-astra`, both with `--no-browser`. Skipped when this exact HEAD was set up with the same hosts, model and browser flag (recorded in the git dir after a successful setup) and `gstack-doctor --check` passes; a warm rerun - takes about a second. + takes about a second. Each setup ends with its own `gstack ready (<host>)`; + the installer then prints `gstack ready (claude, codex)`. 4. `gstack-doctor --check` for the requested workflow (`--for <skill>`, `--only <skill>`, or `--require <id,id>`; default `claude,codex,patch`), run with the launch PATH, so the trailer certifies the Bun workflows run. -5. The install verdict, written atomically to - `<state root>/install-verdicts/<sha256 of the checkout realpath>.json`: - checkout, HEAD, the workflow and required components it certified, whether - pins were skipped as planning-only, the trailer, the remote minimum, the - launch PATH and the time. +5. The install verdict (schema 2), written atomically to + `<state root>/install-verdicts/<sha256 of the checkout realpath>/<sha256 of the project realpath, or "none">.json`: + checkout, project, HEAD, the workflow and required components it + certified, whether pins were skipped as planning-only, the pin it + honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and + `bun_install` when this run downloaded it), the trailer, the remote + minimum, the launch PATH and the time. Admission refuses a workflow whose + running Bun is not the one the verdict recorded + ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so + do the pregate's lanes and guards, which also refuse an invalid verdict + ([INSTALL_UNVERIFIED](troubleshooting.md#install-unverified)) under + `GSTACK_CAPY=1` or whenever a verdict exists. `--check` keeps the recorded + Bun while it exists, reports the recorded version and satisfies the pins; + a candidate that would now resolve first is a `note:` line, and only a + recorded Bun that is gone, changed or no longer satisfies the pins fails + the doctor's `runtime` row. A schema 1 verdict (keyed by checkout only) is + still read. +6. On a Capy machine (`CAPY_MACHINE_CONFIG` set or `~/.capy/system` present), + install mode writes the bun shims described below. -`--check` runs only steps 4 and 5 (no clone, Bun install or setup). +`--check` runs only steps 4 and 5 (no clone, Bun install, setup or shim). + +## The bun shim + +A fresh shell on Capy finds the image's `/usr/local/bin/bun` (1.3.14) first, +so a `bun test` typed without the `GSTACK_LAUNCH` prefix used to run the wrong +Bun. Install mode therefore writes `~/.local/bin/bun` and `~/.local/bin/bunx` +(`~/.local/bin` precedes `/usr/local/bin` on the Capy image's PATH). Each is a +small POSIX script whose second line is the marker +`# gstack-bun-shim: gstack-owned; delete this file to remove it`. At every +call the `bun` shim finds the git toplevel around the working directory and +runs, in order: the Bun that project's install verdict recorded (the `none` +verdict outside any project and inside the gstack checkout), the store copy +of the project's exact pin, the store copy of the install default, then +`$BUN_INSTALL/bin/bun`, `~/.bun/bin/bun` and every non-shim `bun` on PATH, +first at or above the supported minimum, then at or above the security +floor. A pin is used only when it is `x.y.z` and its store entry is a real +binary inside the store (no `..`, symlink escape or shim-marked file). A +project pinning a Bun the store lacks prints `gstack bun shim: <root> pins +bun <v>, which is not in gstack's runtime store; run ... --check here`; +an unrecorded, unpinned repo falls through quietly. `bunx` runs `bun x` +through the `bun` shim beside it. + +- `GSTACK_BUN_SHIM_EXPLAIN=1 bun --version` prints + `gstack bun shim: project=<root> record=<file> chose=<bun> rule=<rule>` + (and names a missing record). +- `GSTACK_BUN_SHIM=0` at install time writes no shim. +- Removing it: delete `~/.local/bin/bun` and `~/.local/bin/bunx`. gstack + replaces only a missing file or a file carrying the marker; a foreign file + or a dangling link there is left untouched with a note, and the + `GSTACK_LAUNCH` prefix stays the way to reach the certified Bun. +- The doctor's `runtime` row notes the shim; the resolver never treats a + shim-marked file as a Bun candidate. ## The one line to grep @@ -124,9 +190,19 @@ The last line is the trailer: ``` gstack: ok 1.91.96.0 for=autoplan +gstack: ok 1.91.96.0 for=review (requested: v1.91.90.0 (run <checkout>/bin/gstack-upgrade to move); latest: 1.91.101.0 available) +gstack: ok 1.91.96.0 for=autoplan (pins: skipped (planning-only)) gstack: fail runtime,pins ``` +An ok trailer carries at most one parenthesised note group, `; `-separated, +in this order: `requested: …` (an existing checkout at another revision than +`--revision`), `pins: skipped (planning-only|no project)`, and `latest: <ver> +available` (origin's default branch has a newer VERSION; the bootstrapper +exports it as `GSTACK_CAPY_REMOTE_VERSION` only after a fetch that succeeded +in this run). A fail trailer never carries notes. Match `^gstack: ok ` or +`^gstack: fail `; never parse past the first space after the version. + Exit 0 only with `gstack: ok`. Read the exit status and the trailer directly: ```bash @@ -144,7 +220,8 @@ A parent agent confirms each subagent machine with this grep; one machine's install never covers another, and a subagent's first report carries its own `gstack:` line. -`SKIP pins (planning-only; repo pins differ: ..., launch bun <v>)` is not a +`SKIP pins (planning-only; repo pins differ: ..., launch bun <v>)` (trailer +note `pins: skipped (planning-only)`) is not a failure: planning workflows (`--for autoplan`, the plan reviews, `office-hours`) never run the target repo's tests. Workflows that do (`review`, `ship`, `qa`) and `--require` keep `pins` required. @@ -231,8 +308,13 @@ that is behind origin is fast-forwarded by the checkout's own wrapper, so the floor is not what keeps machines current). It never exceeds VERSION (test/capy-skill-wrapper.test.ts). Raise `GSTACK_CAPY_MIN_WRAPPER` together with `GSTACK_CAPY_WRAPPER` in the bootstrapper when the bootstrapper itself -changes; older Drive copies keep working and print an owner action -([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)). +changes in a way older copies cannot work with; older Drive copies keep +working and print an owner action +([CAPY_WRAPPER_STALE](troubleshooting.md#capy-wrapper-stale)), and the +doctor's `wrapper` row warns (an active copy below the minimum is a WARN, +never a FAIL; `bin/gstack-upgrade` never leaves a pending reinstall for it). +Wrapper 2 only adds optional exports (the trailer's `latest:` note and the +`cloned` line), so the minimum stays 1. ## Exit codes diff --git a/docs/pregate.md b/docs/pregate.md index 7d96e44c6..5532c51a4 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -13,7 +13,7 @@ Three bins, all over `lib/`: |---|---|---| | `gstack-regen` | `.gstack/generated.json` | generated files regenerate, never hand-merge | | `gstack-pregate` | `.gstack/pregate.json` | two-stage cheap gate, `pregate.json` record, publication check | -| `gstack-issue-links` | — | `Fixes #N` only on explicit closure intent | +| `gstack-issue-links` | — | `Fixes #N` only on explicit closure intent; Related PRs and validated flake issues | ## Trust boundary @@ -66,6 +66,8 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) | | `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) | | `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) | +| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) | +| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) | Only the `origin/<base>` copy is honoured, so a downgrade is reviewed before it takes effect. `gstack-pregate validate` / `init` cover the working-tree file. @@ -73,7 +75,8 @@ takes effect. `gstack-pregate validate` / `init` cover the working-tree file. ## `gstack-pregate` ``` -gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head] [--explain] [--json] +gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head] + [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--explain] [--json] gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init ``` @@ -123,6 +126,14 @@ Two stages with separate clocks, both printed: first of `$BUN_INSTALL/bin` and `~/.bun/bin` whose Bun satisfies gstack's and the project's `engines.bun` (the row prints `runtime: bun <v> from <dir>`), so a Capy host's older image Bun cannot flip the verdict. +- Every test-tier command (the lanes runner, each guard, the `--remote` gate) + runs supervised: see [supervision](#supervision). `--timeout` bounds each + runner process (the unit-file group, each serial file, each guard without + its own `timeout_s`), not their total; the stall timeout catches a hung or + silent one sooner. +- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend` + (`gstack-config set test_backend ubicloud`) records the local test tier as a + `requires-remote` obligation instead of running it: see [remote gate](#remote-gate). Every check records the input identity it ran against (the working-tree fingerprint before and after, the selection, the command hash); a tree that @@ -143,6 +154,77 @@ gstack lacks never gets a green it did not ask for). Tier 2 checks still to come (`ratchets`, `lint`, `links`, `hermetic`, `patches`, `integrator`) are named in `--only`'s help. +<a id="supervision"></a> +## Supervision: progress, stalls, deadlines + +GBRA-95 (gbrain, 4 cores): a core edit selected 3,683 local files, 358 of them +serial; the test stage printed nothing for 90 minutes: each runner process +ran up to its own `--timeout` (one per serial file) with its output buffered +until it returned, and nothing noticed a process that had gone quiet. Now each +test-tier command: + +- runs in its own process group (`setsid`), with stdout and stderr appended to + a log file, never a pipe, so a backgrounded grandchild that keeps the output + open cannot hold the check; after the command exits, the group's leftover + members are killed; +- reports live on stderr (stdout stays the table, or one JSON document with + `--json`): + ``` + PREGATE_TEST: <n>/<total> <file> start + PREGATE_TEST: <n>/<total> <file> pass|fail <seconds> + PREGATE_HEARTBEAT: <label> running <s>s; last file <file>; log <bytes> bytes, last growth <s>s ago (<log>) + PREGATE: tests <verdict> in <s>s — lanes: <detail> + ``` + Per-file lines come from a progress adapter where the runner's reporter + provides them: `bun test` (its `<file>:` headers) is the only adapter today; + a one-file run (each serial file) reports its file from the launch for any + runner. Other runners (node --test, vitest, jest, pytest) get heartbeats + only. A heartbeat follows each 60 s with no progress line; +- is killed as **stalled** when its log does not grow for the stall timeout + (`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the + check is `incomplete` with `PREGATE_STALLED` and names the last file + started; the next serial file still runs; +- is killed as **timed out** when that runner process reaches `--timeout` + (per runner process, so many serial files that each fit still pass); the + check is `incomplete` and the next serial file still runs. + +A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores +SIGTERM still dies). SIGINT or SIGTERM to `gstack-pregate` itself SIGKILLs every +running group, writes no `pregate.json` and exits 130 or 143. Windows has no +process groups here: the kill is `taskkill /T /F` on the shell's tree, which +misses a descendant that was reparented or started detached. + +<a id="remote-gate"></a> +## Small machines and the remote gate + +Under 8 cores (`os.availableParallelism()`), when the base branch's +`.gstack/pregate.json` declares `remote_gate` or `gstack-config get +test_backend` is not `local`, the lanes check runs nothing locally: it records +the selected local files as a `requires-remote` obligation with the command to +run, `pregate.json/remote_gate` (the declared command) or +`test_backend/<backend>` (gstack's `scripts/ubicloud/ubi-runner.sh run --src +<repo> -- '<runner> <files>'`). Without either, the tier runs locally under +the supervision above. Close the obligation one of two ways: + +- after running it, `gstack-pregate clear --remote pregate.json/remote_gate + --receipt <file>` with the gate's output, validated as `--remote` validates it + (a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt), or + `gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which + has no machine-checkable receipt and is recorded as `attested:<url>`; or +- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`, + which runs the declared `remote_gate` now (never under `--policy-from head`; + a `test_backend` without `remote_gate` cannot be run this way). The gate runs + in the repository root with `PREGATE_TREE` (the working-tree fingerprint + being tested), `PREGATE_FILES_FILE` (the selected files, one per line) and + `PREGATE_SPEND_CAP_USD`, and must print + `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`, reporting the + tree it actually tested. The obligation clears only when that tree is + `PREGATE_TREE`, `tests` > 0, `failed` = 0 and the gate exits 0; anything else + leaves it uncleared and the check `incomplete`. The cap defaults to + `$GSTACK_SPEND_CAP_USD`; there is no default spend. Spend prints as + `spent, reserved, unknown`: a gate that prints no `spent=` is `unknown`, + counted at the cap. + <a id="guards"></a> ## Guards and the lane-done rule @@ -339,14 +421,41 @@ held-out and transcript layout. ## `gstack-issue-links` ``` -gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--out issues.json] [--json] +gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]... + [--no-auto-links] [--author <login>] [--repo <owner/repo>] [--out issues.json] [--json] ``` Reads `origin/<base>..HEAD`. `Fixes #N` only on explicit closure intent: a -`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention, a -partial fix, a deferred ask and every cross-repo `owner/repo#N` are `Refs`. Each -closure issue is read through `gstack-issue-guard`; one that cannot be read is -`Refs #N (unreadable …)` with `ISSUE_UNREADABLE` (exit 1), never `Fixes`. Each -`--ci-run <id>` is searched among open issues whose body names it and rendered -`Flake: #N (run <id>)`. The printed lines go under `## Issues` in the PR body; -`--out` writes the object `gstack-ship-receipt write --issues @file` embeds. +`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention +(a subject's `(#N)` included), a partial fix, a deferred ask and every +cross-repo `owner/repo#N` are `Refs`. Same-repo references are printed as +promotion candidates (`reference #N ← <commit line> (promote: fix #N)` on +stderr, `candidates` in `--json`): the owner's `fix #N` reply reruns with +`--issue N`; an unattended run keeps them `Refs` and lists them in the +handoff. Each closure issue is read through `gstack-issue-guard`; one that +cannot be read is `Refs #N (unreadable …)` with `ISSUE_UNREADABLE` (exit 1), +never `Fixes`. + +Each `--ci-run <id>` is searched among open issues whose body names it and +rendered `Flake: #N (run <id>)` only for a bot-opened issue of this repository +that names that run (its `flake:json` `first_seen.run_id`, or this repository's +`actions/runs/<id>` URL) and a test (`flake:json` `file › test`, or a +`Flake: <test>` title). Two issues for the same test keep the lower number; +every refused match is printed with its reason. + +Open PRs by other authors (not this branch, and not this PR's author: `--author`, +else the `gh` viewer; when the viewer is an app such as `capy-ai[bot]`, the HEAD +commit's GitHub user, else the owner of a user-owned repository, else the gap is +`ISSUE_LOOKUP_FAILED`) whose closing +set overlaps this PR's `Fixes` are `Related: #N (@author)`, read page by page +from each issue's closing-PR references, in unattended runs too. `Supersedes #N` +replaces that line and `Contributed by @author` is added (the credit lines of +`lib/contributor-mode.ts`) only with `--supersedes N` / `--credit N`, after the +owner confirms. +`--no-auto-links` skips the lookup. A failed lookup prints `ISSUE_LOOKUP_FAILED` +and keeps the other lines. The stderr preview names the source of each line. + +The printed lines go under `## Issues` in the PR body; `--out` writes the +object (`fixes`, `refs`, `flakes`, `related`) `gstack-ship-receipt write +--issues @file` embeds. With `--body`, that write refuses a body missing any of +those lines (`ISSUE_LINKS_MISSING`). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 06d683dc0..b87e11067 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1443,15 +1443,64 @@ revision at or above the minimum. An interrupted install after the fast-forward leaves the trailer `fail`; the next install run completes it. <a id="capy-wrapper-stale"></a> -### `gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)` +### `FAIL wrapper — ... (CAPY_WRAPPER_STALE)` / `WARN wrapper — ...; owner action: ...` / `gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; ...` -**Meaning.** The Capy skill folder that ran (the bootstrapper's -`GSTACK_CAPY_WRAPPER`) is older than the checkout's `GSTACK_CAPY_MIN_WRAPPER`. -The run continues through the checkout's own wrapper (`bin/gstack-capy-session`); -only a checkout with no such wrapper refuses (`gstack: fail wrapper`). +**Meaning.** The Capy skill folder is older than the checkout's +`GSTACK_CAPY_MIN_WRAPPER` (`capy/support.env`). The doctor's `wrapper` row +decides by the active wrapper's provenance (`GSTACK_CAPY_WRAPPER_REV` and +`GSTACK_CAPY_WRAPPER_PATH`, exported by the bootstrapper): a current active +wrapper passes, and a stale Drive copy elsewhere is a `WARN` row, an owner +action that never fails the run. Without provenance (a direct +`gstack-doctor --check`, or a pre-bootstrapper copy with no +`GSTACK_CAPY_WRAPPER=` line, which reads as wrapper 0) it reads the Drive +copies (`~/.capy/drive/*/skills/gstack/scripts/install.sh`, or only +`GSTACK_CAPY_WRAPPER_PATH` when set) and fails only when every copy is stale, +listing each with its revision. A stale active wrapper is a `WARN` too: an +older copy still works (`capy/support.env`). `bin/gstack-upgrade` runs the +installer with the checkout's own copy as the active wrapper and never leaves +a pending reinstall when the wrapper row is the only failure. -**Fix.** The owner copies `<checkout>/capy/skill/gstack/` over the skill folder -(on Capy, the user-scope Drive `skills/gstack/`). Agents never write it. +**Fix.** This session: run the checkout's own copy, +`bash <checkout>/capy/skill/gstack/scripts/install.sh --check --for <skill>`; +it carries current provenance, so it certifies with the stale Drive copy left +unchanged. Owner: copy `<checkout>/capy/skill/gstack/` over the Drive skill +folder (on Capy, the user-scope Drive `skills/gstack/`), or have Capy re-sync +it. The message recurs every session until the Drive copy changes. Agents never +write the skill folder. + +<a id="bun-resolution-drift"></a> +### `FAIL runtime — bun resolution drifted: recorded bun <v> at <path> (<rule>), now <current>; changed: <what>; fix: ... (BUN_RESOLUTION_DRIFT)` + +**Meaning.** Install and `--check` resolve Bun through one contract +(`gstack_bun_resolve` in `bin/gstack-bun-version.sh`, twinned by `resolveBun` +in `lib/pregate/runtime.ts`): the project's exact pin and gstack's install +default in the runtime store (`<state root>/runtimes/bun/<version>/bun`), +`$BUN_INSTALL/bin`, `~/.bun/bin`, then PATH; the first Bun that satisfies +gstack's minimum and every project pin wins. Install records the pick in the +install verdict for this checkout and project (`bun_path`, `bun_version`, +`bun_rule`). The recorded Bun is gone, reports another version (replaced in +place), or no longer satisfies gstack's minimum and the project's pins. A +different candidate that would now resolve first (a store Bun another +project's install published, a changed `BUN_INSTALL`) is not drift: `--check` +keeps the recorded Bun, prints `note: the recorded bun ... stays certified` +and names the installer run that records the new pick. The pregate's lanes and +guards (`lib/pregate/runtime.ts`) and the workflow start refuse with this code +when the Bun the verdict recorded is gone or changed. + +**Fix.** Run the row's fix: the installer, which records the current pick, or, +when nothing eligible remains, re-downloads a verified Bun into the store. The +next `--check` passes. + +<a id="bun-install-version-invalid"></a> +### `gstack-capy-install: GSTACK_BUN_INSTALL_VERSION=<v> is not a verified Bun at or above gstack's supported minimum ...; fix: ... (BUN_INSTALL_VERSION_INVALID)` + +**Meaning.** `GSTACK_BUN_INSTALL_VERSION` overrides the Bun the installer +downloads when no exact project pin decides (`GSTACK_BUN_DEFAULT`, 1.4.3). It +must be listed in `GSTACK_BUN_VERIFIED` (a SHA-256-verified archive) and sit at +or above the supported minimum (`GSTACK_BUN_TESTED`). Nothing was installed. + +**Fix.** Unset the variable, or set it to one of the verified versions the +message lists. <a id="install-unverified"></a> ### `INSTALL: fail verdict — <detail>; fix: ... (INSTALL_UNVERIFIED)` @@ -2724,6 +2773,35 @@ pass and blocks publication. **Fix.** Pass `--allow-repo-commands`, install the tool, raise `--timeout`, or keep the tree still, then rerun. +<a id="pregate-stalled"></a> +### `PREGATE_STALLED` + +**Meaning.** A test command the lanes check (or a guard, or the `--remote` +gate) launched wrote nothing to its log for the stall timeout (default 300 s, +`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or +`--stall-timeout`). pregate killed its process group and names the last test +file started. The check is incomplete, never pass. + +**Fix.** Open the printed log, find the hang in the named file (a blocked +child, a server that never exits, a synchronous wait), fix it and rerun +`gstack-pregate --stage tests`. A test that is legitimately quiet for longer +needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only +`--timeout`. + +<a id="pregate-receipt-invalid"></a> +### `PREGATE_RECEIPT_INVALID` + +**Meaning.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt +<file>` read the remote gate's output and its `PREGATE_REMOTE:` line is missing, +names another tree than the one `pregate.json` tested, reports 0 tests, or +reports failures. The obligation stays uncleared. A run URL alone never clears +this obligation; a `test_backend/<backend>` obligation takes a run URL, recorded +as attested rather than validated. + +**Fix.** Run the remote gate on the tree pregate tested and pass its output +file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap +<usd> --allow-repo-commands` run and validate it. + <a id="pregate-stale"></a> ### `PREGATE_STALE` @@ -2753,6 +2831,34 @@ could not be read through `gstack-issue-guard`. **Fix.** Check `gh auth status` and the number. Until it reads, the link is rendered as `Refs #N`, never `Fixes`. +<a id="issue-lookup-failed"></a> +### `ISSUE_LOOKUP_FAILED` + +**Meaning.** `gstack-issue-links` could not resolve the repository, list the open +PRs that close an issue this PR fixes, search the flake issues naming a +`--ci-run` id, or tell this PR's author: `gh` runs as an app (`…[bot]`) and +neither the HEAD commit (unpushed, or not linked to a GitHub user) nor a +user-owned repository names one, so the owner's own open PRs stay in the +`Related:` lines. The closure and reference lines are still printed; `--json` +lists each failure under `unavailable`. + +**Fix.** Check `gh auth status`, pass `--repo <owner/repo>` when the remote +is not a GitHub repository and `--author <login>` when `gh` runs as an app, +then rerun. If it still fails, say in the handoff +which lookup is missing; never present the body as complete. + +<a id="issue-links-missing"></a> +### `ISSUE_LINKS_MISSING` + +**Meaning.** `gstack-ship-receipt write --issues @issues.json --body <file>` +found that the body lacks a `Fixes`, `Refs`, `Flake:`, `Related:`, +`Supersedes` or `Contributed by` line the issues object records, so the PR +body and its receipt would disagree. Nothing was written. + +**Fix.** Paste every line `gstack-issue-links` printed under `## Issues` in the +body (or rerun `gstack-issue-links --out` if the issue set changed), then rerun +the receipt write. + ## Eval plans, lane ownership, evidence verification, the owner brief Codes printed by `bin/gstack-eval-plan`, `bin/gstack-lane-check`, @@ -3043,7 +3149,10 @@ incorporated or withdrawn; `export` refuses a `complete` run with one pending. **Fix.** Edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`. `revise` binds each named id to the old and new Implementation-plan hashes and refuses when the Implementation plan did -not change; an id you do not name stays pending. To drop an accepted change +not change; an id you do not name stays pending. A packaging or ordering change +(its `APPLY:` line ends `no plan edit needed: revise --settle <id>`) is +discharged with `gstack-autoplan revise --out <dir> --settle <id>` and reopens +nothing. To drop an accepted change instead, `gstack-autoplan revise --out <dir> --withdraw <decision id>` records the withdrawal in the journal. @@ -3059,6 +3168,100 @@ amendments), or it was already incorporated or withdrawn. Nothing was changed. pending, revise without `--incorporates`: `gstack-autoplan revise --out <dir> --plan <file>`. +<a id="amendment-not-settleable"></a> +### `AMENDMENT_NOT_SETTLEABLE` + +**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change +without a plan edit, which is honest only for a packaging or ordering change +(the gate item's `changes` is `packaging` or `ordering`) on the plan the run +already reviews: the plan the owner accepted it on, or one a later `revise` +recorded. The named change alters scope, cost or direction, names no `changes` +value, the plan file has edits no `revise` has recorded, or `--settle` came with +a `--plan` that changes the Implementation plan while incorporating nothing. +Nothing was changed. + +**Fix.** Edit the plan, then +`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`, +adding `--settle <id>` to settle packaging or ordering changes in the same call +(or settle them after the revise). The `APPLY:` line for each change says which +path it takes, and `NEXT:` lists the settle step first. + +<a id="gate-rec-stale"></a> +### `GATE_REC_STALE` + +**Meaning.** `gstack-autoplan recommend` changed a recommendation after the gate +was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render +would take recommendations they never saw. Every render prints +`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of +the render the owner answered. The same refusal meets a `rec`/`all` reply to a +gate written before `rec` existed (a `gate.json` with no `rec_rev`, from +1.91.101.0 or earlier): that render told the owner `all` leaves items +recommended `b` pending, and `all` now takes them. + +**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it, +and answer with `--rec-hash <h>` from that render. Explicit `<id><option>` +tokens are never refused. + +<a id="checkpoint-invalid"></a> +### `CHECKPOINT_INVALID` + +**Meaning.** `gstack-autoplan resume --out <dir> --from <checkpoint>` found no +generation it can restore: a `checkpoint.json` is unreadable or not a +schema_version 1 checkpoint, a listed file is missing, is not a regular file, +escapes the generation (an `..` path or a symlinked directory), or does not +match its recorded sha256; or the staged run does not pass +`gstack-artifact validate`. `--from` naming a run's collection restores the +newest generation that verifies, skipping corrupt newer ones; a root holding +several runs must name one. Nothing was written to `--out`. + +**Fix.** `gstack-autoplan checkpoints --from <dir>` lists every generation +with `hash=ok` or `hash=bad (<reason>)`; restore an intact one by passing its +directory. With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, every command that +leaves the run at rest publishes a new generation and keeps the previous one. + +<a id="checkpoint-overlap"></a> +### `CHECKPOINT_OVERLAP` + +**Meaning.** The checkpoint directory for this run and the run directory (or, +on restore, the checkpoint and `--out` or its staging directory) are the same +directory or one contains the other, after resolving symlinks. Copying one +into the other would replace or delete the live run, so nothing was touched. +A checkpoint refusal after a committed command prints `CHECKPOINT: failed …` +on stderr and leaves that command's exit code alone. + +**Fix.** Point `GSTACK_AUTOPLAN_CHECKPOINT_DIR` (or `--from` / `--out`) at a +directory outside the run, for example the private project-user Drive folder. + +<a id="checkpoint-target-exists"></a> +### `CHECKPOINT_TARGET_EXISTS` + +**Meaning.** `resume --from` restores only into a missing or empty `--out` +directory, and this one already holds files. + +**Fix.** Pass a new `--out` directory, or run `gstack-autoplan resume --out +<dir>` without `--from` to resume the run already there. + +<a id="owner-reply-required"></a> +### `OWNER_REPLY_REQUIRED` + +**Meaning.** An agent answered the gate (`--by agent:<id>`) without recording +what the owner actually said. Nothing was applied. + +**Fix.** Pass the owner's words verbatim: `--owner-reply "<text>"`, +`--owner-reply -` to read them from stdin, or `--owner-reply-file <path>`. They +are stored on every decision row; when they parse under the gate grammar they +must resolve to the same choices as `--reply`. + +<a id="gate-owner-reply-mismatch"></a> +### `GATE_OWNER_REPLY_MISMATCH` + +**Meaning.** The owner's verbatim reply is itself a gate reply (for example +`rec`), and it resolves to different choices than the tokens the agent relayed. +Nothing was applied. + +**Fix.** Relay what the owner chose; passing their reply as `--reply` is always +consistent. When the owner's words and your tokens disagree, ask the owner. + <a id="gstack-version-changed"></a> ### `GSTACK_VERSION_CHANGED` diff --git a/docs/unattended.md b/docs/unattended.md index 8b866b11f..906257591 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -111,14 +111,14 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`, 3. **List what is pending.** ```bash jq -c 'select(.status=="pending")' <dir>/decisions.jsonl - gstack-gate render <dir>/gate.json # the numbered list and reply grammar + gstack-gate render <dir>/gate.json --all # the numbered list and reply grammar, every page ``` -4. **Answer the gate** by composing the reply grammar (`all`, `<id><option>` - tokens such as `d3b uc1a`, or `all except <tokens>`; bare yes/no is - rejected) and feeding it back with the gate revision you read: +4. **Answer the gate** by composing the reply grammar (`rec`, `as-is`, + `<id><option>` tokens such as `d3b uc1a`, or `rec except <tokens>`; bare + yes/no is rejected) and feeding it back with the gate revision you read: ```bash - gstack-gate parse <dir>/gate.json --reply "all except d3b" --gate-rev 1 --json - gstack-gate decisions <dir>/gate.json --reply "all except d3b" --gate-rev 1 >> <dir>/decisions.jsonl + gstack-gate parse <dir>/gate.json --reply "rec except d3b" --gate-rev 1 --json + gstack-gate decisions <dir>/gate.json --reply "rec except d3b" --gate-rev 1 >> <dir>/decisions.jsonl ``` A stale revision is `GATE_REV_STALE` and a reply with unparsed tokens is `GATE_REPLY_UNPARSED`; both exit `1` and write nothing. @@ -150,8 +150,8 @@ $AP next --out <dir> --plan <plan.md> [--ui|--no-ui] [--developer-tool] [--no-dx # SCOPE: ui=yes (...) dx=yes (detected: ...) source=ui:detected,dx:detected # DRIVER: <root>/autoplan/DRIVER.md # Skipped sections: none (scope: ui=yes,dx=yes,source=ui:detected,dx:detected; checklist=loaded) -# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.md model_family=... -# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.md model_family=differs from the native reviewer +# ATTEMPT: <run>-a1 phase=ceo voice=native prompt=<dir>/ceo-native-prompt.md result=<dir>/ceo-native.a1.md model_family=... +# ATTEMPT: <run>-a2 phase=ceo voice=outside prompt=<dir>/ceo-outside-prompt.md result=<dir>/ceo-outside.a2.md model_family=differs from the native reviewer # SPEND: spent=0.00 reserved=0.00 unknown=0(0.00) worst_case=0.00 cap=none # GSTACK_RESULT: skill=autoplan status=awaiting_result run=<dir> ``` @@ -173,18 +173,21 @@ the plan. `release --attempt <id>` returns its reservation). 2. **Submit each result exactly once.** ```bash - $AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.md --model <id> --attempt <run>-a1 - $AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.md --model <id> --attempt <run>-a2 [--runner host-subagent] [--usage-usd <n>] + $AP submit --out <dir> --phase ceo --voice native --result <dir>/ceo-native.a1.md --model <id> --attempt <run>-a1 + $AP submit --out <dir> --phase ceo --voice outside --result <dir>/ceo-outside.a2.md --model <id> --attempt <run>-a2 [--runner host-subagent (default)] [--usage-usd <n>] # BOUND: <run>-a1 phase=ceo voice=native model=<id> findings=7 sha256=<result hash prefix> # PHASE_CLOSED: ceo confirmed=4 disagree=1 new=2 native_only=1 coverage=both ``` + Submit seals the bytes it bound as `<phase>-<voice>.<attempt>.bound.md` + (read-only); a later write to the reviewer's file changes nothing bound, + and another attempt's result file is refused (`ATTEMPT_MISMATCH`). Binding checks the receipt line against the phase snapshot (`RESULT_RECEIPT_MISSING`), parses the canonical findings fence (`RESULT_FINDINGS_MISSING`), refuses an outside model from the native family (`MODEL_FAMILY_CONFLICT`), and refuses a second bind of the same attempt or a bind while the phase is not awaiting it (`ATTEMPT_MISMATCH`, `PHASE_NOT_AWAITING`, exit 3). Both voices in closes the phase: the - reconciliation (`confirmed` / `disagree` / `new` per outside finding, with + reconciliation (`confirmed` / `unverified` / `disagree` / `new` per outside finding, with disagreement and resolution kept separate) is written to `<phase>-consensus.{json,md}`, and every later phase's prompts (DX, design and Eng alike) carry each closed predecessor's consensus as data; a phase @@ -194,40 +197,56 @@ the plan. disagreements as auto items and `p1` plan approval; nothing is approved. 4. **Answer the gate** with the reply grammar and the revision you read: ```bash - $AP answer --out <dir> --gate-rev 1 --reply "all" --by human # every recommendation the owner may delegate; agreeing plan changes stay pending - $AP answer --out <dir> --gate-rev 1 --reply "all as-is" --by human # APPROVED: keeps every direction, no new paid phase - $AP answer --out <dir> --gate-rev 1 --reply "all except d1b" --by human # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ... + $AP answer --out <dir> --gate-rev 1 --reply "rec" --by human # every recommendation, plan changes included (APPLY: ...; amend_pending) + $AP answer --out <dir> --gate-rev 1 --reply "as-is" --by human # APPROVED: keeps every direction, no new paid phase + $AP answer --out <dir> --gate-rev 1 --reply "rec except d1b" --by agent:<id> --owner-reply "<owner's words>" ``` `--by human` means the owner typed the tokens; `--by agent:<thread or id>` - means an agent relayed or decided them. An unattended run refuses an answer - without it (`ANSWER_BY_REQUIRED`, exit 3) and records the value as + means an agent relayed them, and then `--owner-reply` (or `-` for stdin, + `--owner-reply-file <path>`) stores the owner's words verbatim + (`OWNER_REPLY_REQUIRED`); owner words that parse to different choices are + refused (`GATE_OWNER_REPLY_MISMATCH`). An unattended run refuses an answer + without `--by` (`ANSWER_BY_REQUIRED`, exit 3) and records the value as `answered_by` on every decision row and in the review record's gate line, so a gate an agent closed is distinguishable from one the owner closed. - A user challenge both voices raised on a confirmed finding with the same - `changes` value is recommended as `b`, and option b shows both voices' - remedies (`recommended: b (both voices; remedies shown)`); a `--reconcile` - record naming one shared `proposal` shows that instead. Disagreeing or - missing remedies keep `a`. `all` never answers an item recommended `b`: - that would change the owner's stated direction on their behalf, so those - items stay pending (`GATE_PENDING: uc1 unanswered (reply with uc1a|uc1b; all - does not answer items recommended b)`) until their own token arrives, and - the rendered gate names them under the reply grammar. Every accepted plan change prints + `gstack-autoplan recommend` changes one recommendation before relay; it + bumps the gate's `rec_rev`, and a later `rec` needs the `--rec-hash` from + the render the owner saw (`GATE_REC_STALE`). + A user challenge both voices raised on a confirmed finding (a title match, + not file:line proximity alone, which is `unverified`) with the same + `changes` value is recommended as `b` only with one shared proposal: a + `--reconcile` record naming it, or the same remedy from both voices. + Different or missing remedies keep `a`, and option b shows both verbatim + with the basis. `rec` (alias `all`) takes every recommendation, so it + accepts the items recommended `b`. Every accepted plan change prints `APPLY: <decision id> <title> -> <chosen text>` and becomes a pending amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a - `complete` run until `revise --incorporates` names it (see below). + `complete` run until `revise --incorporates` names it, or `revise --settle + <id>` settles a packaging or ordering change that needs no plan edit. A stale revision (`GATE_REV_STALE`, exit 3), bare `yes`/`no`, or a conflicting reply (`GATE_REPLY_UNPARSED`) writes nothing. A reply that leaves approval items pending records only the answered auto items and - keeps the gate; a later reply never overwrites an earlier answer. A choice - that changes the plan reopens that phase plus Eng, archives their bound - files as `*.r<rev>.*`, and the next gate is `gate_rev + 1`; `p1d` reruns - every phase on the current plan; `p1e` ends `incomplete`. + keeps the gate; a later reply never overwrites an earlier answer. An + accepted change reopens nothing at answer (status `amend_pending`); `revise` + reopens each phase whose reviewed plan changed plus Eng, archives their + prompts and consensus as `*.r<rev>.*` (sealed `*.bound.md` results stay in + place), and the next gate is `gate_rev + 1`; `p1d` reruns every phase on the + current plan; `p1e` ends `incomplete`. 5. **Export and validate.** `$AP export --out <dir>` writes `plan.md` (with the `## URGENT, outside this plan` block first), `review-record.md`, `tasks.jsonl`, `decisions.jsonl`, `findings.jsonl`, `timing.json` and `run.json`; then `gstack-artifact validate <dir>/run.json` and the consumption steps above apply unchanged. `$AP status --out <dir>` and - `resume` work after the run directory moves (paths are re-rooted). + `resume` work after the run directory moves (paths are re-rooted; journal + paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run + at rest publishes an immutable, hash-listed generation under + `<dir>/<run>/generations/` and switches `current.json` to it (the previous + generation is kept; a stale copy never replaces a newer one; a directory + overlapping the run is refused with `CHECKPOINT_OVERLAP`; a failure is a + `CHECKPOINT: failed` warning that leaves the command's exit code alone). + `$AP checkpoints --from <dir>` lists generations with their hash status, and + `$AP resume --out <new dir> --from <dir>/<run>` restores the newest valid + one after validating the staged run (`CHECKPOINT_INVALID`). Concurrency and recovery: every state write is a locked read-modify-write (`run.lock`; a second writer sees `RUN_LOCKED`), attempts are journaled to diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts new file mode 100644 index 000000000..dff1b3ad0 --- /dev/null +++ b/lib/autoplan-checkpoint.ts @@ -0,0 +1,228 @@ +/** + * autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave + * B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that + * leaves the run at rest (gate_pending, amend_pending, complete, incomplete) + * publishes one immutable generation under `<dir>/<run>/generations/<name>/`: + * `checkpoint.json` (every file's sha256), the run files under `run/`, the + * owner's plan file under `plan-input/` when it lives outside the run, and + * `restore.md`. `current.json` then switches to it with one rename; the + * previous generation stays until the switch commits, so an interrupted + * publish leaves the last checkpoint current. A generation never replaces a + * newer one (higher run revision). Publication and restore hold the + * collection's `.lock`; restore also holds `<out>.lock`. + * + * Restore picks the named generation, or the newest generation that verifies + * when `--from` names the collection (or a root holding one run). It refuses + * overlapping source and target paths (symlink aliases included), any listed + * file whose real path leaves the generation, and any hash mismatch; it + * validates the staged run (gstack-artifact's validateRun) before publishing + * it to `--out` with one rename, so a refusal leaves `--out` absent. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sha256File, validateRun } from './headless-artifacts'; +import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state'; + +const MANIFEST = 'checkpoint.json'; +const CURRENT = 'current.json'; +const GENERATIONS = 'generations'; +const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete']; +const FOREIGN_LOCK_STALE_MS = 10 * 60_000; +interface CheckpointManifest { + schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string; + files: Record<string, string>; + plan_input?: { original: string; file: string; sha256: string }; +} +export interface Generation { dir: string; manifest?: CheckpointManifest; problem?: string } + +const inside = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); }; +const overlaps = (a: string, b: string) => a === b || inside(a, b) || inside(b, a); +/** The real path of `p`, resolving symlinks through its deepest existing ancestor. */ +function canonical(p: string): string { + let cur = path.resolve(p); + const rest: string[] = []; + while (!fs.existsSync(cur) && path.dirname(cur) !== cur) { rest.unshift(path.basename(cur)); cur = path.dirname(cur); } + return path.join(fs.realpathSync(cur), ...rest); +} +function refuseOverlap(what: string, a: string, b: string): void { + const ca = canonical(a), cb = canonical(b); + if (overlaps(ca, cb)) throw new RunError('CHECKPOINT_OVERLAP', `${what}: ${a} (${ca}) and ${b} (${cb}) are the same directory or one contains the other`, 3); +} + +function exclusive<T>(lock: string, fn: () => T): T { + fs.mkdirSync(path.dirname(lock), { recursive: true }); + const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() }); + for (let i = 0; ; i++) { + try { fs.writeFileSync(lock, mine, { flag: 'wx' }); break; } catch (e: any) { + if (e.code !== 'EEXIST') throw e; + let owner: { pid?: number; host?: string; at?: string } = {}; + try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* being written */ } + let alive = true; + if (owner.host === os.hostname() && typeof owner.pid === 'number') { try { process.kill(owner.pid, 0); } catch (k: any) { alive = k.code === 'EPERM'; } } + else if (owner.host && owner.at) alive = Date.now() - Date.parse(owner.at) < FOREIGN_LOCK_STALE_MS; + if (alive || i > 3) throw new RunError('RUN_LOCKED', `${lock} is held by pid ${owner.pid ?? '?'} on ${owner.host ?? '?'} since ${owner.at ?? '?'}`, 3); + fs.rmSync(lock, { force: true }); + } + } + try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* gone */ } } +} + +function runFiles(dir: string, rel = ''): string[] { + return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) return /^\.revise-v\d+$/.test(e.name) ? [] : runFiles(dir, r); + return e.isFile() && r !== FILES.lock && !r.endsWith('.tmp') ? [r] : []; + }); +} +const copy = (from: string, to: string) => { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); }; +const writeAtomic = (file: string, text: string) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); }; + +function restoreIndex(m: CheckpointManifest, collection: string): string { + return [`# autoplan checkpoint: ${m.run}`, '', + `Current generation written ${m.at} on ${m.host} at status=${m.status}, gate_rev=${m.gate_rev}, revision=${m.revision}; ${Object.keys(m.files).length} files, each listed with its sha256 in generations/<name>/checkpoint.json.`, '', + 'List the generations and their hash status, then restore the newest valid one on any machine (the target directory must not hold a run):', '', + ` gstack-autoplan checkpoints --from ${collection}`, + ` gstack-autoplan resume --out <new run dir> --from ${collection}`, '', + 'resume verifies every hash and the staged run, publishes the run directory, takes ownership and prints the next step.', + ...(m.plan_input ? ['', `The owner's plan file was ${m.plan_input.original}; a copy is in plan-input/ and is restored into the run directory when the original is missing.`] : []), ''].join('\n'); +} + +/** The newest run revision any generation in the collection records (unreadable generations count as nothing). */ +function newestRevision(collection: string): number { + const dir = path.join(collection, GENERATIONS); + if (!fs.existsSync(dir)) return -1; + return Math.max(-1, ...fs.readdirSync(dir).map(g => { try { return Number(JSON.parse(fs.readFileSync(path.join(dir, g, MANIFEST), 'utf8')).revision); } catch { return -1; } }).filter(Number.isFinite)); +} + +/** Publish a generation when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */ +export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR; + if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined; + const state = readState(out); + if (!AT_REST.includes(state.status)) return undefined; + const collection = path.join(path.resolve(root), state.run); + refuseOverlap('the checkpoint directory overlaps the run directory', collection, state.out); + return exclusive(path.join(collection, '.lock'), () => { + const newest = newestRevision(collection); + if (newest > state.revision) return `CHECKPOINT: skipped ${collection}: it holds a newer checkpoint (revision ${newest} > ${state.revision}); nothing replaced`; + const name = `g${String(state.revision).padStart(6, '0')}-${now().replace(/[-:.TZ]/g, '')}-${process.pid}`; + const tmp = path.join(collection, `.tmp-${name}`); + const files: Record<string, string> = {}; + for (const rel of runFiles(state.out)) { copy(path.join(state.out, rel), path.join(tmp, 'run', rel)); files[rel] = sha256File(path.join(tmp, 'run', rel)); } + const input = state.plan_input && !inside(state.out, path.resolve(state.plan_input)) && fs.existsSync(state.plan_input) ? state.plan_input : undefined; + const planFile = input ? `plan-input/${path.basename(input)}` : undefined; + if (input) copy(input, path.join(tmp, planFile!)); + const m: CheckpointManifest = { schema_version: 1, run: state.run, status: state.status, gate_rev: state.gate_rev ?? 0, revision: state.revision, at: now(), host: os.hostname(), + ...(state.gstack_version ? { gstack_version: state.gstack_version } : {}), files, ...(input ? { plan_input: { original: input, file: planFile!, sha256: sha256File(path.join(tmp, planFile!)) } } : {}) }; + fs.writeFileSync(path.join(tmp, 'restore.md'), restoreIndex(m, collection)); + fs.writeFileSync(path.join(tmp, MANIFEST), JSON.stringify(m, null, 2) + '\n'); + fs.mkdirSync(path.join(collection, GENERATIONS), { recursive: true }); + fs.renameSync(tmp, path.join(collection, GENERATIONS, name)); + if (env.GSTACK_AUTOPLAN_FAULT === 'checkpoint-before-pointer') throw new Error('fault injected: checkpoint interrupted before switching current.json (GSTACK_AUTOPLAN_FAULT, tests only)'); + let previous: string | undefined; + try { previous = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation; } catch { /* first generation */ } + writeAtomic(path.join(collection, CURRENT), JSON.stringify({ generation: name, run: m.run, status: m.status, gate_rev: m.gate_rev, revision: m.revision, at: m.at }, null, 2) + '\n'); + writeAtomic(path.join(collection, 'restore.md'), restoreIndex(m, collection)); + for (const g of fs.readdirSync(path.join(collection, GENERATIONS))) if (g !== name && g !== previous) fs.rmSync(path.join(collection, GENERATIONS, g), { recursive: true, force: true }); + for (const e of fs.readdirSync(collection)) if (e.startsWith('.tmp-')) fs.rmSync(path.join(collection, e), { recursive: true, force: true }); + return `CHECKPOINT: ${path.join(collection, GENERATIONS, name)} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} files=${Object.keys(files).length}`; + }); +} + +/** Verify one generation without throwing: manifest shape, containment by real path, regular files, hashes. */ +export function verifyGeneration(dir: string): Generation { + let m: CheckpointManifest; + try { m = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8')); } catch (e) { return { dir, problem: `no readable ${MANIFEST} (${(e as Error).message})` }; } + if (m.schema_version !== 1 || typeof m.run !== 'string' || !m.files || typeof m.files !== 'object' || !m.files[FILES.run]) return { dir, manifest: m, problem: `${MANIFEST} is not a schema_version 1 checkpoint with run.json listed` }; + const check = (rel: string, sha: string, base: string): string | undefined => { + if (typeof rel !== 'string' || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return `${rel} escapes the checkpoint`; + const file = path.join(base, rel); + let st: fs.Stats; + try { st = fs.lstatSync(file); } catch { return `${rel} is listed but missing`; } + if (!st.isFile()) return `${rel} is not a regular file`; + if (!inside(fs.realpathSync(base), fs.realpathSync(file))) return `${rel} escapes the checkpoint through a symlinked directory`; + if (sha256File(file) !== sha) return `${rel} does not match its sha256 in ${MANIFEST}`; + return undefined; + }; + for (const [rel, sha] of Object.entries(m.files)) { const problem = check(rel, sha, path.join(dir, 'run')); if (problem) return { dir, manifest: m, problem }; } + if (m.plan_input) { const problem = check(m.plan_input.file, m.plan_input.sha256, dir); if (problem) return { dir, manifest: m, problem }; } + return { dir, manifest: m }; +} + +/** Every generation `from` names: one generation, a run's collection, or a root of collections; newest first. */ +export function listGenerations(from: string): Generation[] { + const src = path.resolve(from); + if (fs.existsSync(path.join(src, MANIFEST))) return [verifyGeneration(src)]; + const collections = fs.existsSync(path.join(src, GENERATIONS)) ? [src] + : fs.existsSync(src) ? fs.readdirSync(src).map(e => path.join(src, e)).filter(c => fs.existsSync(path.join(c, GENERATIONS))) : []; + const order = (g: Generation) => [g.manifest?.revision ?? -1, Date.parse(g.manifest?.at ?? '') || 0] as const; + return collections.flatMap(c => fs.readdirSync(path.join(c, GENERATIONS)).map(g => verifyGeneration(path.join(c, GENERATIONS, g)))) + .sort((a, b) => order(b)[0] - order(a)[0] || order(b)[1] - order(a)[1]); +} + +/** `gstack-autoplan checkpoints --from <dir>`: one line per generation, newest first. */ +export function checkpointLines(from: string): string[] { + const gens = listGenerations(from); + if (!gens.length) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: no checkpoint generations found`, 3); + return gens.map(g => { + const m = g.manifest; + const collection = path.dirname(path.dirname(g.dir)); + let current = false; + try { current = JSON.parse(fs.readFileSync(path.join(collection, CURRENT), 'utf8')).generation === path.basename(g.dir); } catch { /* no pointer */ } + return `CHECKPOINT: ${g.dir} run=${m?.run ?? '?'} gate_rev=${m?.gate_rev ?? '?'} status=${m?.status ?? '?'} revision=${m?.revision ?? '?'} at=${m?.at ?? '?'} hash=${g.problem ? `bad (${g.problem})` : 'ok'}${current ? ' current' : ''}`; + }); +} + +/** The generation to restore: the one named, or the newest that verifies in a collection or a root holding one run. */ +function chooseGeneration(from: string): Generation { + const gens = listGenerations(from); + const runs = [...new Set(gens.map(g => g.manifest?.run).filter(Boolean))]; + if (runs.length > 1) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)} holds checkpoints of ${runs.length} runs (${runs.join(', ')}); pass --from <dir>/<run>`, 3); + const valid = gens.find(g => !g.problem); + if (!valid) throw new RunError('CHECKPOINT_INVALID', `${path.resolve(from)}: ${gens.length ? gens.map(g => `${path.basename(g.dir)}: ${g.problem}`).join('; ') : 'no checkpoint generations found'}`, 3); + return valid; +} + +/** Verify a checkpoint and publish it as the run directory `out`. Nothing is written to `out` unless every check passes. */ +export function restoreCheckpoint(from: string, out: string): string[] { + const src = path.resolve(from); + const target = path.resolve(out); + const staging = `${target}.restore-${process.pid}`; + refuseOverlap('the checkpoint and --out', src, target); + refuseOverlap('the checkpoint and the restore staging directory', src, staging); + const collectionLock = fs.existsSync(path.join(src, MANIFEST)) ? path.join(path.dirname(path.dirname(src)), '.lock') : path.join(src, '.lock'); + return exclusive(`${target}.lock`, () => exclusive(collectionLock, () => { + if (fs.existsSync(target) && fs.readdirSync(target).length) throw new RunError('CHECKPOINT_TARGET_EXISTS', `${target} is not empty`, 3); + const gen = chooseGeneration(src); + const m = gen.manifest!; + const skipped = listGenerations(src).filter(g => g.problem && (g.manifest?.revision ?? -1) >= m.revision).map(g => `CHECKPOINT: skipped ${g.dir} (${g.problem})`); + fs.rmSync(staging, { recursive: true, force: true }); + try { + for (const rel of Object.keys(m.files)) copy(path.join(gen.dir, 'run', rel), path.join(staging, rel)); + for (const [rel, sha] of Object.entries(m.files)) if (sha256File(path.join(staging, rel)) !== sha) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: ${rel} changed while it was copied`, 3); + const lines: string[] = []; + const runFile = path.join(staging, FILES.run); + let state: RunState; + try { state = JSON.parse(fs.readFileSync(runFile, 'utf8')); } catch (e) { throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json does not parse (${(e as Error).message})`, 3); } + if (state.run !== m.run || typeof state.out !== 'string' || !state.phases || typeof state.phases !== 'object') throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: run.json is not the run state of ${m.run}`, 3); + if (m.plan_input && !fs.existsSync(m.plan_input.original)) { + copy(path.join(gen.dir, m.plan_input.file), path.join(staging, m.plan_input.file)); + state.plan_input = path.join(state.out, m.plan_input.file); + if (!fs.existsSync(state.plan_source)) state.plan_source = state.plan_input; + fs.writeFileSync(runFile, JSON.stringify(state, null, 2) + '\n'); + lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${path.join(target, m.plan_input.file)}`); + } else if (m.plan_input && sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`); + const v = validateRun(runFile); + if (!v.valid) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: the staged run does not validate: ${v.errors.slice(0, 3).map(e => `${e.code} ${e.path}: ${e.message}`).join('; ')}`, 3); + if (fs.existsSync(target)) fs.rmdirSync(target); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(staging, target); + readState(target); + journal(target, { event: 'restored', from: gen.dir, checkpoint_at: m.at, checkpoint_host: m.host, revision: m.revision, host: os.hostname(), files: Object.keys(m.files).length }); + return [...skipped, `RESTORED: run=${m.run} from=${gen.dir} status=${m.status} gate_rev=${m.gate_rev} revision=${m.revision} files=${Object.keys(m.files).length} (written ${m.at} on ${m.host})`, ...lines]; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } + })); +} diff --git a/lib/autoplan-export.ts b/lib/autoplan-export.ts index cf876bde8..c88e274c5 100644 --- a/lib/autoplan-export.ts +++ b/lib/autoplan-export.ts @@ -45,7 +45,7 @@ export function aggregateFindings(state: RunState): FindingRow[] { for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null }); for (const row of voiceRows(state, phase, 'outside')) { const r = byOutside.get(row.id); - out.push({ ...row, native_counterpart: r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow); + out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow); } } return out; diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 23c327929..35db3f557 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -19,15 +19,16 @@ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; -import { driftLine } from './autoplan-plan-text'; -import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; +import { driftLine, reviewHash } from './autoplan-plan-text'; +import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; import type { Phase } from './autoplan-prompts'; -import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state'; +import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; import { phaseClose } from './autoplan-timing'; -import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list'; +import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list'; import type { FindingRow } from './headless-artifacts'; import { readJsonl } from './headless-artifacts'; import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief'; +import { vaguePlanTerms } from './plan-reality'; import { settle } from './spend-ledger'; export function loadOverrides(file: string | undefined): ReconciliationOverride[] { @@ -56,7 +57,7 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s const md = renderConsensus(consensus, [...native, ...outside]); fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify(consensus, null, 2) + '\n'); fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), md); - ps.consensus = { confirmed: consensus.confirmed, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; + ps.consensus = { confirmed: consensus.confirmed, unverified: consensus.unverified, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; ps.closed_at = now(); const closed = phaseClose(path.join(state.out, 'timing.json'), state.run, phase, { outside_s: seconds(ps.outside), native_s: seconds(ps.native), session_kind: state.session_kind }); journalRow(state.out, { event: 'phase_closed', phase, consensus: ps.consensus, wall_s: closed.entry.wall_s, analytics: closed.analytics }); @@ -70,25 +71,30 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design', const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16); const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false }); + /** - * A user challenge both voices raised recommends the change only when the - * finding reconciled `confirmed`, both name the same `changes` and the - * remedies are on the record (both voices', or the parent's one shared - * proposal from a reconcile override); option b shows them verbatim. Anything - * weaker keeps the plan's direction, and the basis says why. + * A user challenge both voices raised recommends the change only with an + * explicit, coherent shared proposal: the finding reconciled `confirmed`, + * both name the same `changes`, and either the parent's reconcile override + * states one proposal for both or both voices wrote the same remedy. Title + * similarity and file:line proximity only associate the findings; anything + * weaker keeps the plan's direction, and the basis says why. Option b shows + * the remedies verbatim. */ -function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } { +export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } { const nf = n as (FindingRow & { changes?: string }) | undefined, of = o as FindingRow & { changes?: string }; const remedies = row.proposal ? ` (shared proposal: ${row.proposal})` : nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : ''; const text = `accept the change both reviewers recommend${remedies}`; const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text }); + if (row.disposition === 'unverified') return keep(row.resolution.startsWith('same title, a remedy is missing') ? 'a remedy is missing' : row.resolution.startsWith('same title') ? 'remedies differ; no shared proposal' : 'same location, agreement unverified'); if (row.disposition !== 'confirmed') return keep('voices disagree'); if (!nf?.changes || !of.changes) return keep('no shared changes value'); if (nf.changes !== of.changes) return keep('voices name different changes'); if (row.proposal) return { recommended: 'b', basis: 'both voices; shared proposal', text }; if (!nf.resolution || !of.resolution) return keep('a remedy is missing'); - return { recommended: 'b', basis: 'both voices; remedies shown', text }; + if (remedyKey(nf.resolution) !== remedyKey(of.resolution)) return keep('remedies differ; no shared proposal'); + return { recommended: 'b', basis: 'both voices; same remedy', text }; } /** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */ @@ -139,17 +145,54 @@ export function buildGate(state: RunState, gateRev: number): GateList { cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id); } } - const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); - items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', - options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, - { key: 'c', text: 'interrogate (ask questions; stays pending)' }, - { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }); - const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' }; + items.push(planApproval(state, items)); + const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 }; const problems = validateGateList(list); if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`); return list; } +/** The p1 plan-approval item: recommends `b` when any item's recommendation changes the plan, with PLAN_REALITY flags. */ +function planApproval(state: RunState, items: GateItem[]): GateItem { + const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); + const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; + const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); + return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), + options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, + { key: 'c', text: 'interrogate (ask questions; stays pending)' }, + { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }; +} + +/** + * `gstack-autoplan recommend`: the relaying parent sets one item's recommendation before relay, with its basis and + * provenance. The plan-approval item and decisions.jsonl follow, and `rec_rev` bumps so a `rec` rendered earlier is + * refused (GATE_REC_STALE) until the gate is rendered and relayed again. + */ +export function recommendGate(state: RunState, gateRev: number, itemId: string, option: string, basis: string, by: string): string[] { + const gateFile = path.join(state.out, 'gate.json'); + if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3); + const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + if (gateRev !== list.gate_rev) throw new RunError('GATE_REV_STALE', `--gate-rev ${gateRev}, current gate_rev=${list.gate_rev}`, 3); + const item = list.items.find(i => i.id === itemId); + if (!item || item.id === 'p1' || !item.options.some(o => o.key === option)) throw new RunError('GATE_REPLY_UNPARSED', `${itemId}${option} names no item option a recommendation can take (p1 follows the items)`); + Object.assign(item, { recommended: option, recommended_basis: basis, recommended_by: by }); + const p1 = planApproval(state, list.items); + list.items = list.items.map(i => (i.id === 'p1' ? p1 : i)); + list.rec_rev = (list.rec_rev ?? 0) + 1; + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); + const updated = new Map([item, p1].map(i => [decisionId(list.run, gateRev, i.id), i])); + writeDecisions(state.out, readDecisions(state.out).map(r => { + const i = updated.get(r.id); + if (!i) return r; + return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) }; + })); + const hash = recHash(list); + journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash }); + exportRun(state); + return [`RECOMMENDED: ${itemId}${option} by=${by} basis=${JSON.stringify(basis)} p1=${p1.recommended} rec_hash=${hash}`, + `RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`]; +} + /** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */ export function finishGate(state: RunState): GateList { const gateRev = (state.gate_rev ?? 0) + 1; @@ -180,32 +223,71 @@ export function finishGate(state: RunState): GateList { // --------------------------------------------------------------------------- // answer // --------------------------------------------------------------------------- -export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] } +export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected' | 'amend_pending'; lines: string[] } +export interface AnswerOptions { + /** The rec_hash of the render the owner saw; required for a `rec` reply once `recommend` changed the gate. */ + recHash?: string; + /** The owner's verbatim words; required for every `--by agent:<id>` answer. */ + ownerReply?: string; +} + +/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */ +export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string { + return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise'; +} + +/** The revise steps that discharge the pending amendments: settle the packaging/ordering changes first, then edit for the rest. */ +export function amendmentSteps(state: RunState, out: string): string { + const pending = (state.amendments ?? []).filter(a => a.status === 'pending'); + const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision); + const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision); + return [settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : '', + edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then '); +} + +/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */ +function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean { + const owner = parseReply(ownerReply, list, gateRev, prior); + if (!owner.valid) return false; + const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort()); + if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) { + throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3); + } + return true; +} /** * Apply one reply. Earlier answers at this revision stand unless named again; * the gate stays pending until every approval settles. A chosen option with - * `changes_plan` is an accepted plan change: it reopens its phase plus Eng, - * prints `APPLY:` and becomes a pending amendment (`next` refuses until - * `revise --incorporates` or `--withdraw`). + * `changes_plan` is an accepted plan change: it prints `APPLY:` and becomes a + * pending amendment (status `amend_pending`); nothing reopens here. `revise` + * reopens exactly the phases whose reviewed plan changed, and `revise + * --settle` discharges a packaging/ordering change without an edit. */ -export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult { +export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string, o: AnswerOptions = {}): AnswerResult { const gateFile = path.join(state.out, 'gate.json'); if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3); if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3); if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3); + if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3); const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; const rows = readDecisions(state.out); const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!])); const parsed = parseReply(reply, list, gateRev, prior); if (parsed.error === 'GATE_REV_STALE') throw new RunError('GATE_REV_STALE', `reply names gate_rev=${gateRev}, current gate_rev=${list.gate_rev}`, 3); if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`); + const currentHash = recHash(list); + if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3); + if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit <id><option> tokens`, 3); + if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3); + const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined; + const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {}; const itemOf = (id: string) => list.items.find(i => i.id === id)!; const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!; const plan = parsed.choices.find(c => c.id === 'p1'); const lines: string[] = []; const answeredAt = now(); - const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => { + const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => { const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined; return c ? { ...r, chosen_text: c.text } : r; })); @@ -222,15 +304,10 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : []; const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`); const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1'); - const reopenSet = new Set<Phase>(); - if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p)); - for (const c of changing) { - const phase = itemOf(c.id).phase as Phase | undefined; - if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); } - } + const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []); record(); - state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) }; - journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) }); + state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }; + journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }); if (plan.chosen === 'c') { lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`); return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines }; @@ -242,31 +319,46 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ lines.push('REJECTED: run.json.status=incomplete'); return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines }; } + const acceptedPlan = changing.length ? reviewHash(fs.readFileSync(state.active_plan, 'utf8')) : ''; for (const c of changing) { - const decision = decisionId(state.run, gateRev, c.id); - (state.amendments ??= []).push({ decision, title: itemOf(c.id).title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt }); - journalRow(state.out, { event: 'amendment_pending', decision, gate_rev: gateRev }); - lines.push(`APPLY: ${decision} ${itemOf(c.id).title} -> ${optionOf(c).text}`); + const item = itemOf(c.id); + const amendment: Amendment = { decision: decisionId(state.run, gateRev, c.id), title: item.title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt, + item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}), plan_sha256: acceptedPlan }; + (state.amendments ??= []).push(amendment); + journalRow(state.out, { event: 'amendment_pending', decision: amendment.decision, gate_rev: gateRev, ...(item.changes ? { changes: item.changes } : {}) }); + lines.push(`APPLY: ${amendment.decision} ${item.title} -> ${amendment.text}; ${applyGuidance(amendment)}`); } - if (reopenSet.size === 0) { - state.status = 'complete'; - lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10))); + if (reopenSet.size) { + const reopened = state.phase_order.filter(p => reopenSet.has(p)); + reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: revise`); + if (changing.length) state.status = 'amend_pending'; + lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=revise`); + const drift = driftLine(state, 'PLAN_SOURCE_CHANGED'); + if (drift) lines.push(drift); + return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines }; + } + if (changing.length) { + state.status = 'amend_pending'; exportRun(state); - const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id))); - lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`); - return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines }; + lines.push(`AMEND_PENDING: ${changing.length} accepted change(s) not yet in the plan; nothing reopens until revise`); + return { state, applied: true, pending: [], reopened: [], outcome: 'amend_pending', lines }; } - const reopened = state.phase_order.filter(p => reopenSet.has(p)); - const reason = plan.chosen === 'd' ? 'revise' : tokens(changing).join(','); - reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${reason}`); - lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${reason}`); - const drift = driftLine(state, 'PLAN_SOURCE_CHANGED'); - if (drift) lines.push(drift); - return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines }; + lines.push(...approve(state, answeredAt)); + const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id))); + lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`); + return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines }; +} + +/** The run is approved: status complete, draft directions closed, files exported. */ +export function approve(state: RunState, at: string): string[] { + state.status = 'complete'; + const lines = closeDraftDirections(state, at.slice(0, 10)); + exportRun(state); + return lines; } /** E3 phase-close operation: every `draft direction stands until the owner decides` line in the active plan becomes the decided option and date. */ -function closeDraftDirections(state: RunState, date: string): string[] { +export function closeDraftDirections(state: RunState, date: string): string[] { const loaded = loadBriefInputs(state.out); if ('error' in loaded) return []; const r = closeDraftDirectionsFile(state.active_plan, loaded.inputs, date); @@ -305,7 +397,8 @@ export function planReopen(state: RunState, phases: Phase[], gateRev: number, re journalRow(state.out, { event: 'superseded', attempt: a.attempt, phase, voice, reason }); } const output = ps[voice]?.output; - if (output) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; } + // A sealed copy is attempt-owned and immutable: it stays in place and bound; only a pre-seal output (bound at the result path) is archived. + if (output && !output.endsWith('.bound.md')) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; } archive(`${phase}-${voice}-prompt.md`); } archive(`${phase}-consensus.json`); archive(`${phase}-consensus.md`); diff --git a/lib/autoplan-plan-text.ts b/lib/autoplan-plan-text.ts index c6b41f7be..83d69cee1 100644 --- a/lib/autoplan-plan-text.ts +++ b/lib/autoplan-plan-text.ts @@ -10,6 +10,7 @@ */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { extractImplementationPlan, implementationForReview, sectionHeadings } from '../bin/gstack-autoplan-snapshot'; import { extractHeading } from './autoplan-prompts'; import type { RunState } from './autoplan-state'; @@ -79,3 +80,39 @@ export function driftLine(state: RunState, code: 'PLAN_CHANGED' | 'PLAN_SOURCE_C if (!current || current === reviewed) return undefined; return `${code}: ${source} differs from the reviewed plan; run gstack-autoplan revise --out ${state.out} --plan ${source}`; } + +/** The nearest directory at or above `dir` holding `.git` (no git spawn), or undefined. */ +export function repoRoot(dir: string): string | undefined { + for (let d = path.resolve(dir); ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return undefined; + } +} + +/** Line offset of `inner` inside `outer` (0 when it starts on line 1), or undefined when it is not a verbatim block of it. */ +function blockOffset(inner: string, outer: string): number | undefined { + const at = outer.indexOf(inner); + if (at >= 0) return outer.slice(0, at).split('\n').length - 1; + const restoreLine = /^<!-- \/autoplan restore point:[^\n]*\n/.exec(inner); + if (!restoreLine) return undefined; + const rest = blockOffset(inner.slice(restoreLine[0].length), outer); + return rest === undefined ? undefined : rest - 1; +} + +/** + * A canonical row's `file`/`line` as the owner reads them: an absolute path under the repository root becomes + * repo-relative; a phase snapshot or the active plan becomes the plan's own file name, its line mapped to the plan + * source through the snapshot's verbatim position (dropped when it cannot be mapped). The reviewer's bytes are untouched. + */ +export function normalizeFindingFile(f: { file?: string; line?: number }, ctx: { cwd: string; planFiles: string[]; planSource: string; planName: string }): { file?: string; line?: number } { + if (!f.file) return f; + const planFile = ctx.planFiles.find(p => f.file === p || (!path.isAbsolute(f.file!) && path.basename(p) === f.file)); + if (planFile) { + let offset: number | undefined; + try { offset = blockOffset(fs.readFileSync(planFile, 'utf8'), fs.readFileSync(ctx.planSource, 'utf8')); } catch { offset = undefined; } + return { file: ctx.planName, ...(f.line !== undefined && offset !== undefined ? { line: f.line + offset } : {}) }; + } + const root = path.isAbsolute(f.file) ? repoRoot(ctx.cwd) : undefined; + if (root && f.file.startsWith(root + path.sep)) return { ...f, file: path.relative(root, f.file).split(path.sep).join('/') }; + return f; +} diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts index d5f952bf4..3863ced13 100644 --- a/lib/autoplan-prompts.ts +++ b/lib/autoplan-prompts.ts @@ -112,7 +112,7 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c \`\`\`gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} \`\`\` - severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. + severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. 3. Write the whole result to this file: ${resultPath} `; @@ -177,6 +177,15 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri return { native, outside, nativeResult, outsideResult }; } +/** An attempt-owned file in the run directory: `<phase>-<voice>.<attempt>.md` (the reviewer writes it) or `.bound.md` (the sealed copy). */ +export const attemptFile = (phase: string, voice: string, shortAttempt: string, ext: 'md' | 'bound.md') => `${phase}-${voice}.${shortAttempt}.${ext}`; + +/** Point a prompt's RESULT FORMAT at the attempt's own result path, so a superseded reviewer never writes where its successor does. */ +export function bindPromptResult(promptFile: string, resultPath: string): void { + const text = fs.readFileSync(promptFile, 'utf8'); + fs.writeFileSync(promptFile, text.replace(/^3\. Write the whole result to this file: .*$/m, `3. Write the whole result to this file: ${resultPath}`)); +} + /** The installed review skill for a phase: this checkout's generated SKILL.md (same layout as ~/.claude/skills/gstack/<skill>/SKILL.md). */ export function reviewSkillFile(phase: Phase): string { const file = path.join(ROOT, REVIEW_SKILL[phase], 'SKILL.md'); diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts index d09e91a9a..48f5eb5e1 100644 --- a/lib/autoplan-reconcile.ts +++ b/lib/autoplan-reconcile.ts @@ -26,10 +26,12 @@ const SEVERITY_ALIASES: Record<string, Severity> = { export interface CanonicalFinding { severity: Severity; title: string; file?: string; line?: number; fix?: string; local_id?: string; user_challenge?: boolean; urgent?: boolean; suggested_owner?: string; plan_items?: string[]; dimension?: string; - /** What a user challenge changes; only these three make it a gate question (plan B2a). */ + /** What a user challenge changes: scope, cost or direction (plan B2a), or packaging/ordering (settled without a plan edit). */ changes?: Changes; } -export const CHANGES = ['scope', 'cost', 'direction'] as const; +export const CHANGES = ['scope', 'cost', 'direction', 'packaging', 'ordering'] as const; +/** Accepted changes of these kinds need no Implementation-plan edit: `revise --settle` records them. */ +export const SETTLEABLE_CHANGES: readonly string[] = ['packaging', 'ordering']; export type Changes = typeof CHANGES[number]; export interface ParsedReviewResult { receipt: { ok: boolean; phase?: string; sha256?: string; line?: string }; @@ -67,9 +69,9 @@ export function parseReceipt(text: string, expected: { phase: string; sha256: st function parseFences(text: string, errors: string[]): { findings: CanonicalFinding[]; fenceFound: boolean } { const findings: CanonicalFinding[] = []; let fenceFound = false; - for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\1[ \t]*$/gm)) { + for (const fence of text.matchAll(/^[ \t]*(`{3,}|~{3,})gstack-findings[ \t]*\r?\n(?:([\s\S]*?)\r?\n)??[ \t]*\1[ \t]*$/gm)) { fenceFound = true; - fence[2]!.split('\n').forEach((raw, i) => { + (fence[2] ?? '').split('\n').forEach((raw, i) => { const line = raw.trim(); if (!line) return; let row: any; @@ -186,7 +188,8 @@ export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; } as FindingRow)); } -export type ReconciliationDisposition = 'confirmed' | 'disagree' | 'new'; +/** `unverified`: the same file:line with no other shared evidence (titles unalike): associated, agreement unverified. */ +export type ReconciliationDisposition = 'confirmed' | 'unverified' | 'disagree' | 'new'; export interface ReconciliationRow { outside_id: string; native_id: string | null; disposition: ReconciliationDisposition; /** Why this disposition: the matched location or title, or the parent's override reason. */ @@ -206,13 +209,29 @@ function jaccard(a: Set<string>, b: Set<string>): number { return inter / (a.size + b.size - inter); } const level = (s: Severity) => SEVERITIES.indexOf(s); +/** A remedy compared as words: case, punctuation and spacing ignored. */ +export const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +const NEGATING = /\b(keep|leave|retain|not|no|never|without|avoid|remove|drop|revert|skip)\b|n't\b/; +/** Opposite remedies: they name the same thing and exactly one of them keeps, drops or negates it. */ +function contradictory(a: string, b: string): boolean { + const ka = remedyKey(a), kb = remedyKey(b); + if (NEGATING.test(ka) === NEGATING.test(kb)) return false; + const words = (k: string) => new Set(k.split(' ').filter(t => t.length > 3 && !NEGATING.test(t))); + const wb = words(kb); + return [...words(ka)].some(t => wb.has(t)); +} /** - * Structural reconciliation: a file:line match (±3 lines) is `confirmed` - * unless severities differ by two or more levels (`disagree`); otherwise a - * title overlap of at least half the words is `confirmed`; else `new`. Each - * native finding is matched at most once. Overrides replace the structural - * row for their outside id and must name ids from these two lists. + * Structural reconciliation. Findings associate by a title overlap of at least + * half the words, or by file:line (±3 lines). Association alone never + * confirms: a title association is `confirmed` only when both voices state the + * same remedy, `disagree` when severities differ by two or more levels or the + * remedies contradict (one keeps, drops or negates what the other changes), + * and otherwise `unverified` (remedies differ or one is missing); a + * location-only association is `unverified`; else `new`. + * Each native finding is matched at most once. Overrides (the parent's + * semantic reconciliation, with an optional shared `proposal`) replace the + * structural row for their outside id and must name ids from these two lists. */ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides: ReconciliationOverride[] = []): { rows: ReconciliationRow[]; native_only: string[]; errors: string[] } { const errors: string[] = []; @@ -222,7 +241,7 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides for (const o of overrides) { if (!outsideIds.has(o.outside_id)) { errors.push(`override names unknown outside id ${o.outside_id}`); continue; } if (o.native_id && !nativeIds.has(o.native_id)) { errors.push(`override for ${o.outside_id} names unknown native id ${o.native_id}`); continue; } - if (!['confirmed', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|disagree|new`); continue; } + if (!['confirmed', 'unverified', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|unverified|disagree|new`); continue; } if (o.disposition !== 'new' && !o.native_id) { errors.push(`override for ${o.outside_id}: ${o.disposition} needs a native_id`); continue; } if (typeof o.resolution !== 'string' || !o.resolution.trim()) { errors.push(`override for ${o.outside_id}: resolution is required`); continue; } byOutside.set(o.outside_id, o); @@ -238,30 +257,32 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides ...(typeof override.proposal === 'string' && override.proposal.trim() ? { proposal: override.proposal.trim() } : {}) }); continue; } + const near = (n: FindingRow) => !!o.file && o.line !== undefined && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line) <= 3; + const ot = tokens(o.title); let match: FindingRow | undefined; - let basis: ReconciliationRow['basis'] = 'none'; - if (o.file && o.line !== undefined) { - match = native.find(n => !used.has(n.id) && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line!) <= 3); - if (match) basis = 'file_line'; - } - if (!match) { - const ot = tokens(o.title); - let best = 0; - for (const n of native) { - if (used.has(n.id)) continue; - const score = jaccard(ot, tokens(n.title)); - if (score >= 0.5 && score > best) { best = score; match = n; } - } - if (match) basis = 'title'; + let best = 0; + for (const n of native) { + if (used.has(n.id)) continue; + const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0); + if (score >= 0.5 && score > best) { best = score; match = n; } } + const titled = !!match; + match ??= native.find(n => !used.has(n.id) && near(n)); if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; } used.add(match.id); - const delta = Math.abs(level(match.severity) - level(o.severity)); - rows.push({ - outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed', - resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'})` : `both voices (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'}): ${match.title}`, - basis, severity: { outside: o.severity, native: match.severity }, - }); + const basis: ReconciliationRow['basis'] = near(match) ? 'file_line' : 'title'; + const where = near(match) ? `${o.file}:${o.line}` : 'same title'; + if (!titled) { + rows.push({ outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same location, agreement unverified (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity } }); + continue; + } + const severity = { outside: o.severity, native: match.severity }; + const pair = (disposition: ReconciliationDisposition, resolution: string) => rows.push({ outside_id: o.id, native_id: match!.id, disposition, resolution, basis, severity }); + if (Math.abs(level(match.severity) - level(o.severity)) >= 2) { pair('disagree', `severity: native ${match.severity}, outside ${o.severity} (${where})`); continue; } + if (!match.resolution || !o.resolution) { pair('unverified', `same title, a remedy is missing (${where}): ${match.title}`); continue; } + if (contradictory(match.resolution, o.resolution)) { pair('disagree', `remedies conflict (${where}): native "${match.resolution}", outside "${o.resolution}"`); continue; } + if (remedyKey(match.resolution) !== remedyKey(o.resolution)) { pair('unverified', `same title, remedies differ (${where}): ${match.title}`); continue; } + pair('confirmed', `both voices, same remedy (${where}): ${match.title}`); } return { rows, native_only: native.filter(n => !used.has(n.id)).map(n => n.id), errors }; } @@ -271,6 +292,8 @@ export interface ConsensusRecord { native: { model: string | null; findings: number; by_severity: Record<Severity, number> } | null; outside: { model: string | null; provider: string | null; findings: number; by_severity: Record<Severity, number> } | null; confirmed: number; disagree: number; new: number; native_only: number; + /** Same location, agreement unverified (absent on records written before it existed). */ + unverified?: number; rows: ReconciliationRow[]; native_only_ids: string[]; /** CONFIRMED requires both voices completed. */ coverage: 'both' | 'native_only' | 'outside_only' | 'none'; @@ -288,7 +311,7 @@ export function consensusRecord(phase: string, native: { rows: FindingRow[]; mod schema_version: 1, phase, native: native ? { model: native.model ?? null, findings: native.rows.length, by_severity: bySeverity(native.rows) } : null, outside: outside ? { model: outside.model ?? null, provider: outside.provider ?? null, findings: outside.rows.length, by_severity: bySeverity(outside.rows) } : null, - confirmed: rows.filter(r => r.disposition === 'confirmed').length, disagree: rows.filter(r => r.disposition === 'disagree').length, + confirmed: rows.filter(r => r.disposition === 'confirmed').length, unverified: rows.filter(r => r.disposition === 'unverified').length, disagree: rows.filter(r => r.disposition === 'disagree').length, new: rows.filter(r => r.disposition === 'new').length, native_only: native && outside ? rec.native_only.length : (native?.rows.length ?? 0), rows, native_only_ids: native && outside ? rec.native_only : (native?.rows.map(r => r.id) ?? []), coverage: native && outside ? 'both' : native ? 'native_only' : outside ? 'outside_only' : 'none', @@ -306,7 +329,7 @@ export function renderConsensus(c: ConsensusRecord, findings: FindingRow[]): str ...SEVERITIES.map(s => ` ${s.padEnd(15)} ${String(c.native?.by_severity[s] ?? '—').padEnd(28)} ${c.outside?.by_severity[s] ?? '—'}`), ` Findings ${String(c.native?.findings ?? '—').padEnd(28)} ${c.outside?.findings ?? '—'}`, c.coverage === 'both' - ? `CONFIRMED ${c.confirmed} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` + ? `CONFIRMED ${c.confirmed} · SAME LOCATION, UNVERIFIED ${c.unverified ?? 0} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` : `Consensus: N/A (voice coverage: ${c.coverage.replace('_', ' ')}); confirmed counts require both voices.`, '', 'Reconciliation (each outside finding against the native result):', diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index f3452b52f..777416cc8 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -16,13 +16,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; -import { applyRenames, planReopen } from './autoplan-gate'; +import { applyRenames, approve, closeDraftDirections, planReopen } from './autoplan-gate'; +import { SETTLEABLE_CHANGES } from './autoplan-reconcile'; import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text'; import { parseScopeTokens } from './autoplan-scope'; import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts'; import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state'; -export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; cwd: string } +export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: string[]; cwd: string } export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] } const amendmentLine = (state: RunState) => `AMENDMENTS: pending=${pendingAmendments(state).map(a => a.decision).join(',') || 'none'}`; @@ -41,37 +42,81 @@ function revisionText(state: RunState, file: string): string { return diag.text ?? text; } -function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { +function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn' | 'settled', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { return ids.map(id => { const a = pendingAmendments(state).find(x => x.decision === id)!; a.status = status; a.settled_at = at; Object.assign(a, extra); - return { event: status === 'incorporated' ? 'amendment_incorporated' : 'amendment_withdrawn', decision: id, ...extra }; + return { event: `amendment_${status}`, decision: id, ...extra }; }); } +/** A `--settle`/`--incorporates`/`--withdraw` id: the decision id, or the short item id (`d4`) at the current gate_rev. */ +function resolveIds(state: RunState, ids: string[]): string[] { + return ids.map(id => pendingAmendments(state).find(a => a.decision === id || (a.item === id && a.gate_rev === state.gate_rev))?.decision ?? id); +} + +/** + * `--settle` evidence: a packaging/ordering change settles on the plan the run already reviews, never on an + * unrevised edit. Alone, the owner's plan file must hash to the active plan, and the active plan must be the one + * the owner accepted the change on or one a later `revise` recorded (plan_revised). With `--plan` (beside + * `--incorporates`), it settles on that revision's hash, which the reopened phases then review. + */ +function settleEvidence(state: RunState, ids: string[], revised?: string): { plan_sha256: string; changes: Record<string, string> } { + const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); + const changes: Record<string, string> = {}; + for (const id of ids) { + const a = pendingAmendments(state).find(x => x.decision === id)!; + if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3); + changes[id] = a.changes; + if (revised || !a.plan_sha256) continue; + const recorded = [a.plan_sha256, ...readJournal(state.out).filter(r => r.event === 'plan_revised' && r.ts >= a.at).map(r => r.new_plan_sha256 as string)]; + if (!recorded.includes(active)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the active plan ${active.slice(0, 12)} is neither the plan ${id} was accepted on (${a.plan_sha256.slice(0, 12)}) nor one revise recorded; revise --plan instead`, 3); + } + if (revised) return { plan_sha256: revised, changes }; + let input = ''; + try { input = state.plan_input && fs.existsSync(state.plan_input) ? reviewHash(revisionText(state, state.plan_input)) : active; } catch { /* unreadable: refused below */ } + if (input !== active) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the plan file has edits the run has not reviewed (active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan <file> --incorporates <ids> --settle ${ids.join(',')} instead`, 3); + return { plan_sha256: active, changes }; +} + +/** Every accepted change is discharged: the answered gate stands (complete) unless a revise reopened phases still to rerun. */ +function finishAmendments(state: RunState, at: string): string[] { + if (state.status !== 'amend_pending' || pendingAmendments(state).length) return []; + journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null }); + if (state.phase_order.some(p => !state.phases[p]?.closed_at)) { state.status = 'running'; return []; } + return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; +} + export function reviseRun(o: ReviseOptions): ReviseResult { const out = path.resolve(o.out); const state = readState(out); if (['complete', 'incomplete', 'refused'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; revise works on a running or gated run`, 3); + const amending = state.status === 'amend_pending'; const pending = pendingAmendments(state).map(a => a.decision); - for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) { + const incorporates = resolveIds(state, o.incorporates ?? []), withdraw = resolveIds(state, o.withdraw ?? []), settle = resolveIds(state, o.settle ?? []); + for (const id of [...incorporates, ...withdraw, ...settle]) { if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3); } const at = now(); if (!o.plan) { - for (const row of settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })) journal(out, row); + const evidence = settle.length ? settleEvidence(state, settle) : undefined; + for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row); + for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] }); + const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)]; writeState(state); - return { state, reopened: [], lines: [amendmentLine(state)] }; + return { state, reopened: [], lines }; } const file = path.resolve(o.plan); const text = revisionText(state, file); let newHash: string; try { newHash = reviewHash(text); } catch (e: any) { throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${e.message}`, 3); } const oldHash = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); - if (o.incorporates?.length && newHash === oldHash) { - throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${o.incorporates.join(', ')} needs the edit in ${file}`, 3); + if (incorporates.length && newHash === oldHash) { + throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3); } + if (settle.length && newHash !== oldHash && !incorporates.length) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}) and incorporates nothing; incorporate ${settle.join(', ')} instead of settling`, 3); + const evidence = settle.length ? settleEvidence(state, settle, newHash) : undefined; const k = (state.plan_revision ?? 0) + 1; const staging = path.join(out, `.revise-v${k}`); fs.rmSync(staging, { recursive: true, force: true }); @@ -87,8 +132,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash); const reopen = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0)); const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined; - const incorporated = o.incorporates ?? []; - journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates: incorporated, withdraw: o.withdraw ?? [] }); + journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : []; for (const p of added) state.phases[p] = {}; @@ -97,11 +141,19 @@ export function reviseRun(o: ReviseOptions): ReviseResult { if (reopen.length || added.length) state.status = 'running'; state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k; delete state.plan_reality; - const settled = [...settleAmendments(state, incorporated, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })]; + const settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }), + ...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] }))]; + if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : 'complete'; const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null }; fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n'); publish(state, staging, renames, committed, settled); - return { state, reopened: reopen, lines: [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)] }; + const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)]; + if (state.status === 'complete') { + lines.push(...closeDraftDirections(state, at.slice(0, 10)), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`); + exportRun(state); + writeState(state); + } + return { state, reopened: reopen, lines }; } /** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */ @@ -124,15 +176,16 @@ export function recoverRevise(out: string): string[] { const rows = readJournal(out); const intent = rows.filter(r => r.event === 'revise_intent').pop(); if (!intent || rows.some(r => (r.event === 'plan_revised' || r.event === 'revise_abandoned') && r.k === intent.k)) return []; - const stagedRun = path.join(intent.staging, FILES.run); + const staging = path.resolve(out, intent.staging); + const stagedRun = path.join(staging, FILES.run); if (!fs.existsSync(stagedRun)) { journal(out, { event: 'revise_abandoned', k: intent.k }); - fs.rmSync(intent.staging, { recursive: true, force: true }); + fs.rmSync(staging, { recursive: true, force: true }); return [`REVISE_RECOVERED: v${intent.k} abandoned (nothing was published)`]; } const stage = JSON.parse(fs.readFileSync(stagedRun, 'utf8')) as { state: RunState; renames: Array<[string, string]>; committed: Record<string, unknown>; settled: Array<Record<string, unknown>> }; fs.writeFileSync(path.join(out, FILES.run), JSON.stringify(stage.state, null, 2) + '\n'); const state = readState(out); - publish(state, intent.staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled); + publish(state, staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled); return [`REVISE_RECOVERED: v${intent.k} completed (reopened=${(stage.committed.reopened as string[]).join(',') || 'none'})`]; } diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 42995acdc..6346133b4 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -19,10 +19,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile'; -import { closePhase, finishGate } from './autoplan-gate'; +import { amendmentSteps, applyGuidance, closePhase, finishGate } from './autoplan-gate'; import { GSTACK_VERSION } from './autoplan-migrate'; -import { driftLine, reviewHash, scaffold } from './autoplan-plan-text'; -import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts'; +import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text'; +import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts'; import { recoverRevise } from './autoplan-revise'; import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state'; import { checkPlanReality, checkRealityRows, PLAN_REALITY_CHECKER } from './plan-reality'; @@ -167,7 +167,7 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void { ps.outside = state.outside_enabled ? { status: 'pending', attempts: [] } : notRun ? { status: 'disabled', outcome: 'not_run', reason: notRun.reason, detail: notRun.detail } : { status: 'disabled' }; ps.prompts = prompts; - journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts }); + journal(state.out, { event: 'phase_opened', phase, snapshot: snap.sha256, prompts: Object.fromEntries(Object.entries(prompts).map(([k, p]) => [k, path.relative(state.out, p)])) }); } export function nextStep(o: NextOptions): NextResult { @@ -178,7 +178,8 @@ export function nextStep(o: NextOptions): NextResult { if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; } const pending = pendingAmendments(state).map(a => a.decision); if (pending.length) { - throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')}; edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --withdraw <id>)`, 3); + const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; '); + throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); ${amendmentSteps(state, state.out)} (or --withdraw <id>)`, 3); } const phase = currentPhase(state); if (!phase) { finishGate(state); writeState(state); return result; } @@ -210,8 +211,9 @@ export function nextStep(o: NextOptions): NextResult { const estimate = o.estimateUsd ?? 0; try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); } catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; } - const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: prompts[voice === 'native' ? 'nativeResult' : 'outsideResult'], estimate_usd: estimate, dispatched_at: now() }; - journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate }); + const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate, dispatched_at: now() }; + bindPromptResult(row.prompt, row.result); + journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate }); (v.attempts ??= []).push(row); v.attempt = attempt; v.status = 'running'; result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true }); @@ -292,6 +294,10 @@ export function submitResult(o: SubmitOptions): SubmitResult { } const open = resolveAttempt(v, o.attempt, phase, o.voice); const file = (o.record ?? o.result)!; + const owner = new RegExp(`^${phase}-${o.voice}\\.(a\\d+)\\.md$`).exec(path.basename(file)); + if (owner && path.dirname(path.resolve(file)) === state.out && `${state.run}-${owner[1]}` !== open.attempt) { + throw new RunError('ATTEMPT_MISMATCH', `${path.basename(file)} is the result path of ${state.run}-${owner[1]}, not of the open attempt ${open.attempt} (result=${open.result})`, 3); + } if (!fs.existsSync(file)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${file}`); const text = fs.readFileSync(file, 'utf8'); let bind: ReturnType<typeof readRecord>; @@ -309,19 +315,24 @@ export function submitResult(o: SubmitOptions): SubmitResult { else if (modelFamily(model) === 'unknown') conflict = `native model ${JSON.stringify(model)} has no recognized family`; if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3); - // Bind: immutable copy, per-voice canonical rows, journal, ledger, manifest. Nothing above this line wrote. - const bound = open.result; + // Bind: a sealed copy of the reviewer's bytes under an attempt-owned name the reviewer never writes, per-voice + // canonical rows, journal, ledger, manifest. Nothing above this line wrote; a later edit of `file` changes nothing bound. + const bound = path.join(state.out, attemptFile(phase, o.voice, open.attempt.slice(state.run.length + 1), 'bound.md')); const tmp = `${bound}.${process.pid}.tmp`; - fs.writeFileSync(tmp, text); + fs.writeFileSync(tmp, text, process.platform === 'win32' ? {} : { mode: 0o444 }); fs.renameSync(tmp, bound); - const rows = canonicalRows(bind.findings, { run: state.run, phase, voice: o.voice, model }); + if (process.env.GSTACK_AUTOPLAN_FAULT === 'submit-after-seal') throw new Error('fault injected: submit interrupted after sealing (GSTACK_AUTOPLAN_FAULT, tests only)'); + const planFiles = [ps.snapshot_path, ps.snapshot_path && path.join(path.dirname(ps.snapshot_path), 'source-implementation.md'), state.active_plan].filter((p): p is string => !!p); + const pathCtx = { cwd: o.cwd, planFiles, planSource: state.plan_source, planName: path.basename(state.plan_input ?? state.plan_source) }; + const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model }); fs.writeFileSync(`${bound}.findings.jsonl`, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '')); const sha = sha256File(bound); + const provider = o.runner ?? 'host-subagent'; const receipt = { receipt: bind.receipt, findings_source: bind.source, reviewed_sha256: ps.snapshot, ...(bind.receipt === 'attested' ? { attest_reason: o.attest } : {}) }; - journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: bound, result_sha256: sha, model, runner: o.runner ?? null, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt }); + journal(state.out, { event: 'submitted', attempt: open.attempt, phase, voice: o.voice, result: path.basename(bound), result_sha256: sha, model, runner: provider, findings: rows.length, usage_usd: o.usageUsd ?? 'unknown', ...receipt }); settle(path.join(state.out, FILES.spend), open.attempt, o.usageUsd ?? 'unknown'); open.state = 'submitted'; open.terminal_at = now(); - Object.assign(v, { status: 'completed', model, provider: o.runner ?? (o.voice === 'native' ? 'host-subagent' : 'unknown'), output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>); + Object.assign(v, { status: 'completed', model, provider, output: path.basename(bound), result_sha256: sha, findings: rows.length, attempt: open.attempt, usage: { usd: o.usageUsd ?? 'unknown' }, ...receipt } satisfies Partial<VoiceState>); if (bind.receipt === 'attested') (state.deviations ??= []).push(`receipt attested: ${phase} ${o.voice} ${open.attempt} bound from a hand-run record without a reviewer receipt (snapshot ${ps.snapshot.slice(0, 12)}; reason: ${o.attest})`); state.artifacts[path.basename(bound)] = { path: path.basename(bound), sha256: sha }; if (o.voice === 'native') state.native_model = model; else state.outside_model = model; @@ -337,7 +348,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] { const close = closePhase(state, phase, { overridesFile: reconcile }); if (!currentPhase(state)) finishGate(state); else state.status = 'running'; - return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; + return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; } /** @@ -395,17 +406,18 @@ export function resumeRun(out: string): ResumeResult { if (terminal.has(r.attempt)) { // A submit that journaled but died before run.json: re-bind from the journal and the file on disk. const sub = rows.find(x => x.event === 'submitted' && x.attempt === r.attempt); - if (sub && a && a.state !== 'submitted' && fs.existsSync(sub.result) && sha256File(sub.result) === sub.result_sha256) { + const result = sub && path.resolve(out, sub.result); + if (sub && result && a && a.state !== 'submitted' && fs.existsSync(result) && sha256File(result) === sub.result_sha256) { a.state = 'submitted'; a.terminal_at = sub.ts; - Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(sub.result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt, + Object.assign(v!, { status: 'completed', model: sub.model, provider: sub.runner ?? 'unknown', output: path.basename(result), result_sha256: sub.result_sha256, findings: sub.findings, attempt: sub.attempt, ...(sub.receipt ? { receipt: sub.receipt, findings_source: sub.findings_source, reviewed_sha256: sub.reviewed_sha256, ...(sub.attest_reason ? { attest_reason: sub.attest_reason } : {}) } : {}) }); - state.artifacts[path.basename(sub.result)] = { path: path.basename(sub.result), sha256: sub.result_sha256 }; + state.artifacts[path.basename(result)] = { path: path.basename(result), sha256: sub.result_sha256 }; if (r.voice === 'native') state.native_model = sub.model; else state.outside_model = sub.model; rebound.push(r.attempt); } continue; } - if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: r.prompt, result: r.result, estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } + if (!a) { (v!.attempts ??= []).push({ attempt: r.attempt, state: 'execution_unknown', prompt: path.resolve(out, r.prompt), result: path.resolve(out, r.result), estimate_usd: r.estimate_usd ?? 0, dispatched_at: r.ts }); v!.status = 'running'; } else if (a.state === 'dispatched') a.state = 'execution_unknown'; else continue; journal(out, { event: 'execution_unknown', attempt: r.attempt, phase: r.phase, voice: r.voice, by: 'resume' }); @@ -416,7 +428,7 @@ export function resumeRun(out: string): ResumeResult { if (!ps?.snapshot || ps.closed_at) continue; if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {}); } - if (!currentPhase(state) && !['gate_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state); + if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state); else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') { const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v)))); state.status = anyOpen ? 'awaiting_result' : 'running'; @@ -437,9 +449,14 @@ export function statusLines(state: RunState): string[] { const v = (voice: Voice) => { const s = ps[voice]; return `${voice}=${s?.status ?? 'pending'}${s?.model ? `:${s.model}` : ''}${s?.attempt ? `@${s.attempt}` : ''}`; }; lines.push(`PHASE: ${p} ${ps.closed_at ? 'closed' : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`); } + for (const a of pendingAmendments(state)) lines.push(`AMEND_PENDING: ${a.decision} ${a.item ?? ''}${a.changes ? ` changes=${a.changes}` : ''} -> ${applyGuidance(a)}`); + for (const p of state.phase_order) for (const voice of ['native', 'outside'] as const) { + const open = state.phases[p]?.[voice] && openAttempt(state.phases[p]![voice]!); + if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`); + } if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend))))); return lines; } -export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'incomplete', 'refused', 'interrupted']; +export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted']; export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal }; export type { RunState, Voice, VoiceState, AttemptState }; diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts index 0085bc8fb..b7e48ca44 100644 --- a/lib/autoplan-state.ts +++ b/lib/autoplan-state.ts @@ -20,7 +20,13 @@ export interface VoiceState extends VoiceOutcome { receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string; } /** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */ -export interface Amendment { decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn'; at: string; settled_at?: string } +export interface Amendment { + decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string; + /** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */ + changes?: string; phase?: string; item?: string; + /** The reviewed Implementation-plan hash of the active plan when the owner accepted it: `--settle` evidence. */ + plan_sha256?: string; +} export interface PhaseState { snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string; native?: VoiceState; outside?: VoiceState; consensus?: Record<string, unknown>; skipped?: string; @@ -77,7 +83,7 @@ export function readState(out: string): RunState { state.out = path.resolve(out); if (previous && previous !== state.out) { const reroot = (p: string | undefined) => (p && p.startsWith(previous + path.sep) ? path.join(state.out, path.relative(previous, p)) : p); - state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!; + state.active_plan = reroot(state.active_plan)!; state.restore = reroot(state.restore)!; state.plan_source = reroot(state.plan_source)!; state.plan_input = reroot(state.plan_input); for (const ps of Object.values(state.phases)) { ps.snapshot_dir = reroot(ps.snapshot_dir); ps.snapshot_path = reroot(ps.snapshot_path); ps.methodology = reroot(ps.methodology); if (ps.prompts) for (const k of Object.keys(ps.prompts) as Array<keyof typeof ps.prompts>) ps.prompts[k] = reroot(ps.prompts[k])!; diff --git a/lib/doctor-components.ts b/lib/doctor-components.ts index da28cd071..f0aa9f5b6 100644 --- a/lib/doctor-components.ts +++ b/lib/doctor-components.ts @@ -11,8 +11,10 @@ * * Row semantics (printed as `PASS | FAIL | SKIP <id>`): * core always evaluated and always required (runtime, pins, state root, - * privacy, cores, revision). `pins` and `state-root` SKIP with a - * reason when they cannot be evaluated; a SKIP never fails a run. + * privacy, cores, revision, wrapper). `pins`, `state-root` and + * `wrapper` SKIP with a reason when they cannot be evaluated; a + * SKIP never fails a run. `wrapper` WARNs (an owner action, never + * a failure) for a stale Capy skill copy the run did not use. * host a host profile. Required when `--for`/`--require` names it, or by * default when it is installed from this checkout. With no flag and * no host installed, `claude` (setup's default host) is required so @@ -47,6 +49,7 @@ export const DOCTOR_COMPONENTS: readonly DoctorComponent[] = [ { id: 'privacy', title: 'privacy', kind: 'core' }, { id: 'cores', title: 'cores', kind: 'core' }, { id: 'revision', title: 'revision', kind: 'core' }, + { id: 'wrapper', title: 'Capy skill copy (bootstrapper revision)', kind: 'core' }, ] as const; export type ComponentId = (typeof DOCTOR_COMPONENTS)[number]['id']; diff --git a/lib/gate-list.ts b/lib/gate-list.ts index d5df5d009..680fbde5c 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -3,18 +3,21 @@ * A gate list is numbered items, each with a stable id, a recommended default * and lettered options; `auto` items take their recommendation when * unanswered, `approval` items stay pending until answered. One reply - * grammar: `all` (every item takes its recommendation), `<id><option>` tokens - * (`d3b uc1a`), or `all except <tokens>` (the same operation as listing the - * overrides, so there is no exclusion-vs-override ambiguity). Bare yes/no is - * ambiguous across multi-option items and is reported as unparsed, never - * guessed. A reply binds to the gate revision it answers (`gate_rev`). - * `all as-is` keeps every direction: each item takes its option that does not - * change the plan (`changes_plan: false`), so agreeing user challenges whose - * recommendation is the change can be declined in one token. Earlier answers - * at the same revision (`prior`) are kept unless a token names the item again: - * only unresolved items default. + * grammar: `rec` (every item takes its recommendation, plan changes + * included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>` + * (the same operation as listing the overrides). `all`, `approve all`, + * `accept all` and `take all recommendations` are silent aliases of `rec`; + * `as-is` (alias `all as-is`) keeps every direction: each item takes its + * option that does not change the plan (`changes_plan: false`). Bare yes/no + * is ambiguous across multi-option items and is reported as unparsed, never + * guessed. A reply binds to the gate revision it answers (`gate_rev`), and a + * `rec` reply to the recommendations it saw (`rec_hash`, bumped by + * `gstack-autoplan recommend`). Earlier answers at the same revision + * (`prior`) are kept unless a token names the item again: only unresolved + * items default. * Rendered by bin/gstack-gate; rows land in decisions.jsonl (lib/headless-artifacts.ts). */ +import { createHash } from 'node:crypto'; import type { DecisionRow } from './headless-artifacts'; /** `changes_plan`: choosing this option is an accepted plan change (reopens its phase plus Eng, prints APPLY:). */ @@ -24,22 +27,32 @@ export interface GateItem { phase?: string; cost?: string; why?: string; /** false: an agent decision; no option reopens a phase. */ reopens?: boolean; - /** Why `recommended` (e.g. `both voices; remedies shown`, `plan direction; voices disagree`). */ + /** Why `recommended` (e.g. `both voices; shared proposal`, `plan direction; voices disagree`). */ recommended_basis?: string; changes?: string; + /** Set when the relaying parent chose this recommendation (`gstack-autoplan recommend`); shown in the render. */ + recommended_by?: string; /** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */ origin?: string; + /** Advisory lines rendered under the item (PLAN_REALITY: a plan term with no number, arm list or rule body). */ + flags?: string[]; /** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */ carried?: { from: string; chosen: string; gate_rev: number }; } -export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string } +export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string; + /** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */ + rec_rev?: number } export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean } export interface ParsedReply { valid: boolean; gate_rev: number; reply: string; choices: GateChoice[]; pending: string[]; unparsed: string[]; + /** `rec`: the reply took the recommendations (any alias, with or without `except`). */ + mode?: 'rec' | 'as-is' | 'tokens'; error?: 'GATE_REV_STALE' | 'GATE_REPLY_UNPARSED' | 'GATE_EMPTY'; } export const PAGE_SIZE = 7; +/** Reply openers that take every recommendation: `rec` and its silent aliases. */ +const REC_ALIASES: string[][] = [['rec'], ['all'], ['approve', 'all'], ['accept', 'all'], ['take', 'all', 'recommendations']]; const ID_RE = /^[a-z]*[0-9]+$/; export function validateGateList(list: unknown): string[] { @@ -68,25 +81,17 @@ export function validateGateList(list: unknown): string[] { return problems; } -/** - * An approval item whose recommended option changes the plan (both voices - * recommend the change) is answered only by its own token: `all` must never - * flip the owner's stated direction on their behalf (field gate rev 1, d3). - */ -export function heldFromAll(item: GateItem): boolean { - return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true); +/** The recommendations a `rec` reply takes, as a short hash the render prints and `answer --rec-hash` checks. */ +export function recHash(list: GateList): string { + const recs = list.items.map(i => [i.id, i.recommended, i.recommended_basis ?? '', i.recommended_by ?? '']); + return createHash('sha256').update(JSON.stringify([list.gate_rev, list.rec_rev ?? 0, recs])).digest('hex').slice(0, 12); } -/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */ -export function resolveAll(list: GateList): Record<string, string> { - return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended])); -} - -/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */ +/** The `GATE_PENDING` hint: exact tokens per pending item, or `rec`. */ export function pendingHint(list: GateList, pending: string[]): string { const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i); const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' '); - return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`; + return `reply with ${tokens || 'p1a..p1e'} or rec`; } export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string { @@ -98,18 +103,19 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri lines.push(''); list.items.slice(start, start + pageSize).forEach((item, i) => { const n = start + i + 1; - lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); + lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); if (item.why) lines.push(` ${item.why}`); if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`); for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`); if (item.cost) lines.push(` cost: ${item.cost}`); + for (const flag of item.flags ?? []) lines.push(` flagged: ${flag}`); }); lines.push(''); - if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`.`); - lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).'); + if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`, or every page with \`--all\`. Relay every page.`); + lines.push('Reply with exactly one of: `rec` (default: take every recommendation, plan changes included) · `rec except <tokens>` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `as-is` (the plan as written).'); lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.'); - for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`); - lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`); + const rec = parseReply('rec', list); + lines.push(`Reply binds to gate_rev=${list.gate_rev} rec_hash=${recHash(list)}. \`rec\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, rec_hash: recHash(list), choices: Object.fromEntries(rec.choices.map(c => [c.id, c.chosen])) })}`); return lines.join('\n') + '\n'; } @@ -119,7 +125,7 @@ function exampleTokens(list: GateList): string { /** Tokenize a reply: lowercase, strip separators, merge `<id> <option>` pairs into one token. */ export function tokenize(reply: string, list: GateList): string[] { - const raw = reply.toLowerCase().replace(/[,;:=)]/g, ' ').split(/\s+/).filter(Boolean); + const raw = reply.toLowerCase().replace(/[,;:=)`"'\u2018\u2019\u201c\u201d]/g, ' ').split(/\s+/).filter(Boolean); const ids = new Set(list.items.map(i => i.id)); const out: string[] = []; for (let i = 0; i < raw.length; i++) { @@ -142,13 +148,14 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio const tokens = tokenize(reply, list); if (tokens.length === 0) return { ...base, error: 'GATE_EMPTY' }; - let all = false; + let rec = false; let asIs = false; let rest = tokens; - if (tokens[0] === 'all') { - all = true; - rest = tokens.slice(1); - if (rest[0] === 'as-is') { asIs = true; rest = rest.slice(1); } + const lead = REC_ALIASES.find(a => a.every((w, i) => tokens[i] === w)); + if (lead && tokens[lead.length] === 'as-is' && lead.length === 1 && lead[0] === 'all') { asIs = true; rest = tokens.slice(2); } + else if (lead) { rec = true; rest = tokens.slice(lead.length); } + else if (tokens[0] === 'as-is') { asIs = true; rest = tokens.slice(1); } + if (rec || asIs) { if (rest[0] === 'except') rest = rest.slice(1); else if (rest.length > 0) return { ...base, unparsed: rest, error: 'GATE_REPLY_UNPARSED' }; } @@ -173,11 +180,11 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) }); else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true }); else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); } - else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); + else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' }); else pending.push(item.id); } - return { ...base, valid: true, choices, pending }; + return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' }; } /** The option that keeps the plan's direction: the first with `changes_plan: false`; plan approval `a`; else the recommendation. */ @@ -205,14 +212,14 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}): /** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */ export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/; -export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] { +export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string, extra: Partial<DecisionRow> = {}): DecisionRow[] { if (!parsed.valid) return rows; const byId = new Map(parsed.choices.map(c => [c.id, c])); return rows.map(row => { const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, '')); if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row; return { - ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), + ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra, status: choice.how === 'override' ? 'overridden' : 'approved', }; }); diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 846cddaad..f39a19543 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -23,7 +23,7 @@ export type DecisionKind = 'auto' | 'approval' | 'user_challenge'; export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected'; export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done'; export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running'; -export type ResultStatus = 'complete' | 'gate_pending' | 'incomplete' | 'interrupted' | 'refused'; +export type ResultStatus = 'complete' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused'; export type RunStatus = ResultStatus | 'awaiting_result' | 'running'; export interface FindingRow { @@ -39,6 +39,8 @@ export interface DecisionRow { answered_by?: string; source?: string; cost?: string; /** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */ recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string; + /** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */ + owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string; } export interface TaskRow { schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[]; @@ -66,7 +68,7 @@ export interface PregateCheck { // C-pregate (C8): stage, the input identity the check ran against, the fix clause and result code, and per-item lines. stage?: 'preflight' | 'tests'; inputs?: Record<string, string>; fix?: string; code?: string; lines?: string[]; } -export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string } +export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string } export interface PregateFile { schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number; // C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict. @@ -81,11 +83,12 @@ export interface ShipReceipt { // C-pregate (C8): the pre-gate's verdict against this head and its remote obligations (publication requires their receipts). pregate?: string; requires_remote?: string[]; // C-pregate (C6): issue links the body carries, by intent. - issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> }; + // Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation. + issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> }; } -export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused']; -const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'refused', 'incomplete']; +export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; +const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete']; const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed']; const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed']; @@ -131,7 +134,7 @@ export const ARTIFACT_SCHEMAS = { voice: { enum: ['native', 'outside'] }, model: str, severity: { enum: ['Critical', 'High', 'Medium', 'Low', 'Informational'] }, title: str, file: str, line: { type: 'integer' }, disposition: { enum: ['open', 'accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'] }, resolution: str, plan_items: strList, native_counterpart: nullableStr, source: str, - changes: { enum: ['scope', 'cost', 'direction'] }, + changes: { enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }, }, }, decisions: { @@ -169,7 +172,7 @@ export const ARTIFACT_SCHEMAS = { required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'], properties: { schema_version: { const: 1 }, run: str, - status: { enum: ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, + status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, base: str, host: str, session_kind: str, required_phases: strList, phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} }, counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' }, @@ -185,7 +188,7 @@ export const ARTIFACT_SCHEMAS = { schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num, checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } }, head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str, - requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str } } }, + requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } }, }, }, 'ship-receipt': { @@ -196,7 +199,7 @@ export const ARTIFACT_SCHEMAS = { session_kind: str, artifacts_consumed: { ...str, description: '<n>/<m>' }, run: str, thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str, pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList, - issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } } } }, + issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, }, } as const satisfies Record<string, Schema>; diff --git a/lib/install-verdict.ts b/lib/install-verdict.ts index 4fd63fd85..a6973cb9c 100644 --- a/lib/install-verdict.ts +++ b/lib/install-verdict.ts @@ -4,15 +4,21 @@ * install-admission rule gstack-skill-start (Bash) and gstack-autoplan's * mutating verbs share. test/install-verdict.test.ts pins both sides. * - * Verdict file: <state root>/install-verdicts/<sha256(source checkout realpath)>.json, - * or the path the GSTACK_LAUNCH line names as GSTACK_INSTALL_VERDICT. + * Verdict file: <state root>/install-verdicts/<sha256(checkout realpath)>/<sha256(project realpath | "none")>.json, + * then the path the GSTACK_LAUNCH line names as GSTACK_INSTALL_VERDICT, then + * the schema 1 file keyed by the checkout alone (<sha256(checkout)>.json). + * The project is the working directory's git toplevel unless that is the + * checkout itself. It is the one record the bun shim, the Bash twin, + * lib/pregate/runtime.ts and admission read. * * Admission: applies to unattended and spawned sessions. It refuses (exit 3, * `status=refused`) in unattended sessions and explicit * GSTACK_SESSION_KIND=spawned sessions with GSTACK_CAPY=1 when the running Bun * is below gstack's supported minimum and, with GSTACK_CAPY=1 only, when the - * verdict is missing, failing, for another checkout or HEAD, or narrower than - * the workflow. Other spawned sessions only warn. + * verdict is missing, failing, for another checkout or HEAD, narrower than + * the workflow, for another project when the workflow runs the repo's tests, + * or records a Bun other than the one running (BUN_RESOLUTION_DRIFT). Other + * spawned sessions only warn. * GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and sets `overridden` (the caller * records it as a run deviation). */ @@ -25,7 +31,7 @@ import type { ResultCodeName } from './result-codes'; import { resolveStateRoot } from './state-root'; export interface InstallVerdict { - schema_version: 1; + schema_version: 1 | 2; checkout: string; head: string; for: string; @@ -36,6 +42,13 @@ export interface InstallVerdict { min_revision: string; launch_path: string; time: string; + /** Schema 2: the project realpath ('' for none) and the Bun resolution recorded for it. */ + project?: string; + pin?: string; + bun_path?: string; + bun_version?: string; + bun_rule?: string; + bun_install?: string; } export interface InstallAdmission { @@ -67,14 +80,40 @@ export function sourceRoot(gstackRoot: string): string { return fs.realpathSync(src); } -export function installVerdictPath(checkout: string, stateRoot: string): string { - return path.join(stateRoot, 'install-verdicts', `${createHash('sha256').update(checkout).digest('hex')}.json`); +const sha256 = (text: string) => createHash('sha256').update(text).digest('hex'); + +/** The verdict for (checkout, project); `project` null or '' is "none". */ +export function installVerdictPath(checkout: string, stateRoot: string, project: string | null = null): string { + return path.join(stateRoot, 'install-verdicts', sha256(checkout), `${sha256(project || 'none')}.json`); +} + +/** The schema 1 verdict keyed by the checkout alone (read-only fallback). */ +export function legacyInstallVerdictPath(checkout: string, stateRoot: string): string { + return path.join(stateRoot, 'install-verdicts', `${sha256(checkout)}.json`); +} + +/** The project a verdict is keyed by for `cwd`: its git toplevel realpath, null outside a repo or inside the checkout. */ +export function installProject(checkout: string, cwd: string = process.cwd()): string | null { + const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 }); + const top = r.status === 0 ? (r.stdout ?? '').trim() : ''; + if (!top) return null; + try { const real = fs.realpathSync(top); return real === checkout ? null : real; } catch { return null; } +} + +/** The verdict file admission reads: (checkout, project), then GSTACK_INSTALL_VERDICT, then the schema 1 file. */ +export function findInstallVerdict(checkout: string, project: string | null, env: Env): string { + const stateRoot = resolveStateRoot(env); + const keyed = installVerdictPath(checkout, stateRoot, project); + if (fs.existsSync(keyed)) return keyed; + if (env.GSTACK_INSTALL_VERDICT && fs.existsSync(env.GSTACK_INSTALL_VERDICT)) return env.GSTACK_INSTALL_VERDICT; + const legacy = legacyInstallVerdictPath(checkout, stateRoot); + return fs.existsSync(legacy) ? legacy : keyed; } export function readInstallVerdict(file: string): InstallVerdict | null { try { const v = JSON.parse(fs.readFileSync(file, 'utf8')); - return v && v.schema_version === 1 ? v as InstallVerdict : null; + return v && (v.schema_version === 1 || v.schema_version === 2) ? v as InstallVerdict : null; } catch { return null; } @@ -91,18 +130,37 @@ function supportedBun(gstackRoot: string): string { return text.match(/^GSTACK_BUN_TESTED="([0-9.]+)"/m)?.[1] ?? '0.0.0'; } -function verdictProblem(skill: string, gstackRoot: string, env: Env): string { +export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: string } + +/** Why the install verdict for (checkout, project of `cwd`) does not admit `skill`; null when it does. `bunPath` '' skips the running-Bun check. */ +export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null { const src = sourceRoot(gstackRoot); - const file = env.GSTACK_INSTALL_VERDICT || installVerdictPath(src, resolveStateRoot(env)); + const project = installProject(src, cwd); + const file = findInstallVerdict(src, project, env); const v = readInstallVerdict(file); - if (!v) return `no install verdict at ${file} for ${src}`; - if (!v.ok) return `the install verdict says: ${v.trailer}`; - if (v.checkout !== src) return `the install verdict is for checkout ${v.checkout}, not ${src}`; - const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout?.trim() ?? ''; - if (!head || v.head !== head) return `the install verdict is for another HEAD (${v.head.slice(0, 12)}; ${src} is at ${head.slice(0, 12)})`; - if (v.pins_planning_only && !PINS_ADVISORY_SKILLS.includes(skill)) return `the install verdict is planning-only (pins skipped for ${v.for}) and ${skill} runs the repo's tests`; + const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' }); + if (!v) return problem(`no install verdict at ${file} for ${src}`); + if (!v.ok) return problem(`the install verdict says: ${v.trailer}`); + if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`); + const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? ''; + if (!head || v.head !== head) return problem(`the install verdict is for another HEAD (${v.head.slice(0, 12)}; ${src} is at ${head.slice(0, 12)})`); + const advisory = PINS_ADVISORY_SKILLS.includes(skill); + if (v.pins_planning_only && !advisory) return problem(`the install verdict is planning-only (pins skipped for ${v.for}) and ${skill} runs the repo's tests`); + if (v.schema_version !== 1 && !advisory && (v.project ?? '') !== (project ?? '')) { + return { ...problem(`the install verdict checked ${v.project || 'no project'}, not ${project ?? 'this directory (no project)'}, and ${skill} runs the repo's tests`), + fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}, then launch with the GSTACK_LAUNCH line it prints` }; + } const missing = (SKILL_REQUIREMENTS[skill]?.requires ?? []).find(id => !v.required.includes(id)); - return missing ? `the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs` : ''; + if (missing) return problem(`the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs`); + if (v.bun_path && bunPath && bunPath !== v.bun_path) { + return { detail: `bun ${bunVersion} at ${bunPath} is running, but the install verdict recorded bun ${v.bun_version} at ${v.bun_path}`, code: 'BUN_RESOLUTION_DRIFT', + fix: `launch with the GSTACK_LAUNCH line (PATH=${path.dirname(v.bun_path)}:$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for ${skill}) to see what changed` }; + } + return null; +} + +function realBunPath(): string { + try { return fs.realpathSync(process.execPath); } catch { return process.execPath; } } /** @@ -110,7 +168,7 @@ function verdictProblem(skill: string, gstackRoot: string, env: Env): string { * `gstackRoot` (the directory holding bin/). `bunVersion` defaults to the Bun * running this process; `env` to process.env. */ -export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; env?: Env; bunVersion?: string }): InstallAdmission { +export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; env?: Env; bunVersion?: string; bunPath?: string; cwd?: string }): InstallAdmission { const env = opts.env ?? process.env; const kind = env.GSTACK_SESSION_KIND === 'unattended' || env.GSTACK_SESSION_KIND === 'spawned' ? env.GSTACK_SESSION_KIND : env.OPENCLAW_SESSION ? 'spawned' : 'other'; @@ -126,10 +184,10 @@ export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; detail = `bun ${bun} is below the supported minimum ${minimum}`; fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}), then launch with its GSTACK_LAUNCH line`; } else if (capy) { - detail = verdictProblem(opts.skill, opts.gstackRoot, env); - if (detail) { - status = 'verdict'; code = 'INSTALL_UNVERIFIED'; - fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}) on this machine, then launch with the GSTACK_LAUNCH line it prints`; + const p = verdictProblem(opts.skill, opts.gstackRoot, env, opts.bunPath ?? (opts.bunVersion ? '' : realBunPath()), bun, opts.cwd); + if (p) { + status = p.code === 'BUN_RESOLUTION_DRIFT' ? 'runtime' : 'verdict'; code = p.code; detail = p.detail; + fix = p.fix ?? `run the Capy skill installer (scripts/install.sh --for ${opts.skill}) on this machine, then launch with the GSTACK_LAUNCH line it prints`; } } const base = { ...none, applies: true, status, detail, code }; diff --git a/lib/issue-links.ts b/lib/issue-links.ts index df71ba4f1..fa90293ca 100644 --- a/lib/issue-links.ts +++ b/lib/issue-links.ts @@ -11,13 +11,30 @@ * (`owner/repo#N`) are `Refs #N` — never auto-closed. * - A closure issue that cannot be read through gstack-issue-guard is * downgraded to `Refs #N` with ISSUE_UNREADABLE (never Fixes). - * - Failing CI run ids map to bot-opened flake issues whose body names the - * run id: `Flake: #N (run <id>)`. + * - Same-repo references are promotion candidates: the owner's `fix #N` + * reply becomes `--issue N`; unattended runs keep them as Refs. + * - Failing CI run ids map to bot-opened flake issues of this repository + * that name the run (their `flake:json` first_seen.run_id or this repo's + * actions/runs/<id> URL) and a test (`flake:json` file › test or a + * `Flake: <test>` title): `Flake: #N (run <id>)`. Duplicate test names + * keep the lowest issue number. + * - Open PRs by other authors that close an issue this PR fixes are + * `Related: #N (@author)`; `Supersedes #N` and `Contributed by @author` + * (lib/contributor-mode.ts credit lines) only with the owner's + * confirmation (--supersedes / --credit). */ export type IssueIntent = 'fixes' | 'refs'; export interface IssueMention { issue: number; intent: IssueIntent; repo?: string; source: string } -export interface IssueLinks { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; cross: string[]; unreadable: number[] } +export interface FlakeLink { issue: number; run: string; test?: string } +export interface RelatedPr { pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean } +export interface IssueLinks { + fixes: number[]; refs: number[]; flakes: FlakeLink[]; cross: string[]; unreadable: number[]; related?: RelatedPr[]; +} +/** One `gh issue list --json number,title,body,author` row. */ +export interface IssueRow { number: number; title?: string; body?: string; author?: { login?: string; is_bot?: boolean } } +/** One `closedByPullRequestsReferences` node of an issue this PR fixes. */ +export interface ClosingPr { number: number; state?: string; headRefName?: string; author?: { login?: string } | null; repository?: { nameWithOwner?: string } } const CLOSURE = /^\s*(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+(?:#|GH-)(\d+)\s*$/i; const PARTIAL = /\b(?:partial(?:ly)?|part of|defer(?:red)?|follow-?up|related|refs?|see|context)\b/i; @@ -44,7 +61,7 @@ export function mentionsIn(message: string, source = 'commit'): IssueMention[] { * Resolve the final link set. `readable(n)` is the gstack-issue-guard probe * for a closure candidate; a false answer downgrades it to Refs. */ -export function resolveLinks(mentions: IssueMention[], explicit: number[], readable: (issue: number) => boolean, flakes: Array<{ issue: number; run: string }> = []): IssueLinks { +export function resolveLinks(mentions: IssueMention[], explicit: number[], readable: (issue: number) => boolean, flakes: FlakeLink[] = [], related: RelatedPr[] = []): IssueLinks { const fixes = new Set<number>(explicit); const refs = new Set<number>(); const cross = new Set<string>(); @@ -57,9 +74,88 @@ export function resolveLinks(mentions: IssueMention[], explicit: number[], reada for (const n of fixes) refs.delete(n); for (const f of flakes) refs.delete(f.issue); const asc = (a: number, b: number) => a - b; - return { fixes: [...fixes].sort(asc), refs: [...refs].sort(asc), flakes: [...flakes].sort((a, b) => a.issue - b.issue), cross: [...cross].sort(), unreadable: unreadable.sort(asc) }; + return { + fixes: [...fixes].sort(asc), refs: [...refs].sort(asc), flakes: [...flakes].sort((a, b) => a.issue - b.issue), cross: [...cross].sort(), unreadable: unreadable.sort(asc), + ...(related.length ? { related } : {}), + }; } +/** Same-repo references the owner may promote with `fix #N` (unreadable closure issues excluded), with the first line naming each. */ +export function promotionCandidates(mentions: IssueMention[], links: IssueLinks): Array<{ issue: number; source: string }> { + const out: Array<{ issue: number; source: string }> = []; + for (const m of mentions) { + if (!m.repo && links.refs.includes(m.issue) && !links.unreadable.includes(m.issue) && !out.some(c => c.issue === m.issue)) out.push({ issue: m.issue, source: m.source }); + } + return out.sort((a, b) => a.issue - b.issue); +} + +const isBot = (author: IssueRow['author']): boolean => author?.is_bot === true || /^app\/|\[bot\]$/.test(author?.login ?? ''); +const FLAKE_JSON = /<!--\s*flake:json\s*-->\s*```json\s*\r?\n([\s\S]*?)\r?\n```/; +const RUN_URL = /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/runs\/(\d+)(?!\d)/g; + +/** The test an issue is a flake of and the runs it names; undefined test when it names none. */ +function flakeIdentity(row: IssueRow): { test?: string; jsonRun?: string } { + const block = FLAKE_JSON.exec(row.body ?? ''); + if (block) { + try { + const j = JSON.parse(block[1]!) as { file?: unknown; test?: unknown; first_seen?: { run_id?: unknown } }; + if (typeof j.file === 'string' && j.file) { + return { test: typeof j.test === 'string' && j.test ? `${j.file} › ${j.test}` : j.file, jsonRun: j.first_seen?.run_id === undefined ? undefined : String(j.first_seen.run_id) }; + } + } catch { /* an unparsable block names no test; the title may */ } + } + const title = /^flake:\s*(\S.*)$/i.exec((row.title ?? '').trim()); + return { test: title?.[1] }; +} + +/** + * The open issues a `"<run>" in:body` search returned, narrowed to bot-opened + * flake issues of `repo` that name this run and a test. Every other row is + * rejected with its reason; two issues for the same test keep the lowest number. + */ +export function selectFlakes(run: string, repo: string, rows: IssueRow[]): { flakes: FlakeLink[]; rejected: Array<{ issue: number; reason: string }> } { + const flakes: FlakeLink[] = []; + const rejected: Array<{ issue: number; reason: string }> = []; + for (const row of [...rows].sort((a, b) => a.number - b.number)) { + if (!isBot(row.author)) { rejected.push({ issue: row.number, reason: `opened by @${row.author?.login ?? 'unknown'}, not a bot` }); continue; } + const { test, jsonRun } = flakeIdentity(row); + if (!test) { rejected.push({ issue: row.number, reason: 'names no test (no flake:json file, no "Flake: <test>" title)' }); continue; } + const urls = [...(row.body ?? '').matchAll(RUN_URL)].filter(m => m[2] === run).map(m => m[1]!); + if (jsonRun !== run && !urls.some(u => u.toLowerCase() === repo.toLowerCase())) { + rejected.push({ issue: row.number, reason: urls.length ? `names run ${run} of ${[...new Set(urls)].join(', ')}, not ${repo}` : `does not name run ${run} of ${repo}` }); + continue; + } + const first = flakes.find(f => f.test === test); + if (first) { rejected.push({ issue: row.number, reason: `duplicate of #${first.issue} (${test})` }); continue; } + flakes.push({ issue: row.number, run, test }); + } + return { flakes, rejected }; +} + +/** + * Open PRs by other authors whose closing set overlaps `fixes`. `closing` + * maps each fixed issue to the PRs that close it; this branch's own PR and the + * viewer's PRs are excluded. A PR in another repository keeps its repo. + */ +export function relatedPrs(closing: Map<number, ClosingPr[]>, ctx: { repo: string; viewer: string; branch?: string }): RelatedPr[] { + const byKey = new Map<string, RelatedPr>(); + for (const [issue, prs] of closing) { + for (const pr of prs) { + const prRepo = pr.repository?.nameWithOwner; + const same = !prRepo || prRepo.toLowerCase() === ctx.repo.toLowerCase(); + const author = pr.author?.login ?? 'ghost'; + if ((pr.state ?? 'OPEN') !== 'OPEN' || author.toLowerCase() === ctx.viewer.toLowerCase() || (same && ctx.branch && pr.headRefName === ctx.branch)) continue; + const key = `${same ? '' : prRepo}#${pr.number}`; + const row = byKey.get(key) ?? { pr: pr.number, ...(same ? {} : { repo: prRepo }), author, closes: [] }; + if (!row.closes.includes(issue)) row.closes.push(issue); + byKey.set(key, row); + } + } + return [...byKey.values()].map(r => ({ ...r, closes: r.closes.sort((a, b) => a - b) })).sort((a, b) => (a.repo ?? '').localeCompare(b.repo ?? '') || a.pr - b.pr); +} + +const prId = (r: RelatedPr) => `${r.repo ?? ''}#${r.pr}`; + /** The `## Issues` lines for the PR body; an empty set renders nothing. */ export function renderIssueLines(links: IssueLinks): string[] { const lines: string[] = []; @@ -67,10 +163,26 @@ export function renderIssueLines(links: IssueLinks): string[] { for (const n of links.refs) lines.push(links.unreadable.includes(n) ? `Refs #${n} (unreadable through gstack-issue-guard; not auto-closed)` : `Refs #${n}`); for (const c of links.cross) lines.push(`Refs ${c}`); for (const f of links.flakes) lines.push(`Flake: #${f.issue} (run ${f.run})`); + for (const r of links.related ?? []) { + lines.push(r.supersedes ? `Supersedes ${prId(r)}` : `Related: ${prId(r)} (@${r.author})`); + if (r.credit) lines.push(`Contributed by @${r.author}`); + } return lines; } -/** The receipt's `issues` field (lib/headless-artifacts.ts ship-receipt schema). */ -export function receiptIssues(links: IssueLinks): { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> } { - return { fixes: links.fixes, refs: links.refs, flakes: links.flakes }; +/** Where each added line came from, for the PR-body preview (one `<line> ← <source>` per Related/Supersedes/Contributed/Flake line). */ +export function lineSources(links: IssueLinks): string[] { + const out: string[] = []; + for (const f of links.flakes) out.push(`Flake: #${f.issue} (run ${f.run}) ← bot flake issue for ${f.test ?? 'an unnamed test'}`); + for (const r of links.related ?? []) { + const closes = `open PR closing ${r.closes.map(n => `#${n}`).join(', ')}`; + out.push(r.supersedes ? `Supersedes ${prId(r)} ← ${closes} by @${r.author}; owner confirmed --supersedes` : `Related: ${prId(r)} (@${r.author}) ← ${closes}`); + if (r.credit) out.push(`Contributed by @${r.author} ← ${prId(r)}, ${closes}; owner confirmed --credit`); + } + return out; +} + +/** The receipt's `issues` field (lib/headless-artifacts.ts ship-receipt schema); `related` only when a PR is listed. */ +export function receiptIssues(links: IssueLinks): { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: RelatedPr[] } { + return { fixes: links.fixes, refs: links.refs, flakes: links.flakes.map(f => ({ issue: f.issue, run: f.run })), ...(links.related ? { related: links.related } : {}) }; } diff --git a/lib/plan-reality.ts b/lib/plan-reality.ts index fdb2911da..ff92eaac5 100644 --- a/lib/plan-reality.ts +++ b/lib/plan-reality.ts @@ -203,10 +203,11 @@ export interface RealityCheck { * measured/costs/takes/took. Receipts, versions and ids are removed before matching, so they never count. */ const MEASURED_RE = /\$\s?\d|\b\d[\d,]*(?:\.\d+)?\s?[kKM]?\s?(?:ms|s|%|USD|tokens?|MB|GB)(?![\w])|\b\d[\d,]*(?:\.\d+)?\s?[×x](?![\w])|\b(?:measured|costs?|takes|take|took)\b\W+(?:[A-Za-z]+\W+){0,3}?\$?\d/i; -const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g'); +const ITEM_REF_RE = /\b(?:items?|sections?)\s+\d+(?:\s*[–-]\s*\d+)?(?:\s*(?:,|and)\s*\d+(?:\s*[–-]\s*\d+)?)*|§\s?\d+(?:\.\d+)*/; +const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|${ITEM_REF_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g'); const EXEMPT_TAG_RE = /\[(?:estimated|re-measure before gate)\]/i; const SCRIPT_RE = /(?:^|[\s(`"'])(?:\.?[\w-]+\/)*[\w.-]+\.(?:ts|js|mjs|cjs|sh|py|rb|go)\b|(?:^|[\s(`"'])(?:bin|scripts)\/[\w.-]+/; -const COMMAND_RE = /`[^`]*\s[^`]*`/; +const COMMAND_RE = /`(?!\s*\$?\d)[^`]*\s[^`]*`/; const RULE_CLAIM_RE = /\b(?:decision rule|rules? (?:was |were )?written first|pre-?registered rule)\b/i; const QUOTED_RULE_RE = /"[^"]{8,}"|\u201c[^\u201d]{8,}\u201d/; const LINK_RE = /(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+/; @@ -228,23 +229,32 @@ export function splitClauses(text: string): string[] { return out.map(c => c.trim()).filter(Boolean); } -/** Clauses of one line that state a measured number and carry no receipt of their own. */ -export function unreceiptedNumbers(line: string): string[] { - return splitClauses(line).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' ')) +const QUOTED_SPAN_RE = /"([^"]+)"|\u201c([^\u201d]+)\u201d/g; +const BACKTICK_SPAN_RE = /`[^`]*`/g; + +/** + * Clauses of one line that state a measured number and carry no receipt of their own. With `quotedFrom` (the + * reviewed plan, reviewer scope only), a span quoted verbatim from it is the plan's number, not the reviewer's claim. + */ +export function unreceiptedNumbers(line: string, quotedFrom?: string): string[] { + const text = quotedFrom ? line.replace(QUOTED_SPAN_RE, (span, a, b) => (quotedFrom.includes((a ?? b) as string) ? '""' : span)) : line; + return splitClauses(text).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' ')) && !RECEIPT_RE.test(c) && !SCRIPT_RE.test(c) && !COMMAND_RE.test(c) && !EXEMPT_TAG_RE.test(c)); } -/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. */ -function ruleUnquoted(lines: string[], i: number): boolean { +/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. Plan scope ignores phrases inside backticks (a phrase named, not claimed). */ +function ruleUnquoted(lines: string[], i: number, scope: 'plan' | 'review' = 'review'): boolean { const line = lines[i]!; - if (!RULE_CLAIM_RE.test(line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false; + if (!RULE_CLAIM_RE.test(scope === 'plan' ? line.replace(BACKTICK_SPAN_RE, ' ') : line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false; const after = lines.slice(i + 1).find(l => l.trim()); return !after || !/^\s*(?:```|~~~|>)/.test(after); } const verdictFor = (ok: boolean, sessionKind?: string): RealityVerdict => (ok ? 'complete' : sessionKind === 'unattended' ? 'incomplete' : 'repair'); -export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string } +export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string; + /** The reviewed plan snapshot: a number inside a span quoted verbatim from it needs no receipt of its own. */ + planText?: string } /** Deterministic: which required rows the review carries. The verdict word depends on the session kind only. */ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCheck { @@ -267,7 +277,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh if (metric && l.metrics[metric] !== undefined) metrics[metric] = l.metrics[metric]!; } const rows = lines.filter(l => rowById(l.id) && l.status !== 'n/a'); - const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest)); + const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest, o.planText)); const rawLines = reviewText.split('\n'); const unquoted = rows.filter(l => ruleUnquoted(rawLines, l.line - 1)).map(l => rawLines[l.line - 1]!.trim()); const verdict = verdictFor(missing.length === 0 && unreceipted.length === 0 && numbers.length === 0 && unquoted.length === 0, o.sessionKind); @@ -283,7 +293,7 @@ export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCh } /** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */ -export const PLAN_REALITY_CHECKER = 'plan-reality/2'; +export const PLAN_REALITY_CHECKER = 'plan-reality/3'; export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string } /** @@ -299,13 +309,40 @@ export function checkPlanReality(planText: string, o: { sessionKind?: string } = if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; } if (fence || /^\s*<!--/.test(line)) return; numbers.push(...unreceiptedNumbers(line)); - if (ruleUnquoted(lines, i)) unquoted.push(line.trim()); + if (ruleUnquoted(lines, i, 'plan')) unquoted.push(line.trim()); }); const verdict = verdictFor(numbers.length === 0 && unquoted.length === 0, o.sessionKind); return { checker: PLAN_REALITY_CHECKER, unreceipted_numbers: numbers, unquoted, numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), verdict, line: `PLAN_REALITY: scope=plan unreceipted_numbers=${numbers.length} unquoted=${unquoted.length} verdict=${verdict}` }; } +const VAGUE_RE = /\b(minimum (?:detectable )?effect|margins?|decision rule|dollar cost|cost in dollars|USD cost)\b/i; +const DETAIL_AFTER_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\||```|~~~|>)/; + +/** + * PLAN_REALITY flags for the gate (advisory, never blocking): a plan line naming a minimum effect, a margin, a + * decision rule or a dollar cost with no number on it (item references and phrases in backticks do not count) + * and no arm list, table, quoted rule or block right after it. `line` is 1-based in `planText`. + */ +export function vaguePlanTerms(planText: string): Array<{ phrase: string; line: number; text: string }> { + const lines = planText.split('\n'); + const out: Array<{ phrase: string; line: number; text: string }> = []; + let fence: string | null = null; + lines.forEach((raw, i) => { + const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)?.[1]; + if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; } + if (fence || /^\s*<!--/.test(raw)) return; + const prose = raw.replace(BACKTICK_SPAN_RE, ' '); + const m = VAGUE_RE.exec(prose); + if (!m) return; + if (/\d/.test(prose.replace(NOT_A_QUANTITY_RE, ' ')) || QUOTED_RULE_RE.test(prose)) return; + const after = lines.slice(i + 1).find(l => l.trim()); + if (after && DETAIL_AFTER_RE.test(after)) return; + out.push({ phrase: m[1]!.toLowerCase(), line: i + 1, text: raw.trim() }); + }); + return out; +} + /** The example line every template quotes; one grammar, stated once. */ export const REALITY_LINE_SHAPE = 'REALITY: <row> pass|finding|n/a <summary> <file:line>'; diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts index b74d964a7..dc28c8144 100644 --- a/lib/pregate/checks/guards.ts +++ b/lib/pregate/checks/guards.ts @@ -2,8 +2,9 @@ * pregate check `guards` (tier 2, preflight; field wave CD1): the repo's own * mechanical gates a lane must pass before it reports done. Each * `.gstack/pregate.json` guard `{ id, when, command, fix?, timeout_s? }` - * whose `when` globs match a touched file runs in its own process (bash, - * under the verified launch Bun, with its own timeout). A failing guard + * whose `when` globs match a touched file runs in its own process group + * (lib/pregate/supervise.ts: bash, under the verified launch Bun, output to a + * log file, with its own timeout and the stall timeout). A failing guard * fails pregate naming the guard and its fix; a timed-out one is * `incomplete`; without --allow-repo-commands the selected guards are listed * and the check is `incomplete`. Guards are never downgradable. Field @@ -11,7 +12,6 @@ * passed both the isolation check and pregate; `test/cli-goldens.test.ts` * spawns the CLI as a subprocess, so no import graph selects it. */ -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -19,21 +19,21 @@ import { matchesAny } from '../../ship-policy'; import type { Guard } from '../config'; import { baseInputs, sha256 } from '../context'; import { launchRuntime } from '../runtime'; +import { readRange, supervise } from '../supervise'; import type { CheckDef, CheckResult, PregateContext } from '../types'; -interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; tail: string[]; log: string } +interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string } -function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): GuardRun { +async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise<GuardRun> { const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs; - const r = spawnSync('bash', ['-c', `set -o pipefail; ${guard.command}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024, env: { ...env, CI: env.CI ?? '1' } }); - const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`.trim(); const log = path.join(dir, `${guard.id}.log`); - fs.writeFileSync(log, `$ ${guard.command}\n${output}\n`); - const timedOut = r.status === null; - return { guard, exit: r.status, timedOut, tail: output ? output.split('\n').slice(-15) : [], log }; + fs.writeFileSync(log, ''); + const r = await supervise({ command: guard.command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + timeoutMs, stallMs: ctx.stallTimeoutMs, label: `guard ${guard.id}`, progress: ctx.progress }); + const output = readRange(log, r.start, r.end).trim(); + return { guard, exit: r.exit, timedOut: r.outcome !== 'exited', stalled: r.outcome === 'stalled', tail: output ? output.split('\n').slice(-15) : [], log }; } -function run(ctx: PregateContext): CheckResult { +async function run(ctx: PregateContext): Promise<CheckResult> { const touched = ctx.touched.flatMap(t => (t.renamedFrom ? [t.path, t.renamedFrom] : [t.path])); const selected = ctx.config.guards.filter(g => touched.some(f => matchesAny(g.when, f))); const inputs = baseInputs(ctx, { guards: sha256(selected.map(g => `${g.id}\0${g.command}`).join('\n')) }); @@ -45,11 +45,13 @@ function run(ctx: PregateContext): CheckResult { return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', lines: listed, detail: `${selected.length} guard(s) not run (pass --allow-repo-commands): ${selected.map(g => g.id).join(', ')}`, fix: 'rerun with --allow-repo-commands (what a lane runs before reporting done, and what /ship passes)' }; } const runtime = launchRuntime(ctx.repoRoot, ctx.env); + if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] }; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-guards-')); - const runs = selected.map(g => runGuard(ctx, g, runtime.env, dir)); + const runs: GuardRun[] = []; + for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir)); const lines = [`runtime: ${runtime.label}`]; for (const r of runs) { - lines.push(r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`); + lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`); if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`)); } const failed = runs.filter(r => !r.timedOut && r.exit !== 0); @@ -57,7 +59,7 @@ function run(ctx: PregateContext): CheckResult { const withRuns = { ...base, lines, inputs: { ...inputs, runtime: runtime.label } }; const fixOf = (rs: GuardRun[]) => rs.map(r => `${r.guard.id}: ${r.guard.fix ?? `read ${r.log}, fix the failure, rerun gstack-pregate --only guards`}`).join('; '); if (failed.length) return { ...withRuns, status: 'fail', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) }; - if (timedOut.length) return { ...withRuns, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) timed out: ${timedOut.map(r => r.guard.id).join(', ')}`, fix: 'raise the guard\'s timeout_s or --timeout; a timed-out guard never passes' }; + if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' }; return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` }; } diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 5d8b13944..4d76453c1 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -8,15 +8,15 @@ * Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green), * p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun). */ -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { baseInputs, sha256 } from '../context'; import { selectLanes, type Selection } from '../lane-selection'; import { launchRuntime } from '../runtime'; +import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise'; import { matchesAny } from '../../ship-policy'; -import type { CheckDef, CheckResult, PregateContext } from '../types'; +import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types'; /** The test command with `{files}`; detected from the repo when the config does not name one. */ export function detectRunner(ctx: PregateContext, files: readonly string[]): string | null { @@ -49,32 +49,52 @@ export function parseSummary(output: string): RunnerSummary { return { ran: null, failed: null }; } -interface LocalRun { exit: number | null; ran: number | null; failed: number | null; log: string; tail: string[]; command: string } +interface LocalRun { exit: number | null; outcome: Supervised['outcome']; ran: number | null; failed: number | null; tail: string[]; command: string; files: string[]; lastFile: string | null; ms: number } -function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[], log: string): LocalRun { - const command = runner.replace('{files}', files.map(f => `'${f.replace(/'/g, `'\\''`)}'`).join(' ')); - const r = spawnSync('bash', ['-c', `set -o pipefail; ${command}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: ctx.timeoutMs, maxBuffer: 256 * 1024 * 1024, env: { ...env, CI: env.CI ?? '1' } }); - const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`; - fs.appendFileSync(log, `$ ${command}\n${output}\n`); +const quote = (f: string) => `'${f.replace(/'/g, `'\\''`)}'`; + +async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[], log: string, counter: Counter): Promise<LocalRun> { + const command = runner.replace('{files}', files.map(quote).join(' ')); + const r = await supervise({ command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${files.length === 1 ? files[0] : `${files.length} files`})`, files, adapter: adapterFor(runner), counter, progress: ctx.progress }); + const output = readRange(log, r.start, r.end); const summary = parseSummary(output); - return { exit: r.status, ...summary, log, tail: output.trim().split('\n').slice(-25), command }; + return { exit: r.exit, outcome: r.outcome, ...summary, tail: output.trim().split('\n').slice(-25), command, files, lastFile: r.lastFile, ms: r.ms }; } /** * Unit files in one runner process, then each serial-pattern file in its * own (a serial file shares process-wide state: a module mock, an env - * mutation). The first non-zero or timed-out run decides the exit; counts - * add up only when every run reported a summary. + * mutation). `--timeout` bounds each runner process; the stall timeout + * catches a silent one. A stalled or timed-out group is killed and the next + * one runs. The first unfinished, then the first non-zero run decides the + * exit; counts add up only when every run reported a summary. */ -function runLocal(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[]): LocalRun & { serial: number } { +async function runLocal(ctx: PregateContext, env: NodeJS.ProcessEnv, runner: string, files: string[]) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-lanes-')); const log = path.join(dir, 'lanes.log'); + fs.writeFileSync(log, ''); const serial = files.filter(f => matchesAny(ctx.config.serial_patterns, f)); const unit = files.filter(f => !serial.includes(f)); - const runs = [...(unit.length ? [unit] : []), ...serial.map(f => [f])].map(group => runOnce(ctx, env, runner, group, log)); + const counter: Counter = { done: 0, total: files.length }; + const runs: LocalRun[] = []; + for (const group of [...(unit.length ? [unit] : []), ...serial.map(f => [f])]) { + runs.push(await runOnce(ctx, env, runner, group, log, counter)); + } const bad = runs.find(r => r.exit === null) ?? runs.find(r => r.exit !== 0); const sum = (k: 'ran' | 'failed') => (runs.some(r => r[k] === null) ? null : runs.reduce((n, r) => n + (r[k] as number), 0)); - return { exit: bad ? bad.exit : 0, ran: sum('ran'), failed: sum('failed'), log, tail: (bad ?? runs[runs.length - 1]!).tail, command: runs.map(r => r.command).join(' && '), serial: serial.length }; + return { runs, bad, exit: bad ? bad.exit : 0, ran: sum('ran'), failed: sum('failed'), log, tail: (bad ?? runs[runs.length - 1]!).tail, command: runs.map(r => r.command).join(' && '), serial: serial.length }; +} + +/** Why the local lane did not finish: a stall, a runner process past --timeout or a launch failure, with the last file started. */ +function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix'> { + const stalled = r.runs.filter(x => x.outcome === 'stalled'); + const timedOut = r.runs.filter(x => x.outcome === 'timeout'); + const rest = r.failed ? `; ${r.failed} failed` : ''; + if (stalled.length) { + return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` }; + } + if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' }; + return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' }; } function describe(sel: Selection): string[] { @@ -84,7 +104,77 @@ function describe(sel: Selection): string[] { return lines; } -function run(ctx: PregateContext): CheckResult { +export const SMALL_MACHINE_CORES = 8; +const GSTACK_ROOT = path.resolve(import.meta.dir, '..', '..', '..'); + +/** + * The small-machine obligation: the repo's declared `remote_gate`, else the + * configured test backend running this selection through gstack's Ubicloud + * runner (that command prints no PREGATE_REMOTE line, so only `clear` with a + * run URL discharges it). + */ +function remoteObligation(ctx: PregateContext, sel: Selection): RemoteObligation { + const declared = ctx.config.remote_gate; + const runner = detectRunner(ctx, sel.localTests); + const command = declared ?? `${GSTACK_ROOT}/scripts/ubicloud/ubi-runner.sh run --src ${quote(ctx.repoRoot)} -- ${quote(runner ? runner.replace('{files}', sel.localTests.map(quote).join(' ')) : '<test runner> <selected files>')}`; + const [workflow, job] = declared ? REMOTE_GATE_LANE.split('/') as [string, string] : ['test_backend', ctx.testBackend]; + return { lane: `${workflow}/${job}`, platform: 'remote', workflow, job, tests: sel.localTests, receipt: null, command }; +} + +export const REMOTE_GATE_LANE = 'pregate.json/remote_gate'; +/** The `clear` command for an obligation: the remote gate's own output for remote_gate, a run URL otherwise. */ +export const clearHint = (lane: string) => `gstack-pregate clear --remote ${lane} ${lane === REMOTE_GATE_LANE ? '--receipt <gate output file>' : '--run-url <url>'}`; +export interface RemoteReport { tree: string; tests: number; failed: number; spent?: string } + +/** The last `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]` line in a remote gate's output. */ +export function parseRemoteReport(output: string): RemoteReport | null { + const m = [...output.matchAll(/^PREGATE_REMOTE: tree=(\S+) tests=(\d+) failed=(\d+)(?: spent=(\d+(?:\.\d+)?))?\s*$/gm)].pop(); + return m ? { tree: m[1]!, tests: Number(m[2]), failed: Number(m[3]), spent: m[4] } : null; +} + +/** Why a remote gate report does not clear the obligation for `tree` (`--remote` and `clear --receipt`), or null when it does. */ +export function remoteReportProblem(report: RemoteReport, tree: string): string | null { + if (report.tree !== tree) return `the remote gate tested tree ${report.tree.slice(0, 12)}, not ${tree.slice(0, 12)}`; + if (report.tests === 0) return 'the remote gate reported 0 tests'; + if (report.failed > 0) return `the remote gate reported ${report.failed} failed of ${report.tests}`; + return null; +} + +/** + * `--remote`: run the declared remote gate now, under the spend cap. The + * gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and + * must print `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`; + * the obligation clears only when that tree is the tested tree, tests > 0, + * failed = 0 and the gate exited 0. Anything else leaves it uncleared and + * the check incomplete. + */ +async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> { + const lines = base.lines ?? []; + if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' }; + const obligation = remoteObligation(ctx, sel); + const cap = ctx.remote!.spendCapUsd; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-')); + const log = path.join(dir, 'remote.log'); + const filesFile = path.join(dir, 'files.txt'); + fs.writeFileSync(log, ''); + fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n'); + const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress }); + const output = readRange(log, r.start, r.end); + const reported = parseRemoteReport(output); + const spent = reported?.spent; + const spend = `spend: spent ${spent ? `$${spent}` : '$0'}, reserved $0, unknown ${spent ? '$0' : `$${cap} (no spent= reported; counted at the cap)`} (cap $${cap})`; + ctx.progress(`PREGATE_REMOTE_SPEND: ${spend.slice('spend: '.length)}`); + const runLines = [...lines, `remote gate: ${obligation.command}`, `log: ${log}`, spend, ...output.trim().split('\n').slice(-15).map(l => ` ${l}`)]; + const inputs = { ...base.inputs, command: sha256(obligation.command!) }; + const why = r.outcome !== 'exited' ? `the remote gate did not finish (${r.outcome === 'stalled' ? `no output for ${ctx.stallTimeoutMs / 1000} s` : r.outcome})` + : !reported ? `the remote gate exited ${r.exit} without a PREGATE_REMOTE: line` + : remoteReportProblem(reported, ctx.wtree) ?? (r.exit !== 0 ? `the remote gate exited ${r.exit}` : null); + if (why) return { ...base, inputs, lines: runLines, status: 'incomplete', remote: [...sel.remote, obligation], code: r.outcome === 'stalled' ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${why}; requires-remote ${obligation.lane} stays uncleared`, fix: `read ${log}; the obligation clears only on PREGATE_REMOTE: tree=${ctx.wtree.slice(0, 12)}… tests>0 failed=0 with exit 0` }; + const cleared = { ...obligation, receipt: `remote-gate tree=${ctx.wtree.slice(0, 12)} tests=${reported!.tests}`, cleared_by: `gstack-pregate --remote ${new Date().toISOString()}` }; + return { ...base, inputs, lines: runLines, status: 'requires-remote', remote: [...sel.remote, cleared], detail: `remote gate ${obligation.lane}: ${reported!.tests} tests, 0 failed on tree ${ctx.wtree.slice(0, 12)}; ${spend}` }; +} + +async function run(ctx: PregateContext): Promise<CheckResult> { const sel = selectLanes(ctx); ctx.explain.push(...sel.provenance); for (const [lane, set] of Object.entries(sel.laneSets)) ctx.explain.push(`lane ${lane}: ${set === '?' ? 'file set unknown' : `${set.length} files`}`); @@ -95,15 +185,22 @@ function run(ctx: PregateContext): CheckResult { return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: 'add a test that names the file, or declare its lane (or []) under dependencies in .gstack/pregate.json' }; } if (sel.tests.length === 0) return { ...base, detail: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files' }; + if (ctx.remote && sel.localTests.length) return runRemote(ctx, sel, base); + const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local'); + if (smallMachine) { + const obligation = remoteObligation(ctx, sel); + return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands` }; + } if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' }; if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` }; const runner = detectRunner(ctx, sel.localTests); if (!runner) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `no test runner detected for ${sel.localTests.length} selected file(s)`, fix: 'declare `runner` (with {files}) in .gstack/pregate.json' }; const runtime = launchRuntime(ctx.repoRoot, ctx.env); - const r = runLocal(ctx, runtime.env, runner, sel.localTests); - const runLines = [...lines, `runtime: ${runtime.label}`, `ran: ${r.command.length > 200 ? `${r.command.slice(0, 200)}…` : r.command}`, `log: ${r.log}`, ...r.tail.map(l => ` ${l}`)]; + if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] }; + const r = await runLocal(ctx, runtime.env, runner, sel.localTests); + const runLines = [...lines, `runtime: ${runtime.label}`, `progress: ${adapterFor(runner) ?? 'none (no progress adapter for this runner; serial files and heartbeats only)'}`, `ran: ${r.command.length > 200 ? `${r.command.slice(0, 200)}…` : r.command}`, `log: ${r.log}`, ...r.tail.map(l => ` ${l}`)]; const withRun = { ...base, lines: runLines, inputs: { ...inputs, command: sha256(r.command), runtime: runtime.label } }; - if (r.exit === null) return { ...withRun, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `local lane timed out after ${ctx.timeoutMs} ms (${sel.localTests.length} files)`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' }; + if (r.exit === null) return { ...withRun, status: 'incomplete', ...unfinished(ctx, r, sel.localTests.length) }; if (r.exit !== 0) return { ...withRun, status: 'fail', code: 'PREGATE_LANE_FAILED', detail: `local lane exited ${r.exit} (${r.failed ?? '?'} failed of ${r.ran ?? '?'} ran; ${sel.localTests.length} files; pipefail)`, fix: `read ${r.log}, fix the in-branch failure, rerun gstack-pregate --stage tests` }; if (r.ran === 0) return { ...withRun, status: 'fail', code: 'PREGATE_LANE_FAILED', detail: `ZERO-RUN: the runner reported 0 tests for ${sel.localTests.length} selected file(s)`, fix: 'a bad selector, a missing file or a skipped describe; exit 0 is not a pass' }; const detail = `${r.ran ?? 'count unavailable'} tests ran in ${sel.localTests.length} file(s)${r.serial ? ` (${r.serial} serial)` : ''}, exit 0${sel.remote.length ? `; ${sel.remote.length} requires-remote obligation(s)` : ''}`; diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index af6dd4bd5..5e202ab7d 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -22,6 +22,11 @@ * the pre-push hook and `gstack-pregate sealed`, not by `run`) * price_file the call-plan data file `gstack-eval-plan price-gate` prices * a paid gate from (GSTACK_EVAL_CALLS v1; never executed) + * stall_timeout_s seconds without test output before the lanes check kills + * the runner's process group (default 300; 0 disables) + * remote_gate the repo's remote test gate command: under 8 cores the test + * tier is a requires-remote obligation naming it, and + * `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`) */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -45,14 +50,16 @@ export interface PregateConfig { guards: Guard[]; serial_patterns: string[]; price_file: string | null; + stall_timeout_s: number | null; + remote_gate: string | null; } export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number } -export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null }; +export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; export interface ConfigError { code: ResultCodeName; path: string; message: string } const MAX_LIST = 256; const MAX_COMMAND = 512; -const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed']; +const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; const MAX_GUARDS = 32; const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s']; @@ -104,6 +111,13 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] const why = typeof obj.price_file === 'string' ? containedRelative(obj.price_file) : 'must be a repo-relative path or null'; if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'price_file', message: why }); } + if ('stall_timeout_s' in obj && obj.stall_timeout_s !== null && (!Number.isInteger(obj.stall_timeout_s) || (obj.stall_timeout_s as number) < 0 || (obj.stall_timeout_s as number) > 86_400)) { + errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'stall_timeout_s', message: 'must be an integer number of seconds from 0 (disabled) to 86400, or null' }); + } + if ('remote_gate' in obj && obj.remote_gate !== null) { + const why = commandError(obj.remote_gate); + if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'remote_gate', message: why }); + } if ('guards' in obj) guardErrors(obj.guards, errors); if ('sealed' in obj) for (const e of sealedErrors(obj.sealed)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: e.path, message: e.message }); if ('serial_patterns' in obj) { @@ -162,7 +176,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []): const errors = validateConfig(raw, knownChecks); if (errors.length) return { config: DEFAULT_CONFIG, errors }; const o = raw as Partial<PregateConfig>; - return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null }, errors: [] }; + return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; } export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string }; @@ -214,5 +228,7 @@ export function explainConfig(): string { `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`, 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', + 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', + 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index dfbc592a1..29212d8fb 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -9,6 +9,8 @@ */ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { wtreeFingerprint, isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen'; import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config'; import type { CheckResult, PregateContext, TouchedFile } from './types'; @@ -18,6 +20,17 @@ import type { RegistryError } from '../regen'; export interface BuildContextOptions { repoRoot: string; base: string; policyFrom: 'base' | 'head'; allowRepoCommands: boolean; timeoutMs?: number; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; knownChecks?: readonly string[]; + /** `--stall-timeout`; default the config's `stall_timeout_s`, else 300 s. */ + stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null; +} + +export const DEFAULT_STALL_TIMEOUT_S = 300; +const GSTACK_CONFIG = path.resolve(import.meta.dir, '..', '..', 'bin', 'gstack-config'); + +/** `gstack-config get test_backend` (local | ubicloud); `local` when unreadable. */ +export function readTestBackend(env: NodeJS.ProcessEnv): string { + const r = spawnSync(GSTACK_CONFIG, ['get', 'test_backend'], { encoding: 'utf8', timeout: 15_000, env, stdio: ['ignore', 'pipe', 'ignore'] }); + return (r.status === 0 && (r.stdout ?? '').trim()) || 'local'; } export type ContextLoad = { ctx: PregateContext } | { refused: { code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID'; message: string; errors?: Array<ConfigError | RegistryError> } }; @@ -66,11 +79,17 @@ export function buildContext(o: BuildContextOptions): ContextLoad { const wtree = wtreeFingerprint(o.repoRoot); if (!wtree) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: 'cannot fingerprint the working tree (gstack-wtree failed)' } }; const platform = o.platform ?? process.platform; + const env = o.env ?? process.env; + const config = configLoad.config as PregateConfig; return { ctx: { repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase), - config: configLoad.config as PregateConfig, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, - lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, env: o.env ?? process.env, explain: [], + config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, + stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000, + progress: o.progress ?? (line => process.stderr.write(`${line}\n`)), + cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env), policyFrom: o.policyFrom, remote: o.remote ?? null, + env, explain: [], }, }; } @@ -84,13 +103,14 @@ export function baseInputs(ctx: PregateContext, extra: Record<string, string> = /** * Run one check under the before/after fingerprint (bin/gstack-evidence:394): - * a tree that changed while the check ran, a thrown error or a timeout is - * `incomplete`. Wall time is measured here so every row carries `ms`. + * a tree that changed while the check ran, a thrown error or rejected promise + * or a timeout is `incomplete`. Wall time is measured here so every row + * carries `ms`. */ -export function withFingerprint(ctx: PregateContext, id: string, stage: CheckResult['stage'], fn: () => CheckResult): CheckResult { +export async function withFingerprint(ctx: PregateContext, id: string, stage: CheckResult['stage'], fn: () => CheckResult | Promise<CheckResult>): Promise<CheckResult> { const started = Date.now(); let result: CheckResult; - try { result = fn(); } + try { result = await fn(); } catch (e: any) { result = { id, stage, status: 'incomplete', detail: `threw: ${e?.message ?? e}`, code: 'PREGATE_INCOMPLETE', fix: 'read the error and rerun the check', inputs: baseInputs(ctx) }; } diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index f54bd0353..76b5f7d73 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -10,12 +10,13 @@ * land: `guards` (field wave CD1); ratchets, lint, links, hermetic, patches * and integrator later. */ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateRemote, type ValidationError } from '../headless-artifacts'; import { wtreeFingerprint } from '../regen'; import { guardsCheck } from './checks/guards'; -import { lanesCheck } from './checks/lanes'; +import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem } from './checks/lanes'; import { literalsCheck } from './checks/literals'; import { regenCheck } from './checks/regen'; import { secretsCheck } from './checks/secrets'; @@ -40,16 +41,18 @@ export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: C return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` }; } -export function runPregate(ctx: PregateContext, o: RunOptions): PregateFile { +export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<PregateFile> { const selected = CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id))); const checks: CheckResult[] = []; const clocks: Record<Stage, number> = { preflight: 0, tests: 0 }; for (const def of selected) { const started = Date.now(); - const result = applyDowngrades(ctx, withFingerprint(ctx, def.id, def.stage, () => def.run(ctx)), def); + const result = applyDowngrades(ctx, await withFingerprint(ctx, def.id, def.stage, () => def.run(ctx)), def); clocks[def.stage] += Date.now() - started; checks.push({ ...result, stage: def.stage }); } + const tests = checks.filter(c => c.stage === 'tests'); + if (tests.length) ctx.progress(`PREGATE: tests ${verdictOf({ schema_version: 1, tree: ctx.wtree, checks: tests, requires_remote: tests.flatMap(c => c.remote ?? []) })} in ${(clocks.tests / 1000).toFixed(1)}s — ${tests.map(c => `${c.id}: ${c.detail ?? c.status}`).join('; ')}`); const remote: PregateRemote[] = checks.flatMap(c => c.remote ?? []).map(r => ({ ...r })); const file: PregateFile = { schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(), @@ -82,7 +85,10 @@ export function renderTable(file: PregateFile, opts: { lines?: boolean } = { lin file.stage1_ms !== undefined ? `stage 1 (mechanical preflight): ${file.stage1_ms} ms` : null, file.stage2_ms !== undefined ? `stage 2 (test execution): ${file.stage2_ms} ms` : null, ].filter(Boolean); - const remote = (file.requires_remote ?? []).map(r => `requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) — ${r.receipt ? `cleared by ${r.receipt}` : 'uncleared; clear with gstack-pregate clear --remote ' + r.lane + ' --run-url <url>'}`); + const remote = (file.requires_remote ?? []).map(r => { + const run = r.command ? `run ${r.command.length > 200 ? `${r.command.slice(0, 200)}… (full command in pregate.json)` : r.command}, then ` : ''; + return `requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) — ${r.receipt ? `cleared by ${r.receipt}` : `uncleared; ${run}clear with ${clearHint(r.lane)}`}`; + }); return [ `stage check status time detail`, ...rows, ...body, ...clocks, ...remote, @@ -129,6 +135,41 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow = wtr return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared }; } +export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string } +export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string }; + +/** + * What `clear` may record for one obligation. A `pregate.json/remote_gate` + * obligation needs the gate's own report (`--receipt <file>` holding its + * PREGATE_REMOTE: line), validated as `--remote` validates it against the + * tested tree; a run URL is not checkable there. A `test_backend/<backend>` + * obligation has no machine-checkable receipt, so its run URL is recorded as + * attested. Platform lanes take a run URL or an evidence-bundle lane label. + */ +export function clearDecision(file: PregateFile, lane: string, input: ClearInput): ClearDecision { + const list = file.requires_remote ?? []; + const target = list.find(r => r.lane === lane); + if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` }; + if (target.lane === REMOTE_GATE_LANE) { + if (!input.receiptPath) return { refused: 'usage', message: `${lane} clears only with --receipt <file> holding the remote gate's PREGATE_REMOTE: tree=… tests=… failed=… line (a run URL is not checkable); or rerun gstack-pregate --remote` }; + let text: string; + try { text = fs.readFileSync(input.receiptPath, 'utf8'); } + catch (e: any) { return { refused: 'PREGATE_RECEIPT_INVALID', message: `cannot read ${input.receiptPath}: ${e.message}` }; } + const report = parseRemoteReport(text); + const problem = report ? remoteReportProblem(report, file.tree) : `${input.receiptPath} has no PREGATE_REMOTE: line`; + if (problem) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${problem}; ${lane} stays uncleared` }; + return { receipt: `remote-gate tree=${file.tree.slice(0, 12)} tests=${report!.tests} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false }; + } + if (input.receiptPath) return { refused: 'usage', message: `--receipt applies only to ${REMOTE_GATE_LANE}; clear ${lane} with --run-url <url>${target.workflow === 'test_backend' ? '' : ' or --bundle-lane <label>'}` }; + if (target.workflow === 'test_backend') { + if (!input.runUrl) return { refused: 'usage', message: `${lane} clears with --run-url <url>` }; + return { receipt: `attested:${input.runUrl}`, attested: true }; + } + const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined); + if (!receipt) return { refused: 'usage', message: `${lane} clears with --run-url <url> or --bundle-lane <label>` }; + return { receipt, attested: false }; +} + /** Record the receipt that clears one obligation (a run URL or an evidence-bundle lane label). */ export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string): PregateFile { const list = file.requires_remote ?? []; diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index 491bf7e56..c8a46ff54 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -1,38 +1,190 @@ /** - * pregate/runtime — the Bun a repo command runs under (field wave CD1, the - * TypeScript side of `gstack_bun_launch_dir` in bin/gstack-bun-version.sh). - * On a Capy cloud host the image's Bun is first on PATH and older than both - * gstack's minimum and most project pins, while the verified Bun the - * installer left in `$BUN_INSTALL/bin` or `~/.bun/bin` is off PATH; the same - * tree then passes or fails its lanes depending on the shell. The launch dir - * is the first of those two holding a Bun that satisfies gstack's own - * `engines.bun` and the project's `engines.bun`; when there is one, the - * lanes runner and every guard run with it first on PATH. + * pregate/runtime — the Bun a repo command runs under, and the TypeScript twin + * of the one Bun resolution contract (`gstack_bun_resolve` in + * bin/gstack-bun-version.sh; test/bun-resolve.test.ts runs both on one + * layout). Candidates, first eligible wins: the project's exact pin and + * gstack's install default in the versioned runtime store + * (`<state root>/runtimes/bun/<version>/bun`), `$BUN_INSTALL/bin`, + * `~/.bun/bin`, then every `bun` on PATH. Eligible: it runs, is at or above + * gstack's supported minimum, and satisfies gstack's own `engines.bun` and + * every project pin (lib/runtime-pins.ts); a file carrying the bun shim's + * marker is never a candidate. + * + * The install verdict for (checkout, project) is the one record install, + * the shim and admission share. Under the admission condition (GSTACK_CAPY=1, + * or a verdict exists for this checkout and project) it is validated as + * workflow admission validates it and the lanes run the Bun it recorded; an + * invalid verdict (INSTALL_UNVERIFIED) or a recorded Bun that is gone, changed + * or no longer satisfies the pins (BUN_RESOLUTION_DRIFT) makes the lanes and + * guards incomplete before anything launches. Otherwise the verdict check is + * skipped with an explain line (contributors, CI). */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { satisfies } from '../runtime-pins'; +import { findInstallVerdict, installProject, readInstallVerdict, sourceRoot, verdictProblem } from '../install-verdict'; +import { resultCodeSuffix } from '../result-codes'; +import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins'; +import { resolveStateRoot } from '../state-root'; -const GSTACK_PACKAGE = path.resolve(import.meta.dir, '..', '..', 'package.json'); +type Env = NodeJS.ProcessEnv; + +const GSTACK_ROOT = path.resolve(import.meta.dir, '..', '..'); +const VERSIONS = fs.readFileSync(path.join(GSTACK_ROOT, 'bin/gstack-bun-version.sh'), 'utf8'); +const shellValue = (name: string) => VERSIONS.match(new RegExp(`^${name}="([^"]*)"`, 'm'))?.[1] ?? ''; + +export const BUN_TESTED = shellValue('GSTACK_BUN_TESTED'); +export const BUN_DEFAULT = shellValue('GSTACK_BUN_DEFAULT'); +export const BUN_VERIFIED = shellValue('GSTACK_BUN_VERIFIED').split(/\s+/).filter(Boolean); +export const BUN_SHIM_MARKER = shellValue('GSTACK_BUN_SHIM_MARKER'); + +/** `gstack_bun_status <v> = ok`: parseable and not below the supported minimum (a prerelease of it is below). */ +export function bunStatusOk(version: string): boolean { + const m = version.replace(/^v/, '').replace(/\+.*$/, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + const min = BUN_TESTED.split('.').map(Number); + if (!m) return false; + for (let i = 0; i < 3; i++) if (Number(m[i + 1]) !== min[i]) return Number(m[i + 1]) > min[i]; + return !m[4]; +} + +/** `gstack_bun_install_version`: GSTACK_BUN_INSTALL_VERSION or the default; null when not a verified Bun at or above the minimum. */ +export function bunInstallVersion(env: Env): string | null { + const v = env.GSTACK_BUN_INSTALL_VERSION || BUN_DEFAULT; + return BUN_VERIFIED.includes(v) && bunStatusOk(v) ? v : null; +} + +export const bunStore = (stateRoot: string) => path.join(stateRoot, 'runtimes', 'bun'); + +export type BunRule = 'pin' | 'default' | 'BUN_INSTALL' | 'home' | 'PATH'; +export interface BunResolution { bin: string; version: string; rule: BunRule } + +function isShim(file: string): boolean { + try { + const fd = fs.openSync(file, 'r'); + const buf = Buffer.alloc(512); + const n = fs.readSync(fd, buf, 0, 512, 0); + fs.closeSync(fd); + return buf.subarray(0, n).toString('latin1').includes(BUN_SHIM_MARKER); + } catch { return false; } +} + +function bunVersionOf(bin: string, env: Env): string { + const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 15_000, env }); + return r.status === 0 ? (r.stdout ?? '').split('\n')[0].replace(/[^A-Za-z0-9.+-]/g, '') : ''; +} + +/** + * The resolution contract. `accept` is the caller's project-pin check; + * `judged` lists the candidates that ran and were refused. + */ +export function resolveBun(opts: { store: string; env: Env; pinExact?: string | null; accept?: (version: string, bin: string) => boolean }): { resolution: BunResolution | null; judged: string[] } { + const { store, env } = opts; + const installVersion = bunInstallVersion(env); + const candidates: [BunRule, string][] = []; + if (opts.pinExact) candidates.push(['pin', path.join(store, opts.pinExact, 'bun')]); + if (installVersion) candidates.push(['default', path.join(store, installVersion, 'bun')]); + if (env.BUN_INSTALL) candidates.push(['BUN_INSTALL', path.join(env.BUN_INSTALL, 'bin', 'bun')]); + if (env.HOME) candidates.push(['home', path.join(env.HOME, '.bun', 'bin', 'bun')]); + for (const dir of (env.PATH ?? '').split(':')) if (dir) candidates.push(['PATH', path.join(dir, 'bun')]); + const seen = new Set<string>(); + const judged: string[] = []; + for (const [rule, file] of candidates) { + let real: string; + try { + if (!fs.statSync(file).isFile()) continue; + fs.accessSync(file, fs.constants.X_OK); + if (isShim(file)) continue; + real = fs.realpathSync(file); + } catch { continue; } + if (seen.has(real)) continue; + seen.add(real); + const version = bunVersionOf(real, env); + if (!bunStatusOk(version) || (opts.accept && !opts.accept(version, real))) { judged.push(`bun ${version || 'unknown'} at ${real} (${rule})`); continue; } + return { resolution: { bin: real, version, rule }, judged }; + } + return { resolution: null, judged }; +} function enginesBun(file: string): string | null { try { const v = JSON.parse(fs.readFileSync(file, 'utf8'))?.engines?.bun; return typeof v === 'string' && v.trim() ? v.trim() : null; } catch { return null; } } -export interface LaunchRuntime { dir: string | null; version: string | null; env: NodeJS.ProcessEnv; label: string } +const byVersionDesc = (a: string, b: string) => { const x = parseVersion(a)!, y = parseVersion(b)!; return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; }; -/** The launch dir and the env with it first on PATH; the env is unchanged when no candidate qualifies. */ +/** + * The project's pins as the resolver's eligibility hook, and the exact Bun to + * prefer (read_pins in bin/gstack-capy-install): the one non-CI exact pin, + * else among the CI alternatives the install version, the highest verified, + * or the highest. + */ +export function projectBunPins(repoRoot: string | null, env: Env = process.env): { pinExact: string | null; accept: (version: string) => boolean } { + const gstackPin = enginesBun(path.join(GSTACK_ROOT, 'package.json')); + const pins = repoRoot ? collectPins(repoRoot).filter(p => p.tool === 'bun') : []; + const plan = exactPlan(pins, 'bun'); + const alts = plan.ciAlternatives.filter(v => parseVersion(v)).sort(byVersionDesc); + const installVersion = bunInstallVersion(env); + const pinExact = plan.required.length ? (plan.required.length === 1 && parseVersion(plan.required[0].version) ? plan.required[0].version : null) + : alts.find(v => v === installVersion) ?? alts.find(v => BUN_VERIFIED.includes(v) && bunStatusOk(v)) ?? alts[0] ?? null; + return { + pinExact, + accept: (version: string) => (!gstackPin || satisfies(version, gstackPin) === true) && evaluatePins(pins, { bun: version }).failures.length === 0, + }; +} + +export interface LaunchRuntime { + dir: string | null; + version: string | null; + env: NodeJS.ProcessEnv; + label: string; + /** + * The install-verdict check: ok, drift (recorded Bun gone, changed or no + * longer satisfying the pins), unverified (no verdict under GSTACK_CAPY=1, + * or one that is not ok, for another HEAD, planning-only, another project or + * too narrow) or skipped (no GSTACK_CAPY=1 and no verdict: contributors, CI). + */ + verdict: 'ok' | 'drift' | 'unverified' | 'skipped'; + verdictLine: string; + /** Set for drift and unverified: the lanes and guards return it before launching anything. */ + refusal: { status: 'incomplete'; code: 'BUN_RESOLUTION_DRIFT' | 'INSTALL_UNVERIFIED'; detail: string; fix: string } | null; +} + +/** + * The Bun the lanes and guards run under, its dir first on PATH; the env is + * unchanged when nothing qualifies. Under the admission condition the install + * verdict for (checkout, project) is validated as workflow admission does + * (verdictProblem: ok, HEAD, pins, project, coverage, for /ship) and its + * recorded Bun is the one launched; the gstack checkout itself binds the + * 'none' verdict (installProject). + */ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchRuntime { - const pins = [enginesBun(GSTACK_PACKAGE), enginesBun(path.join(repoRoot, 'package.json'))].filter((p): p is string => !!p); - const candidates = [env.BUN_INSTALL ? path.join(env.BUN_INSTALL, 'bin') : null, env.HOME ? path.join(env.HOME, '.bun', 'bin') : null].filter((d): d is string => !!d); - for (const dir of [...new Set(candidates)]) { - const bun = path.join(dir, 'bun'); - try { fs.accessSync(bun, fs.constants.X_OK); } catch { continue; } - const r = spawnSync(bun, ['--version'], { encoding: 'utf8', timeout: 15_000, env }); - const version = r.status === 0 ? (r.stdout ?? '').trim() : ''; - if (!version || pins.some(pin => satisfies(version, pin) !== true)) continue; - return { dir, version, env: { ...env, PATH: [dir, env.PATH ?? ''].filter(Boolean).join(path.delimiter) }, label: `bun ${version} from ${dir}` }; + const checkout = sourceRoot(GSTACK_ROOT); + const project = installProject(checkout, repoRoot); + const file = findInstallVerdict(checkout, project, env); + const recorded = readInstallVerdict(file); + let pinsRoot: string | null = null; + try { pinsRoot = fs.realpathSync(repoRoot); } catch { /* unresolvable: no pins */ } + const { pinExact, accept } = projectBunPins(pinsRoot, env); + const result = (dir: string | null, version: string | null, label: string, verdict: LaunchRuntime['verdict'], verdictLine: string, refusal: LaunchRuntime['refusal'] = null): LaunchRuntime => + ({ dir, version, env: dir ? { ...env, PATH: [dir, env.PATH ?? ''].filter(Boolean).join(path.delimiter) } : env, label: `${label}${verdictLine ? `; ${verdictLine}` : ''}`, verdict, verdictLine, refusal }); + const refuse = (code: 'BUN_RESOLUTION_DRIFT' | 'INSTALL_UNVERIFIED', detail: string, fix: string) => + result(null, null, 'not launched', code === 'BUN_RESOLUTION_DRIFT' ? 'drift' : 'unverified', `install verdict: ${detail}; fix: ${fix} ${resultCodeSuffix(code)}`, { status: 'incomplete', code, detail: `install verdict: ${detail}`, fix }); + const installer = 'run the Capy skill installer here (scripts/install.sh --for ship), then rerun gstack-pregate with the GSTACK_LAUNCH line it prints'; + let verdict: LaunchRuntime['verdict'] = 'skipped'; + let verdictLine = ''; + if (env.GSTACK_CAPY !== '1' && !recorded) { + verdictLine = 'install verdict: not checked (no GSTACK_CAPY=1 and no verdict for this checkout and project)'; + } else { + const problem = verdictProblem('ship', GSTACK_ROOT, env, '', '', repoRoot); + if (problem) return refuse('INSTALL_UNVERIFIED', problem.detail, problem.fix ?? installer); + verdict = 'ok'; + if (recorded!.bun_path) { + const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : ''; + const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : ''; + if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix'); + return result(path.dirname(recorded!.bun_path), now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', ''); + } } - return { dir: null, version: null, env, label: 'PATH (no verified Bun in $BUN_INSTALL/bin or ~/.bun/bin)' }; + const { resolution } = resolveBun({ store: bunStore(resolveStateRoot(env)), env, pinExact, accept }); + if (resolution) return result(path.dirname(resolution.bin), resolution.version, `bun ${resolution.version} at ${resolution.bin} (resolved: ${resolution.rule})`, verdict, verdictLine); + return result(null, null, 'PATH (no eligible Bun in the runtime store, $BUN_INSTALL/bin, ~/.bun/bin or PATH)', verdict, verdictLine); } diff --git a/lib/pregate/supervise.ts b/lib/pregate/supervise.ts new file mode 100644 index 000000000..0d6aac3a0 --- /dev/null +++ b/lib/pregate/supervise.ts @@ -0,0 +1,189 @@ +/** + * pregate/supervise — how a test-tier command runs (Lane C, GBRA-95): its + * own process group, stdout and stderr appended to a log file (no pipes, so + * a backgrounded grandchild holding the output can never keep the check + * waiting), supervised asynchronously. A stall is no log growth for + * `stallMs`; the deadline is the check's `--timeout`. Either kills the whole + * process group (SIGTERM, then SIGKILL after a grace period) and the run is + * incomplete. After a normal exit the group's leftover members are killed. + * + * Progress: a runner with a progress adapter (`bun test`: its `<file>:` + * headers) reports `PREGATE_TEST: <n>/<total> <file> start|pass|fail <s>` + * per file; a run of one file reports it from the launch itself for any + * runner. Without either, only heartbeats: a line after each `heartbeatMs` + * with no progress line. + * + * Platforms: POSIX signals the negative pid (the group `detached` creates + * with setsid). Windows has no process groups here: the kill is + * `taskkill /T /F` on the shell's tree, which misses a descendant that was + * reparented or started detached. + */ +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { bashCommandInvocation } from '../script-invocation'; + +export type ProgressAdapter = 'bun'; +export const PROGRESS_ADAPTERS: Record<ProgressAdapter, RegExp> = { bun: /(?:^|[\s;&|(])bun\s+test\b/ }; + +export interface Counter { done: number; total: number } + +export interface SuperviseOptions { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + log: string; + /** Absolute epoch ms when the run is killed as timed out. */ + deadline: number; + /** No log growth for this long kills the group; 0 disables. */ + stallMs: number; + heartbeatMs?: number; + graceMs?: number; + pollMs?: number; + label: string; + files?: readonly string[]; + adapter?: ProgressAdapter | null; + counter?: Counter; + progress: (line: string) => void; +} + +export interface Supervised { + outcome: 'exited' | 'timeout' | 'stalled' | 'spawn-failed'; + exit: number | null; + signal: string | null; + /** Byte range of this run's output in the log. */ + start: number; + end: number; + lastFile: string | null; + error?: string; + ms: number; +} + +const ACTIVE = new Set<number>(); + +/** SIGKILL every supervised process group still running (the CLI's SIGINT/SIGTERM handler, before it exits). */ +export function cancelActive(): number { + const pids = [...ACTIVE]; + for (const pid of pids) killGroup(pid, 'SIGKILL'); + ACTIVE.clear(); + return pids.length; +} + +export function killGroup(pid: number, signal: NodeJS.Signals): void { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { timeout: 15_000, windowsHide: true }); + return; + } + try { process.kill(-pid, signal); } catch { /* group already gone */ } +} + +/** The adapter that reads per-file progress from this runner's output, or null when it has none. */ +export function adapterFor(command: string): ProgressAdapter | null { + for (const [name, re] of Object.entries(PROGRESS_ADAPTERS)) if (re.test(command)) return name as ProgressAdapter; + return null; +} + +/** The last `bytes` of the log range as text. */ +export function readRange(log: string, start: number, end: number, bytes = 4 * 1024 * 1024): string { + const from = Math.max(start, end - bytes); + if (end <= from) return ''; + const fd = fs.openSync(log, 'r'); + try { const buf = Buffer.alloc(end - from); fs.readSync(fd, buf, 0, buf.length, from); return buf.toString('utf8'); } + finally { fs.closeSync(fd); } +} + +export function supervise(o: SuperviseOptions): Promise<Supervised> { + const heartbeatMs = o.heartbeatMs ?? 60_000; + const graceMs = o.graceMs ?? 5_000; + const pollMs = o.pollMs ?? 250; + const counter = o.counter ?? { done: 0, total: o.files?.length ?? 0 }; + const files = new Set(o.files ?? []); + const single = o.files?.length === 1 ? o.files[0]! : null; + const adapter = single ? null : (o.adapter ?? null); + const started = Date.now(); + fs.appendFileSync(o.log, `$ ${o.command}\n`); + const start = fs.statSync(o.log).size; + let size = start; + let readAt = start; + let partial = ''; + let lastGrowth = started; + let lastProgress = started; + let current: { file: string; at: number; failed: boolean } | null = null; + let lastFile: string | null = null; + const emit = (line: string) => { lastProgress = Date.now(); o.progress(line); }; + const startFile = (file: string) => { current = { file, at: Date.now(), failed: false }; lastFile = file; emit(`PREGATE_TEST: ${counter.done + 1}/${counter.total} ${file} start`); }; + const finishFile = (failed?: boolean) => { + if (!current) return; + counter.done += 1; + emit(`PREGATE_TEST: ${counter.done}/${counter.total} ${current.file} ${(failed ?? current.failed) ? 'fail' : 'pass'} ${((Date.now() - current.at) / 1000).toFixed(1)}`); + current = null; + }; + const readNew = () => { + if (!adapter || size <= readAt) return; + const text = partial + readRange(o.log, readAt, size, size - readAt); + readAt = size; + const lines = text.split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) { + const header = /^(?:::group::)?(\S.*):$/.exec(line); + if (header && files.has(header[1]!)) { finishFile(); startFile(header[1]!); continue; } + if (/^\s*\d+\s+pass$/.test(line) || /^Ran \d+ tests? across/.test(line) || /^\s*\d+\s+tests? failed:?$/.test(line)) { finishFile(); continue; } + if (current && /^\(fail\)\s/.test(line)) current.failed = true; + } + }; + + return new Promise(resolve => { + let fd: number; + try { fd = fs.openSync(o.log, 'a'); } + catch (e: any) { resolve({ outcome: 'spawn-failed', exit: null, signal: null, start, end: start, lastFile: null, error: e.message, ms: 0 }); return; } + const inv = bashCommandInvocation(`set -o pipefail; ${o.command}`); + if (!inv.ok) { + fs.closeSync(fd); + resolve({ outcome: 'spawn-failed', exit: null, signal: null, start, end: start, lastFile: null, error: inv.error, ms: Date.now() - started }); + return; + } + let child: ReturnType<typeof spawn>; + try { + child = spawn(inv.cmd, inv.argv, { cwd: o.cwd, env: o.env, stdio: ['ignore', fd, fd], detached: process.platform !== 'win32', windowsHide: true }); + } catch (e: any) { + fs.closeSync(fd); + resolve({ outcome: 'spawn-failed', exit: null, signal: null, start, end: start, lastFile: null, error: e.message, ms: Date.now() - started }); + return; + } + fs.closeSync(fd); + if (single) startFile(single); + let outcome: Supervised['outcome'] | null = null; + let settled = false; + let escalate: ReturnType<typeof setTimeout> | null = null; + const stop = (why: Supervised['outcome']) => { + if (outcome || !child.pid) return; + outcome = why; + killGroup(child.pid, 'SIGTERM'); + escalate = setTimeout(() => killGroup(child.pid!, 'SIGKILL'), graceMs); + }; + const finish = (exit: number | null, signal: string | null, error?: string) => { + if (settled) return; + settled = true; + clearInterval(timer); + if (escalate) clearTimeout(escalate); + if (child.pid) { ACTIVE.delete(child.pid); if (process.platform !== 'win32') killGroup(child.pid, 'SIGKILL'); } + try { size = fs.statSync(o.log).size; } catch { /* log removed */ } + readNew(); + const done: Supervised['outcome'] = error ? 'spawn-failed' : (outcome ?? 'exited'); + if (current) finishFile(done !== 'exited' || exit !== 0 ? true : undefined); + resolve({ outcome: done, exit: done === 'exited' ? exit : null, signal, start, end: size, lastFile, error, ms: Date.now() - started }); + }; + const timer = setInterval(() => { + const now = Date.now(); + let grown = size; + try { grown = fs.statSync(o.log).size; } catch { /* log removed */ } + if (grown > size) { size = grown; lastGrowth = now; readNew(); } + if (outcome) return; + if (now >= o.deadline) { stop('timeout'); return; } + if (o.stallMs > 0 && now - lastGrowth >= o.stallMs) { stop('stalled'); return; } + if (now - lastProgress >= heartbeatMs) emit(`PREGATE_HEARTBEAT: ${o.label} running ${Math.round((now - started) / 1000)}s; last file ${lastFile ?? 'none reported'}; log ${size - start} bytes, last growth ${Math.round((now - lastGrowth) / 1000)}s ago (${o.log})`); + }, pollMs); + if (child.pid) ACTIVE.add(child.pid); + child.on('error', e => finish(null, null, e.message)); + child.on('exit', (code, signal) => finish(code, signal)); + }); +} diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 309e2a91b..37512dee4 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -39,6 +39,16 @@ export interface PregateContext { platform: NodeJS.Platform; allowRepoCommands: boolean; timeoutMs: number; + /** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */ + stallTimeoutMs: number; + /** Live stderr lines: per-file progress, heartbeats, the stage result. */ + progress: (line: string) => void; + /** CPU count and `gstack-config test_backend`: under 8 cores a configured remote gate records an obligation instead of running locally. */ + cores: number; + testBackend: string; + policyFrom: 'base' | 'head'; + /** `--remote`: run the declared remote gate now, under this spend cap. */ + remote: { spendCapUsd: number } | null; env: NodeJS.ProcessEnv; /** `--explain`: checks append provenance lines here. */ explain: string[]; @@ -55,6 +65,8 @@ export interface RemoteObligation { /** What clears it: a run URL or an evidence-bundle lane label, recorded by `gstack-pregate clear`. */ receipt: string | null; cleared_by?: string; + /** The exact command that discharges the obligation (a small machine's remote gate). */ + command?: string; } export interface CheckResult extends PregateCheck { @@ -78,5 +90,5 @@ export interface CheckDef { downgradable: boolean; /** True when the check executes commands the repo declares (pin files or its test runner). */ needsRepoCommands: boolean; - run(ctx: PregateContext): CheckResult; + run(ctx: PregateContext): CheckResult | Promise<CheckResult>; } diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 8bc79c0db..c3bdeaace 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -112,7 +112,7 @@ export const RESULT_CODES = { GATE_REPLY_UNPARSED: { anchor: 'gate-reply-unparsed', summary: 'part of the reply matched no gate item or option, so nothing was applied', - fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous', + fix: 'reply with `rec`, `as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `rec except <tokens>`; bare yes/no is ambiguous', }, ANSWER_BY_REQUIRED: { anchor: 'answer-by-required', @@ -394,6 +394,16 @@ export const RESULT_CODES = { summary: 'a check timed out, its tool is unavailable, or the tree changed while it ran; the result is incomplete, never pass', fix: 'install the named tool or raise --timeout, keep the tree still during the run, then rerun the check', }, + PREGATE_STALLED: { + anchor: 'pregate-stalled', + summary: 'a test command printed nothing for the stall timeout, so its process group was killed; the check is incomplete, never pass', + fix: 'read the lane log for the last file started, fix the hang, or raise `--stall-timeout` (`stall_timeout_s`; 0 disables) for a test that is quiet for longer, then rerun `gstack-pregate --stage tests`', + }, + PREGATE_RECEIPT_INVALID: { + anchor: 'pregate-receipt-invalid', + summary: 'the receipt offered to `gstack-pregate clear` for the remote gate obligation does not show the tested tree passing', + fix: 'rerun the remote gate on this tree and pass its output with `gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>`, or run `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`', + }, PREGATE_STALE: { anchor: 'pregate-stale', summary: 'pregate.json was recorded against a different tree than the one being published', @@ -409,6 +419,16 @@ export const RESULT_CODES = { summary: 'an issue a closure trailer or --issue names could not be read through gstack-issue-guard, so its link is Refs, never Fixes', fix: 'check `gh auth status` and the issue number; rerun gstack-issue-links once the issue reads', }, + ISSUE_LOOKUP_FAILED: { + anchor: 'issue-lookup-failed', + summary: 'gstack-issue-links could not read the repository, this PR\'s author, the open PRs closing a fixed issue, or the flake issues naming a CI run, so Related or Flake lines may be missing or list the owner\'s own PRs', + fix: 'check `gh auth status` and the repository (`--repo <owner/repo>`), pass `--author <login>` when gh runs as an app, then rerun gstack-issue-links; report the gap in the handoff if it still fails', + }, + ISSUE_LINKS_MISSING: { + anchor: 'issue-links-missing', + summary: 'the PR body lacks a line gstack-issue-links wrote to issues.json, so the body and the ship receipt would disagree about what the PR closes or relates to', + fix: 'paste every line gstack-issue-links printed under `## Issues` in the body, then rerun gstack-ship-receipt write; nothing was written', + }, // PR E (multi-agent wave): gstack-eval-plan, gstack-lane-check, gstack-evidence verify, // gstack-owner-brief, gstack-contributor-mode, gstack-banned-terms. PREREG_INCOMPLETE: { @@ -514,13 +534,48 @@ export const RESULT_CODES = { PLAN_AMEND_PENDING: { anchor: 'plan-amend-pending', summary: 'the owner accepted plan changes at the gate that the plan has not absorbed yet', - fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', }, AMENDMENT_UNKNOWN: { anchor: 'amendment-unknown', summary: '`revise --incorporates` or `--withdraw` named a decision that is not a pending amendment (never accepted as a plan change, or already incorporated or withdrawn)', fix: 'name only the ids the refusal lists after `pending:`; with none pending, drop --incorporates and run `gstack-autoplan revise --out <dir> --plan <file>`', }, + AMENDMENT_NOT_SETTLEABLE: { + anchor: 'amendment-not-settleable', + summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the plan has edits no revise has recorded', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>` (add `--settle <id>` to settle packaging/ordering changes in the same call); settle alone only on the plan the run already reviews', + }, + GATE_REC_STALE: { + anchor: 'gate-rec-stale', + summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes, or the gate predates `rec` (a gate.json without rec_rev, rendered when `all` left changes pending)', + fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`', + }, + CHECKPOINT_INVALID: { + anchor: 'checkpoint-invalid', + summary: 'no autoplan checkpoint generation can be restored: a manifest is unreadable, a listed file is missing, escapes the generation (by path or symlink) or does not match its sha256, or the staged run does not validate', + fix: 'run `gstack-autoplan checkpoints --from <dir>` to see each generation\'s hash status and restore an intact one; nothing was written to --out', + }, + CHECKPOINT_OVERLAP: { + anchor: 'checkpoint-overlap', + summary: 'the checkpoint directory and the run directory (or the restore target) are the same directory or one contains the other, symlinks resolved', + fix: 'point GSTACK_AUTOPLAN_CHECKPOINT_DIR (or --from / --out) at a directory outside the run; nothing was moved or deleted', + }, + CHECKPOINT_TARGET_EXISTS: { + anchor: 'checkpoint-target-exists', + summary: '`gstack-autoplan resume --from` restores only into an empty or missing --out directory', + fix: 'pass a new --out directory, or resume the run already there without --from', + }, + OWNER_REPLY_REQUIRED: { + anchor: 'owner-reply-required', + summary: 'an agent answered the gate (`--by agent:<id>`) without the owner\'s verbatim reply', + fix: 'pass the owner\'s words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`', + }, + GATE_OWNER_REPLY_MISMATCH: { + anchor: 'gate-owner-reply-mismatch', + summary: 'the owner\'s verbatim reply parses under the gate grammar but resolves to different choices than the relayed tokens', + fix: 'relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree', + }, GSTACK_VERSION_CHANGED: { anchor: 'gstack-version-changed', summary: 'the run directory was written by a different gstack version', @@ -554,8 +609,18 @@ export const RESULT_CODES = { }, CAPY_WRAPPER_STALE: { anchor: 'capy-wrapper-stale', - summary: 'the Capy skill copy that ran is older than the checkout\'s GSTACK_CAPY_MIN_WRAPPER', - fix: 'ask the owner to copy <checkout>/capy/skill/gstack/ over the Drive skill folder; the checkout wrapper keeps working meanwhile', + summary: 'the Capy skill copy that ran (or, without provenance, every Drive copy) is older than the checkout\'s GSTACK_CAPY_MIN_WRAPPER', + fix: 'this session: run <checkout>/capy/skill/gstack/scripts/install.sh, which certifies; owner: copy <checkout>/capy/skill/gstack/ over the Drive skill folder (it recurs every session until the copy changes)', + }, + BUN_RESOLUTION_DRIFT: { + anchor: 'bun-resolution-drift', + summary: 'the Bun the install verdict recorded for this checkout and project is gone, reports another version, or no longer satisfies the pins (or the Bun running is not it)', + fix: 'run the fix the row prints: the installer, which records the current resolution or re-downloads a verified Bun into the store', + }, + BUN_INSTALL_VERSION_INVALID: { + anchor: 'bun-install-version-invalid', + summary: 'GSTACK_BUN_INSTALL_VERSION names a Bun without a verified archive, or one below gstack\'s supported minimum', + fix: 'unset GSTACK_BUN_INSTALL_VERSION, or set it to a version listed in GSTACK_BUN_VERIFIED (bin/gstack-bun-version.sh)', }, INSTALL_UNVERIFIED: { anchor: 'install-unverified', @@ -605,6 +670,12 @@ export function resultCodeSuffix(code: ResultCodeName): string { return `(${code}; ${TROUBLESHOOTING_DOC}#${RESULT_CODES[code].anchor})`; } +/** The installer form: `<context>: <summary>[ — <detail>]; fix: <fix> (<CODE>; <doc>#anchor)`. */ +export function resultLineWithDocs(context: string, code: ResultCodeName, detail?: string): string { + const row: ResultCode = RESULT_CODES[code]; + return `${context}: ${row.summary}${detail ? ` — ${detail}` : ''}; fix: ${row.fix} ${resultCodeSuffix(code)}`; +} + /** One error line: `<context>: <summary>[ (<detail>)]; fix: <fix> (<CODE>)`. */ export function resultLine(context: string, code: ResultCodeName, detail?: string): string { const row: ResultCode = RESULT_CODES[code]; diff --git a/lib/runtime-pins.ts b/lib/runtime-pins.ts index 9eae9c67a..0c9736dad 100644 --- a/lib/runtime-pins.ts +++ b/lib/runtime-pins.ts @@ -11,6 +11,11 @@ * the platform lane of the job's `runs-on`, so a Windows job's pin never fails * a Linux run. A value this module cannot evaluate (`lts/*`, a matrix * expression) is reported as skipped, never silently dropped. + * + * CI exact pins that apply to this platform are alternatives: a repository's + * lanes may test several Bun versions (gstack pins its minimum everywhere and + * runs one job on its install default), so the running Bun satisfies CI when + * it equals any one of them. CI ranges and every non-CI pin stay required. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -210,20 +215,43 @@ export function evaluatePins(pins: Pin[], running: Running, platform: NodeJS.Pla const version = running[tool]; const applicable = mine.filter(p => p.lane === 'any' || p.lane === here); const unmet: Pin[] = []; + const ciExact: Pin[] = []; for (const pin of applicable) { if (pin.kind === 'unevaluable') { skipped.push(`${tool} ${pin.constraint} (${pin.source}) not evaluated`); continue; } if (!version) { skipped.push(`${tool} ${pin.constraint} (${pin.source}): ${tool} not found on PATH`); continue; } evaluated++; - if (satisfies(version, pin.constraint) === false) unmet.push(pin); + if (pin.field === 'CI' && pin.kind === 'exact') ciExact.push(pin); + else if (satisfies(version, pin.constraint) === false) unmet.push(pin); } + const ciMet = ciExact.some(pin => satisfies(version!, pin.constraint) !== false); + if (!ciMet) unmet.push(...ciExact); if (unmet.length === 0) continue; const [first, ...rest] = groupPins(unmet); const context = rest.map(g => `${describe(g)} pins ${g.pin.constraint}`); - failures.push(`${tool} ${version} outside ${first.pin.field} ${first.pin.constraint} (${first.pin.source}${context.length ? '; ' + context.join('; ') : ''})`); + const alternatives = ciMet ? [] : [...new Set(ciExact.map(p => p.constraint))]; + const anyOf = alternatives.length > 1 ? `; CI exact pins are alternatives, any one of ${alternatives.join(', ')} satisfies CI` : ''; + failures.push(`${tool} ${version} outside ${first.pin.field} ${first.pin.constraint} (${first.pin.source}${context.length ? '; ' + context.join('; ') : ''}${anyOf})`); } return { pins, summary, failures, skipped, verdict: failures.length ? 'fail' : evaluated ? 'pass' : 'none' }; } +export interface ExactPlan { + /** Distinct non-CI exact pins (engines, packageManager, .tool-versions, .bun-version): all required. */ + required: Array<{ version: string; source: string }>; + /** Distinct CI exact pins for this platform's lanes: any one satisfies CI. */ + ciAlternatives: string[]; +} + +/** The exact pins of one tool that apply on this platform, split into required and CI alternatives. */ +export function exactPlan(pins: Pin[], tool: Tool, platform: NodeJS.Platform = process.platform): ExactPlan { + const here = PLATFORM_LANE[platform] ?? 'any'; + const exact = pins.filter(p => p.tool === tool && p.kind === 'exact' && (p.lane === 'any' || p.lane === here)); + const strip = (v: string) => v.replace(/^v/, ''); + const required: ExactPlan['required'] = []; + for (const p of exact.filter(p => p.field !== 'CI')) if (!required.some(r => r.version === strip(p.constraint))) required.push({ version: strip(p.constraint), source: p.source }); + return { required, ciAlternatives: [...new Set(exact.filter(p => p.field === 'CI').map(p => strip(p.constraint)))] }; +} + /** The wire lines bin/gstack-doctor-check.sh reads (PINS_VERDICT last). */ export function renderPinLines(report: PinReport, platform: NodeJS.Platform = process.platform): string[] { const here = PLATFORM_LANE[platform] ?? 'any'; @@ -231,6 +259,9 @@ export function renderPinLines(report: PinReport, platform: NodeJS.Platform = pr for (const s of report.summary) lines.push(`PIN_SUMMARY: ${s}`); for (const s of report.skipped) lines.push(`PIN_SKIP: ${s}`); for (const f of report.failures) lines.push(`PIN_FAIL: ${f}`); + const plan = exactPlan(report.pins, 'bun', platform); + for (const r of plan.required) lines.push(`PIN_EXACT_REQUIRED: bun ${r.version} ${r.source}`); + if (plan.ciAlternatives.length) lines.push(`PIN_CI_ALTERNATIVES: bun ${plan.ciAlternatives.join(' ')}`); lines.push(`PINS_VERDICT: ${report.verdict}`); return lines; } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index f4dcd3f7d..3bd17b167 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -7,6 +7,7 @@ * thread id against an owner-maintained thread cohort). */ import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts'; +import { renderIssueLines } from './issue-links'; export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array<keyof ShipReceipt> = [ @@ -54,6 +55,20 @@ export function placeReceiptFirst(body: string, receipt: ShipReceipt): string { return block + (stripped ? '\n' + stripped : ''); } +/** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */ +export function receiptIssuesFrom(file: Record<string, unknown>): ShipReceipt['issues'] { + const issues: Record<string, unknown> = {}; + for (const key of ['fixes', 'refs', 'flakes', 'related']) if (file[key] !== undefined) issues[key] = file[key]; + return issues as ShipReceipt['issues']; +} + +/** Lines the receipt's issues imply that the body does not carry (a line may continue, e.g. `Refs #N (unreadable …)`). */ +export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt['issues']>): string[] { + const have = body.split(/\r?\n/).map(l => l.trim()); + const expected = renderIssueLines({ fixes: issues.fixes ?? [], refs: issues.refs ?? [], flakes: issues.flakes ?? [], cross: [], unreadable: [], ...(issues.related ? { related: issues.related } : {}) }); + return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `))); +} + /** The first lines of the completion message to the coordinator: identifiers only. */ export function receiptSummaryLine(r: ShipReceipt): string { const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`]; diff --git a/package.json b/package.json index 03014f326..05047cd10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.106", + "version": "1.91.107", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index e3faa362b..f1267efa6 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -58,6 +58,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> // import graph are covered by test/pregate-selection.test.ts, which the bin/ pattern excludes too. { file: 'test/gstack-regen.test.ts', reason: 'spawns bin/gstack-regen through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and POSIX fixture regen commands' }, { file: 'test/pregate-guards.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and runs node guard and lane fixture commands through the POSIX runner' }, + { file: 'test/pregate-remote.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and runs POSIX remote-gate fixture commands through the lane runner' }, { file: 'test/pregate-regate.test.ts', reason: 'spawns bin/gstack-pregate regate through test/helpers/restamp-fixture.ts runBin (shebang via CreateProcess)' }, { file: 'test/stamp-templates.test.ts', reason: 'spawns bin/gstack-tree-receipt through test/helpers/restamp-fixture.ts runBin (shebang via CreateProcess)' }, { file: 'test/pregate-invalidation.test.ts', reason: 'spawns bin/gstack-pregate and bin/gstack-ship-receipt through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and the pipefail lane runner' }, diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 8266ea5b1..867a80ca9 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -22,24 +22,27 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' }, - { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"', does: 'answer the gate with the reply grammar below' }, + { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' }, + { cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' }, { cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' }, + { cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' }, { cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' }, { cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' }, - { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' }, ]; /** The reply grammar `gstack-gate` parses, one form per line. */ export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [ - { reply: 'all', means: 'every item takes its recommendation (an agreed change prints APPLY: and needs revise)' }, - { reply: 'all as-is', means: 'keep every direction; no plan change' }, + { reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias' }, + { reply: 'as-is', means: 'keep every direction; no plan change' }, { reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' }, - { reply: 'all except d3b', means: 'recommendations, with the listed overrides' }, + { reply: 'rec except d3b', means: 'recommendations, with the listed overrides' }, ]; /** Result codes the loop can print, in the order a run meets them. */ export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH', - 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'PLAN_AMEND_PENDING', 'GSTACK_VERSION_CHANGED'] as const; + 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'CHECKPOINT_INVALID', 'GSTACK_VERSION_CHANGED'] as const; /** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */ const PENDING_FIX: Record<string, string> = { PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither', diff --git a/scripts/resolvers/plan-reality.ts b/scripts/resolvers/plan-reality.ts index 0ac767d20..a9ed699a9 100644 --- a/scripts/resolvers/plan-reality.ts +++ b/scripts/resolvers/plan-reality.ts @@ -52,7 +52,7 @@ in an unattended run, never a warning; interactive runs follow the repair rule a } if (mode === 'autoplan') { return `Reality rows: the loaded skill's \`REALITY:\` lines, one per applicable row; - \`${BIN} check --phase ${args?.[1] ?? 'ceo'} <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes + \`${BIN} check --phase ${args?.[1] ?? 'ceo'} --plan <reviewed snapshot> <review-file|->\` prints \`PLAN_REALITY: … verdict=complete\` before the phase closes (unattended: \`incomplete\` keeps it open).`; } const phase = phaseFor(ctx, mode); diff --git a/scripts/ubicloud/ubi-runner.sh b/scripts/ubicloud/ubi-runner.sh index 352567189..7963c33be 100755 --- a/scripts/ubicloud/ubi-runner.sh +++ b/scripts/ubicloud/ubi-runner.sh @@ -51,6 +51,7 @@ API_TIMEOUT="${UBI_API_TIMEOUT:-120}" CREATE_GRACE="${UBI_CREATE_GRACE:-180}" DOWN_TIMEOUT="${UBI_DOWN_TIMEOUT:-900}" POLL="${UBI_POLL_SECONDS:-5}" +ALLOWED_STORAGE="${UBI_ALLOWED_STORAGE:-320 640}" PREFIX="ubirun" if [ -n "${UBI_VM_RECORD:-}" ]; then VM_RECORD="$UBI_VM_RECORD" elif [ -d "$HOME/.capy/work" ]; then VM_RECORD="$HOME/.capy/work/ubi-runner/vms.tsv" @@ -229,6 +230,14 @@ new_name() { echo "$PREFIX-$OWNER-$(date +%s)-$(head -c4 /dev/urandom | od -An - valid_name() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; } +# check_storage GIB: Ubicloud accepts only these disk sizes; refuse before any API call. +check_storage() { + case " $ALLOWED_STORAGE " in + *" $1 "*) [ -n "$1" ] && return 0 ;; + esac + die "invalid storage '$1' GiB (allowed: $ALLOWED_STORAGE; set UBI_ALLOWED_STORAGE when Ubicloud adds sizes)" +} + UP_NAME="" UP_READY="" cmd_up() { @@ -238,7 +247,7 @@ cmd_up() { -s|--size) size=$2; shift 2 ;; -l|--location) location=$2; shift 2 ;; -n|--name) name=$2; shift 2 ;; - -S|--storage) storage=$2; shift 2 ;; + -S|--storage) check_storage "$2"; storage=$2; shift 2 ;; -b|--image) image=$2; shift 2 ;; *) die "up: unknown option $1" ;; esac @@ -430,7 +439,8 @@ cmd_run() { local up_args=() src=. dest="" setup="" keep=0 pulls=() envs=() while [ $# -gt 0 ]; do case $1 in - -s|--size|-l|--location|-n|--name|-S|--storage|-b|--image) up_args+=("$1" "$2"); shift 2 ;; + -S|--storage) check_storage "$2"; up_args+=("$1" "$2"); shift 2 ;; + -s|--size|-l|--location|-n|--name|-b|--image) up_args+=("$1" "$2"); shift 2 ;; --src) src=$2; shift 2 ;; --dest) dest=$2; shift 2 ;; --setup) setup=$2; shift 2 ;; @@ -498,6 +508,7 @@ usage: ubi-runner.sh <command> [args] up + sync + setup + command + pull + destroy; exits with COMMAND's status up [-s SIZE] [-l LOCATION] [-n NAME] [-S STORAGE_GIB] [-b IMAGE] create a VM and wait for SSH; prints its name + (STORAGE_GIB: one of $ALLOWED_STORAGE) ssh NAME [COMMAND] shell or login-shell command on the VM (user ubi, passwordless sudo) sync NAME [SRC] [DEST] stream a checkout (tracked + untracked-unignored + .git) pack [SRC] write that checkout tarball to stdout @@ -516,7 +527,8 @@ VMs are named $PREFIX-<owner>-<epoch>-<suffix>; set UBI_OWNER (e.g. your thread code) to tag them, otherwise a per-machine id is used. defaults: size=$DEFAULT_SIZE location=$DEFAULT_LOCATION (env UBI_SIZE, UBI_LOCATION) env: UBI_GC_HOURS (unset/0: no sweep on up), UBI_CREATE_GRACE (${CREATE_GRACE}s), - UBI_DOWN_TIMEOUT (${DOWN_TIMEOUT}s), UBI_API_TIMEOUT (${API_TIMEOUT}s), UBI_VM_RECORD (scratch VM-id file) + UBI_DOWN_TIMEOUT (${DOWN_TIMEOUT}s), UBI_API_TIMEOUT (${API_TIMEOUT}s), UBI_VM_RECORD (scratch VM-id file), + UBI_ALLOWED_STORAGE (the -S sizes Ubicloud accepts; default "320 640") EOF } diff --git a/setup b/setup index 65decb3e9..ff4eb717b 100755 --- a/setup +++ b/setup @@ -441,6 +441,15 @@ _repair_crlf_checkout() { } [ -n "${GSTACK_SETUP_REFRESH_CHILD:-}" ] || _repair_crlf_checkout +# _setup_ready_line — the final `gstack ready (<host>, ...)` line from summary +# rows on stdin (host<TAB>...<TAB>result in field 7): every host installed, +# updated or unchanged, in table order; nothing when no host row exists. +_setup_ready_line() { + local hosts + hosts=$(awk -F'\t' '$1 != "state" && $7 ~ /^(installed|updated|unchanged)$/ && !seen[$1]++ { printf "%s%s", (n++ ? ", " : ""), $1 }') + [ -z "$hosts" ] || log "gstack ready ($hosts)" +} + # ─── --refresh-registered: refresh exactly the registered installs (#1925) ─── # What /gstack-upgrade and the team-mode auto-update run. Each install this # checkout registered is refreshed by running setup for that one host, in its @@ -554,6 +563,7 @@ EOF if [ "$_rf_disabled" != " " ]; then echo " disabled skills (not registered on any host):$_rf_disabled" fi + _setup_ready_line < "$_RF_SUMMARY" rm -f "$_RF_SUMMARY" exit "$_RF_FAILED" fi @@ -4772,3 +4782,6 @@ if [ -n "${_HOOK_REFUSED:-}" ]; then echo "gstack setup: everything else is installed, but these hooks were not registered because they do not parse: $_HOOK_REFUSED(first: $_HOOK_REFUSED_AT). This is a gstack bug; report $_HOOK_REFUSED_AT. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#setup-hook-does-not-parse" >&2 exit 1 fi +# The final line names every host this run installed, from the same rows as +# the summary table; instruction-only hosts write no row and print none. +printf '%s' "$_SETUP_ROWS" | _setup_ready_line diff --git a/ship/SKILL.md b/ship/SKILL.md index 39ca8aa00..fcc7d06ae 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -887,15 +887,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index f568b7eaa..68021885b 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -350,15 +350,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/ship/sections/handoff.md b/ship/sections/handoff.md index b576d3172..efcc24bc6 100644 --- a/ship/sections/handoff.md +++ b/ship/sections/handoff.md @@ -7,9 +7,10 @@ automation lane, an unattended parent said so). Nothing here publishes. 1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming the lanes that need a remote (`requires-remote`), to `.gstack/tmp/ship-gate.json` - as the pr-body section does, then: + as the pr-body section does, then (with its Issue links flags; ask nothing): ```bash - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json + ~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), @@ -17,6 +18,7 @@ automation lane, an unattended parent said so). Nothing here publishes. cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. + `issues:` its lines, unpromoted candidates and lookup failures. Full thread ids (`jam_…`) in any cross-thread reference, never a short code. Prose may follow the identifiers; nothing may precede them. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. diff --git a/ship/sections/handoff.md.tmpl b/ship/sections/handoff.md.tmpl index 015198597..66758528b 100644 --- a/ship/sections/handoff.md.tmpl +++ b/ship/sections/handoff.md.tmpl @@ -5,9 +5,10 @@ automation lane, an unattended parent said so). Nothing here publishes. 1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming the lanes that need a remote (`requires-remote`), to `.gstack/tmp/ship-gate.json` - as the pr-body section does, then: + as the pr-body section does, then (with its Issue links flags; ask nothing): ```bash - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json + ~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), @@ -15,6 +16,7 @@ automation lane, an unattended parent said so). Nothing here publishes. cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. + `issues:` its lines, unpromoted candidates and lookup failures. Full thread ids (`jam_…`) in any cross-thread reference, never a short code. Prose may follow the identifiers; nothing may precede them. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 5bcfe857e..3c8a95c3d 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -56,6 +56,14 @@ then return here for a new lookup and a fresh body before publishing. and names the remaining work; never auto-close it. Include the archive filename and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body. +### Issue links + +Run `~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json`, adding +`--issue <n>` per issue the user named as fixed and `--ci-run <id>` per failing CI run. Interactive: +ask once about its `(promote: fix #N)` references (each `fix #<n>` reply adds `--issue <n>`), and add +`--supersedes <n>`/`--credit <n>` for a `Related:` PR only when the owner confirms; rerun after any change. +The completion message lists unpromoted candidates, `Related:` lines and any `ISSUE_LOOKUP_FAILED`. + The PR/MR body should contain these sections (never reuse a prior run's body): ``` @@ -118,9 +126,7 @@ unavailable results as passing.> Without a valid match, omit this entire section.> ## Issues -<The lines `gstack-issue-links --base <base> [--issue <n>] [--ci-run <id>] --out .gstack/tmp/issues.json` -prints: Fixes only from a Fixes/Closes trailer or --issue, Refs otherwise, -Flake: #N for failing run ids. Omit when it prints none.> +<Issue links stdout, verbatim; omit on ISSUE_LINKS: none.> ## Verification Results <Step 8.1 obligations executed at Step 9: N PASS, M FAIL, K BLOCKED, J NOT RUN, @@ -195,7 +201,8 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead). New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body. Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad -field: fix that input file and rerun; nothing was written. `PREGATE_STALE` means rerun +field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add +the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun `gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs `gstack-pregate clear` with the run URL. Both stop publication. diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 7d4200e98..0b37b036b 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -47,6 +47,14 @@ then return here for a new lookup and a fresh body before publishing. and names the remaining work; never auto-close it. Include the archive filename and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body. +### Issue links + +Run `~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json`, adding +`--issue <n>` per issue the user named as fixed and `--ci-run <id>` per failing CI run. Interactive: +ask once about its `(promote: fix #N)` references (each `fix #<n>` reply adds `--issue <n>`), and add +`--supersedes <n>`/`--credit <n>` for a `Related:` PR only when the owner confirms; rerun after any change. +The completion message lists unpromoted candidates, `Related:` lines and any `ISSUE_LOOKUP_FAILED`. + The PR/MR body should contain these sections (never reuse a prior run's body): ``` @@ -109,9 +117,7 @@ unavailable results as passing.> Without a valid match, omit this entire section.> ## Issues -<The lines `gstack-issue-links --base <base> [--issue <n>] [--ci-run <id>] --out .gstack/tmp/issues.json` -prints: Fixes only from a Fixes/Closes trailer or --issue, Refs otherwise, -Flake: #N for failing run ids. Omit when it prints none.> +<Issue links stdout, verbatim; omit on ISSUE_LINKS: none.> ## Verification Results <Step 8.1 obligations executed at Step 9: N PASS, M FAIL, K BLOCKED, J NOT RUN, @@ -178,7 +184,8 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead). New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body. Its `SHIP_RECEIPT:` line opens the completion message. `RECEIPT_INVALID` names the bad -field: fix that input file and rerun; nothing was written. `PREGATE_STALE` means rerun +field: fix that input file and rerun; nothing was written. `ISSUE_LINKS_MISSING`: add +the named line under `## Issues` and rerun. `PREGATE_STALE` means rerun `gstack-pregate` on the stamped tree; `PREGATE_REMOTE_UNCLEARED` needs `gstack-pregate clear` with the run URL. Both stop publication. diff --git a/ship/sections/tests.md b/ship/sections/tests.md index b243e4c5e..14a488649 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -214,10 +214,10 @@ Only commit if there are changes. Stage the bootstrap's own files by name (confi ``` Stage 1 (regen, secrets, strays, literals) is the two-minute preflight; stage 2 -runs the local lanes the touched files select under pipefail and prints -`requires-remote` for platform lanes this machine cannot run. `fail` or -`incomplete` blocks: fix, rerun (`--explain` shows each mapping). Step 18 refuses -a stale `pregate.json`, so rerun it after the final stamp. +runs the selected lanes under pipefail. `fail` or `incomplete` blocks: fix, +rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs +`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun +after the final stamp; Step 18 refuses a stale one. Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. The commands below are examples only for repositories that actually provide them. Use the same lane labels and exact commands again in Step 16. diff --git a/ship/sections/tests.md.tmpl b/ship/sections/tests.md.tmpl index 9ff92a1a6..5b0fa257f 100644 --- a/ship/sections/tests.md.tmpl +++ b/ship/sections/tests.md.tmpl @@ -13,10 +13,10 @@ ``` Stage 1 (regen, secrets, strays, literals) is the two-minute preflight; stage 2 -runs the local lanes the touched files select under pipefail and prints -`requires-remote` for platform lanes this machine cannot run. `fail` or -`incomplete` blocks: fix, rerun (`--explain` shows each mapping). Step 18 refuses -a stale `pregate.json`, so rerun it after the final stamp. +runs the selected lanes under pipefail. `fail` or `incomplete` blocks: fix, +rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs +`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun +after the final stamp; Step 18 refuses a stale one. Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. The commands below are examples only for repositories that actually provide them. Use the same lane labels and exact commands again in Step 16. diff --git a/test/autoplan-answer-invalidation.test.ts b/test/autoplan-answer-invalidation.test.ts index 0b14209a7..48dd028bd 100644 --- a/test/autoplan-answer-invalidation.test.ts +++ b/test/autoplan-answer-invalidation.test.ts @@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, journal, readRun, resultOf, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -31,9 +31,9 @@ function gatedRun(name: string): string { review(path.join(out, 'ceo-native-prompt.md')); review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree }); const ids = attemptsOf(out, 'ceo'); - expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); - const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'gpt-5.5', '--attempt', ids.outside]); - expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); + const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]); + expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=0 unverified=1 disagree=1 new=1/); const eng = runPhase(out, 'eng'); expect(eng.outside.stdout).toContain('status=gate_pending'); return out; @@ -112,26 +112,31 @@ describe('answer', () => { expect(readRun(out).attempt_counter).toBe(counter); }); - test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => { + test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => { const out = gatedRun('reopen'); const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']); expect(r.status, r.stderr).toBe(0); const runId = readRun(out).run; - expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] }); + expect(JSON.parse(r.stdout)).toMatchObject({ status: 'amend_pending', outcome: 'amend_pending', reopened: [], amendments: [`${runId}-d1`] }); + expect(readRun(out).phases.ceo.closed_at).toBeTruthy(); + expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]); + // the accepted change is applied to the plan and named; revise reopens the phases that reviewed the old plan, once + const revised = incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.'); + expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); const run = readRun(out); expect(run.phases.ceo).toEqual({}); expect(run.phases.eng).toEqual({}); expect(run.phases.design.skipped).toBeTruthy(); expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]); - for (const f of ['ceo-native.r1.md', 'ceo-outside.r1.md', 'ceo-consensus.r1.json', 'eng-native.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); - for (const f of ['ceo-native.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); - expect(Object.keys(run.artifacts)).not.toContain('ceo-native.md'); + expect(run.status).toBe('running'); + for (const f of ['ceo-native-prompt.r1.md', 'ceo-outside-prompt.r1.md', 'ceo-consensus.r1.json', 'eng-native-prompt.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); + for (const f of ['ceo-native-prompt.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); + // sealed copies are attempt-owned: they stay in place and bound across the reopen + const sealed = run.history.flatMap((h: any) => ['native', 'outside'].map(v => h.previous[v].output)); + expect(sealed.every((o: string) => /\.a\d+\.bound\.md$/.test(o) && fs.existsSync(path.join(out, o)) && run.artifacts[o])).toBe(true); const d1 = decisions(out).find((d: any) => d.label === 'D1'); expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 }); expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']); - // the accepted change is applied to the plan and named before any phase reruns; the reopened phases stay reopened once - incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.'); - expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']); // the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones const ceo = runPhase(out, 'ceo'); expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m); @@ -160,16 +165,16 @@ describe('answer', () => { expect(valid.status).toBe(0); }); - test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { + test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { const out = gatedRun('eng-only'); const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']); const runId = readRun(out).run; - expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] }); + expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] }); expect(readRun(out).phases.ceo.closed_at).toBeTruthy(); - // the revised plan changes the hash ceo reviewed, so revise (not the answer) reruns ceo on v2 before eng + // the revised plan changes the hash both phases reviewed, so revise (not the answer) reruns ceo, then eng, on v2 const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.'); - expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo$/m); - expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['eng', expect.anything()], ['ceo', expect.stringContaining('revise')]]); + expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); + expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['ceo', expect.stringContaining('revise')], ['eng', expect.stringContaining('revise')]]); expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/); const eng = runPhase(out, 'eng'); expect(eng.outside.stdout).toContain('status=gate_pending'); diff --git a/test/autoplan-capy-e2e.test.ts b/test/autoplan-capy-e2e.test.ts index 0c174d1f6..f559646b8 100644 --- a/test/autoplan-capy-e2e.test.ts +++ b/test/autoplan-capy-e2e.test.ts @@ -12,7 +12,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; -import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -138,14 +138,14 @@ describe('conformance: one run through every mechanism', () => { const run = readRunJson(s.out).run; // ceo: native canonical (fixture), outside attested record ok(c.review(path.join(s.out, 'ceo-native-prompt.md'))); - ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(s.out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(s.out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5'])); const hand = path.join(s.dir, 'ceo-outside-hand.md'); fs.writeFileSync(hand, '| Severity | Title |\n|---|---|\n| Medium | Retry storage cost is unpriced |\n'); ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--record', hand, '--model', 'openai/gpt-6.1-sol', '--attest', 'Capy subagent', '--attempt', readRunJson(s.out).phases.ceo.outside.attempt])); // eng: native challenge with changes, outside unavailable ok(c.ap(['next', '--out', s.out])); ok(c.review(path.join(s.out, 'eng-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'High', title: 'Gate the backoff behind a flag', user_challenge: true, changes: 'scope', fix: 'RETRY_BACKOFF=1' }]) })); - ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', path.join(s.out, 'eng-native.md'), '--model', 'anthropic/claude-opus-5-5'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(s.out, 'eng', 'native'), '--model', 'anthropic/claude-opus-5-5'])); expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending'); const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id; // partial, then full @@ -166,7 +166,7 @@ describe('conformance: one run through every mechanism', () => { const unknown = [...resumed.stdout.matchAll(/^EXECUTION_UNKNOWN: (\S+)$/gm)].map(m => m[1]!); ok(c.ap(['next', '--out', s.out, ...unknown.flatMap(a => ['--redispatch', a])])); for (const voice of ['native', 'outside']) ok(c.review(path.join(s.out, `ceo-${voice}-prompt.md`))); - for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', path.join(s.out, `ceo-${voice}.md`), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol'])); + for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', resultOf(s.out, 'ceo', voice as 'native' | 'outside'), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol'])); phaseVia(c, s.out, 'eng'); expect(readRunJson(s.out).gate_rev).toBe(2); ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d'])); @@ -181,6 +181,6 @@ describe('conformance: one run through every mechanism', () => { expect(final.gate_rev).toBe(3); expectUniqueIds(s.out); expectReviewedHashes(s.out); - expect(fs.readFileSync(path.join(s.out, `ceo-native.md`), 'utf8')).toContain(final.phases.eng.snapshot); + expect(fs.readFileSync(boundOf(s.out, 'ceo', 'native'), 'utf8')).toContain(final.phases.eng.snapshot); }); }); diff --git a/test/autoplan-checkpoint.test.ts b/test/autoplan-checkpoint.test.ts new file mode 100644 index 000000000..72aac6ab3 --- /dev/null +++ b/test/autoplan-checkpoint.test.ts @@ -0,0 +1,183 @@ +/** + * Durable checkpoint (next-wave B12, integration fixes): with + * GSTACK_AUTOPLAN_CHECKPOINT_DIR set, a run at rest publishes an immutable, + * hash-listed generation under <dir>/<run>/generations/ and switches + * current.json to it; journal paths are run-relative, so `resume --from` + * restores the run on a new directory (even with the owner's plan file gone, + * twice over), takes ownership, and the run answers, revises and exports a + * directory `gstack-artifact validate` accepts. A checkpoint never replaces + * or deletes the live run, never replaces a newer generation, and a failure + * after a committed command is a warning. Restore picks the newest valid + * generation and refuses tampered, escaping (by path or symlink), invalid or + * overlapping input, writing nothing. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-checkpoint-')); +afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); +const validate = (out: string) => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); +const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const current = (collection: string) => JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8')); +const generations = (collection: string) => fs.readdirSync(path.join(collection, 'generations')).sort(); + +/** A small run brought to gate_pending with the checkpoint env on for the last submit. */ +function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record<string, string> } { + const dir = path.join(TMP, name); + const root = path.join(dir, 'drive'); + const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }); + const out = startSmallRun(dir); + runPhase(out, 'ceo'); + expect(autoplan(['next', '--out', out], { env: e }).stderr).not.toContain('CHECKPOINT:'); + for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`)); + expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7'], { env: e }).status).toBe(0); + const gated = autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'outside', '--result', resultOf(out, 'eng', 'outside'), '--model', 'openai/gpt-6-astra'], { env: e }); + expect(gated.stdout).toContain('status=gate_pending'); + const run = readRun(out).run; + const collection = path.join(root, run); + expect(gated.stderr).toMatch(new RegExp(`^CHECKPOINT: ${esc(path.join(collection, 'generations'))}/g\\d{6}-\\S+ status=gate_pending gate_rev=1 revision=\\d+ files=\\d+$`, 'm')); + return { dir, out, root, collection, run, e }; +} + +describe('checkpoint and restore', () => { + test('write at gate_pending, delete the run and the plan file, restore twice, answer, revise and export: validate passes', () => { + const c = checkpointed('roundtrip'); + const [gen] = generations(c.collection); + expect(current(c.collection)).toMatchObject({ generation: gen, run: c.run, status: 'gate_pending', gate_rev: 1 }); + const manifest = JSON.parse(fs.readFileSync(path.join(c.collection, 'generations', gen!, 'checkpoint.json'), 'utf8')); + expect(Object.keys(manifest.files)).toEqual(expect.arrayContaining(['run.json', 'attempts.jsonl', 'gate.json', 'decisions.jsonl'])); + expect(Object.keys(manifest.files)).not.toContain('run.lock'); + expect(fs.readFileSync(path.join(c.collection, 'restore.md'), 'utf8')).toContain(`gstack-autoplan resume --out <new run dir> --from ${c.collection}`); + for (const r of journal(c.out)) for (const k of ['prompt', 'result', 'staging']) if (typeof r[k] === 'string') expect(path.isAbsolute(r[k])).toBe(false); + fs.rmSync(c.out, { recursive: true, force: true }); + fs.rmSync(path.join(c.dir, 'plan.md')); + const first = path.join(c.dir, 'first', 'run'); + const resumed = autoplan(['resume', '--out', first, '--from', c.collection], { env: c.e }); + expect(resumed.status, resumed.stderr).toBe(0); + expect(resumed.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=.* status=gate_pending gate_rev=1 revision=\\d+ files=\\d+ `, 'm')); + expect(resumed.stdout).toMatch(/^PLAN_INPUT: .*plan\.md is missing; restored the checkpoint copy to .*first\/run\/plan-input\/plan\.md$/m); + expect(resumed.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${first}`); + expect(journal(first).find(r => r.event === 'restored')).toMatchObject({ host: os.hostname() }); + expect(resumed.stderr).toMatch(/^CHECKPOINT: .* status=gate_pending /m); + fs.rmSync(first, { recursive: true, force: true }); + const second = path.join(c.dir, 'second', 'run'); + expect(autoplan(['resume', '--out', second, '--from', c.collection]).status).toBe(0); + const state = readRun(second); + expect(state.plan_input).toBe(path.join(second, 'plan-input', 'plan.md')); + expect(state.plan_source.startsWith(second + path.sep)).toBe(true); + expect(state.active_plan).toBe(path.join(second, 'plan-active.md')); + const answered = autoplan(['answer', '--out', second, '--gate-rev', '1', '--reply', 'p1d', '--by', 'human'], { env: c.e }); + expect(answered.status, answered.stderr).toBe(0); + fs.writeFileSync(state.plan_input, SMALL_PLAN.replace('Tests cover', 'Ship behind RETRY_BACKOFF=1. Tests cover')); + const revised = autoplan(['revise', '--out', second, '--plan', state.plan_input]); + expect(revised.status, revised.stderr).toBe(0); + runPhase(second, 'ceo'); + runPhase(second, 'eng'); + expect(autoplan(['answer', '--out', second, '--gate-rev', '2', '--reply', 'as-is', '--by', 'human']).stdout).toContain('status=complete'); + expect(autoplan(['export', '--out', second]).status).toBe(0); + const v = validate(second); + expect(v.status, v.stdout + v.stderr).toBe(0); + }); + + test('`checkpoints` lists every generation; a corrupt newest is skipped for the newest valid; an interrupted publish keeps the previous current', () => { + const c = checkpointed('generations'); + const [older] = generations(c.collection); + const faulted = autoplan(['export', '--out', c.out], { env: { ...c.e, GSTACK_AUTOPLAN_FAULT: 'checkpoint-before-pointer' } }); + expect(faulted.status, faulted.stderr).toBe(0); + expect(faulted.stderr).toMatch(/^CHECKPOINT: failed for .*fault injected: checkpoint interrupted before switching current\.json.*; the command's result stands, the previous checkpoint stays current$/m); + expect(current(c.collection).generation).toBe(older); + const newer = generations(c.collection).find(g => g !== older)!; + const listed = autoplan(['checkpoints', '--from', c.root]); + expect(listed.status, listed.stderr).toBe(0); + expect(listed.stdout.trim().split('\n')).toEqual([ + expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${newer} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok$`)), + expect.stringMatching(new RegExp(`^CHECKPOINT: \\S+/${older} run=${c.run} gate_rev=1 status=gate_pending revision=\\d+ at=\\S+ hash=ok current$`)), + ]); + fs.appendFileSync(path.join(c.collection, 'generations', newer, 'run', 'gate.json'), ' '); + expect(autoplan(['checkpoints', '--from', c.collection]).stdout).toMatch(new RegExp(`${newer} .* hash=bad \\(gate\\.json does not match its sha256 in checkpoint\\.json\\)$`, 'm')); + const target = path.join(c.dir, 'restored'); + const explicit = autoplan(['resume', '--out', target, '--from', path.join(c.collection, 'generations', newer)]); + expect(explicit.status).toBe(3); + expect(explicit.stderr).toMatch(/gate\.json does not match its sha256.*\(CHECKPOINT_INVALID\)/); + expect(fs.existsSync(target)).toBe(false); + const newest = autoplan(['resume', '--out', target, '--from', c.collection]); + expect(newest.status, newest.stderr).toBe(0); + expect(newest.stdout).toMatch(new RegExp(`^CHECKPOINT: skipped \\S+/${newer} \\(gate\\.json does not match`, 'm')); + expect(newest.stdout).toMatch(new RegExp(`^RESTORED: run=${c.run} from=\\S+/${older} `, 'm')); + }); + + test('a stale copy never replaces a newer generation', () => { + const c = checkpointed('newer'); + const stale = path.join(c.dir, 'stale'); + fs.cpSync(c.out, stale, { recursive: true }); + for (let i = 0; i < 3; i++) expect(autoplan(['export', '--out', c.out], { env: c.e }).status).toBe(0); + const before = current(c.collection); + const r = autoplan(['export', '--out', stale], { env: c.e }); + expect(r.status, r.stderr).toBe(0); + expect(r.stderr).toMatch(/^CHECKPOINT: skipped .*: it holds a newer checkpoint \(revision \d+ > \d+\); nothing replaced$/m); + expect(current(c.collection)).toEqual(before); + }); + + test('a checkpoint directory that is, contains or aliases the live run is refused before anything moves; the command still succeeds', () => { + const c = checkpointed('overlap'); + const root = path.join(c.dir, 'root'); + fs.mkdirSync(root); + const live = path.join(root, c.run); + fs.renameSync(c.out, live); + const same = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }) }); + expect(same.status, same.stderr).toBe(0); + expect(same.stderr).toMatch(/^CHECKPOINT: failed for .*\(CHECKPOINT_OVERLAP\); the command's result stands/m); + expect(readRun(live).run).toBe(c.run); + const inner = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: path.join(live, 'ckpt') }) }); + expect(inner.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/); + expect(fs.existsSync(path.join(live, 'ckpt'))).toBe(false); + const aliasRoot = path.join(c.dir, 'alias'); + fs.mkdirSync(aliasRoot); + fs.symlinkSync(live, path.join(aliasRoot, c.run)); + const alias = autoplan(['export', '--out', live], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: aliasRoot }) }); + expect(alias.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/); + expect(readRun(live).run).toBe(c.run); + expect(fs.existsSync(path.join(live, 'run.json'))).toBe(true); + const restoreInto = autoplan(['resume', '--out', path.join(c.collection, 'inside'), '--from', c.collection]); + expect(restoreInto.status).toBe(3); + expect(restoreInto.stderr).toMatch(/\(CHECKPOINT_OVERLAP\)/); + }); + + test('tampered, escaping, symlink-escaping and non-validating checkpoints and a non-empty target are refused and write nothing', () => { + const c = checkpointed('refusals'); + const gen = path.join(c.collection, 'generations', generations(c.collection)[0]!); + const target = path.join(c.dir, 'restored'); + const refused = (pattern: RegExp, code = 'CHECKPOINT_INVALID') => { + const r = autoplan(['resume', '--out', target, '--from', gen]); + expect(r.status).toBe(3); + expect(r.stderr).toMatch(new RegExp(`${pattern.source}.*\\(${code}\\)`)); + expect(fs.existsSync(target)).toBe(false); + }; + const nonEmpty = path.join(c.dir, 'busy'); + fs.mkdirSync(nonEmpty, { recursive: true }); + fs.writeFileSync(path.join(nonEmpty, 'keep.txt'), 'x'); + const busy = autoplan(['resume', '--out', nonEmpty, '--from', gen]); + expect(busy.stderr).toMatch(/\(CHECKPOINT_TARGET_EXISTS\)/); + expect(fs.readdirSync(nonEmpty)).toEqual(['keep.txt']); + const manifestFile = path.join(gen, 'checkpoint.json'); + const m = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + const snapDir = Object.keys(m.files).find(f => f.includes('/'))!.split('/')[0]!; + const outsideCopy = path.join(c.dir, 'outside-copy'); + fs.cpSync(path.join(gen, 'run', snapDir), outsideCopy, { recursive: true }); + fs.rmSync(path.join(gen, 'run', snapDir), { recursive: true }); + fs.symlinkSync(outsideCopy, path.join(gen, 'run', snapDir)); + refused(/escapes the checkpoint through a symlinked directory/); + fs.rmSync(path.join(gen, 'run', snapDir)); + fs.cpSync(outsideCopy, path.join(gen, 'run', snapDir), { recursive: true }); + fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, '../../escape.md': m.files['run.json'] } })); + refused(/escapes the checkpoint/); + const gateFile = path.join(gen, 'run', 'gate.json'); + fs.appendFileSync(gateFile, '\n'); + fs.writeFileSync(manifestFile, JSON.stringify({ ...m, files: { ...m.files, 'gate.json': require('node:crypto').createHash('sha256').update(fs.readFileSync(gateFile)).digest('hex') } })); + refused(/the staged run does not validate: ARTIFACT_STALE gate\.json/); + }); +}); diff --git a/test/autoplan-driver.test.ts b/test/autoplan-driver.test.ts index 6131fd7b0..b7173dcdd 100644 --- a/test/autoplan-driver.test.ts +++ b/test/autoplan-driver.test.ts @@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => { test('the plan-specified commands and the amendment interlock are present', () => { for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed', - 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`all as-is`', 'PLAN_AMEND_PENDING', 'APPLY:']) { + 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', '--from <checkpoint>', 'PLAN_AMEND_PENDING', 'APPLY:']) { expect(doc).toContain(token); } }); diff --git a/test/autoplan-gate-artifacts.test.ts b/test/autoplan-gate-artifacts.test.ts new file mode 100644 index 000000000..9a8d5394a --- /dev/null +++ b/test/autoplan-gate-artifacts.test.ts @@ -0,0 +1,139 @@ +/** + * Next wave, Lane B artifacts (GBRA-40 items 7, 11, 14, 15, 18): + * B3 an empty gstack-findings fence (LF, CRLF, whitespace-only) is zero findings, not a missing fence; + * an opener without a closer and invalid rows are still refused. + * B7 canonical rows carry repo-relative paths; snapshot citations become the plan file and its source line. + * B8 `status` lists every open attempt with its result file. + * B9 `gstack-gate render --all` prints every item; paged output tells the relay to send every page. + * B6 a plan naming a minimum effect with no number gets a flagged PLAN_REALITY line under the plan approval item. + * B11 an outside voice submitted without --runner is labeled host-subagent in the consensus header. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { normalizeFindingFile } from '../lib/autoplan-plan-text'; +import { parseRecordFindings, parseReviewResult } from '../lib/autoplan-reconcile'; +import { renderGate, type GateList } from '../lib/gate-list'; +import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-gate-artifacts-')); +afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); +const SHA = 'a'.repeat(64); +const GATE = path.join(ROOT, 'bin', 'gstack-gate'); + +describe('B3: empty findings fence', () => { + const head = `INPUT: ceo ${SHA}\n\n## review\n\n`; + test('LF, CRLF and whitespace-only empty fences parse as zero findings in both grammars', () => { + for (const text of [`${head}\`\`\`gstack-findings\n\`\`\`\n`, `${head}\`\`\`gstack-findings\n\`\`\`\n`.replace(/\n/g, '\r\n'), `${head}\`\`\`gstack-findings\n \n\`\`\`\n`]) { + const r = parseReviewResult(text, { phase: 'ceo', sha256: SHA }); + expect(r.fenceFound).toBe(true); + expect(r.errors).toEqual([]); + expect(r.findings).toEqual([]); + expect(parseRecordFindings(text)).toEqual({ findings: [], source: 'fence', errors: [] }); + } + }); + test('an opener without a closer is no fence; an invalid row is refused; two fences still parse', () => { + expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"x"}\n`, { phase: 'ceo', sha256: SHA }).errors).toContain('no ```gstack-findings fence'); + expect(parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"Bogus","title":"x"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA }).errors[0]).toMatch(/severity "Bogus"/); + const two = parseReviewResult(`${head}\`\`\`gstack-findings\n{"severity":"High","title":"a"}\n\`\`\`\n\n\`\`\`gstack-findings\n\`\`\`\n\n\`\`\`gstack-findings\n{"severity":"Low","title":"b"}\n\`\`\`\n`, { phase: 'ceo', sha256: SHA }); + expect(two.errors).toEqual([]); + expect(two.findings.map(f => f.title)).toEqual(['a', 'b']); + }); +}); + +describe('B7: repo-relative paths and plan lines', () => { + test('absolute repo paths become relative; snapshot and active-plan citations map to the plan source line or drop the line', () => { + const repo = path.join(TMP, 'repo'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + const source = path.join(TMP, 'plan-source.md'); + fs.writeFileSync(source, '# Title\n\n## Implementation plan\n\nline A\nline B\nline C\n\n## Review record\n'); + const snapDir = path.join(TMP, 'autoplan-ceo-Xy12'); + fs.mkdirSync(snapDir, { recursive: true }); + const snapshot = path.join(snapDir, 'ceo-implementation.md'); + fs.writeFileSync(snapshot, '\nline A\nline B\nline C\n\n'); + const active = path.join(TMP, 'plan-active.md'); + fs.writeFileSync(active, `<!-- /autoplan restore point: "${TMP}/restore.md" -->\n${fs.readFileSync(source, 'utf8')}`); + const stray = path.join(TMP, 'other-snapshot.md'); + fs.writeFileSync(stray, 'unrelated\n'); + const ctx = { cwd: path.join(repo, 'src'), planFiles: [snapshot, active, stray], planSource: source, planName: 'plan.md' }; + expect(normalizeFindingFile({ file: path.join(repo, 'src', 'worker.ts'), line: 9 }, ctx)).toEqual({ file: 'src/worker.ts', line: 9 }); + expect(normalizeFindingFile({ file: snapshot, line: 3 }, ctx)).toEqual({ file: 'plan.md', line: 6 }); + expect(normalizeFindingFile({ file: 'ceo-implementation.md', line: 2 }, ctx)).toEqual({ file: 'plan.md', line: 5 }); + expect(normalizeFindingFile({ file: active, line: 6 }, ctx)).toEqual({ file: 'plan.md', line: 5 }); + expect(normalizeFindingFile({ file: stray, line: 1 }, ctx)).toEqual({ file: 'plan.md' }); + expect(normalizeFindingFile({ file: '/elsewhere/x.ts', line: 1 }, ctx)).toEqual({ file: '/elsewhere/x.ts', line: 1 }); + expect(normalizeFindingFile({ file: 'lib/x.ts', line: 4 }, ctx)).toEqual({ file: 'lib/x.ts', line: 4 }); + }); + + test('end to end: a reviewer citing the snapshot and an absolute repo path binds repo-relative rows; the bound copy stays verbatim', () => { + const out = startSmallRun(path.join(TMP, 'paths')); + expect(autoplan(['next', '--out', out]).status).toBe(0); + const snap = readRun(out).phases.ceo.snapshot_path as string; + const findings = JSON.stringify([{ severity: 'High', title: 'cap unexplained', file: snap, line: 2 }, { severity: 'Low', title: 'abs path', file: path.join(ROOT, 'lib', 'gate-list.ts'), line: 3 }]); + review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: findings }); + const ids = attemptsOf(out, 'ceo'); + const result = readRun(out).phases.ceo.native.attempts[0].result as string; + const bind = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', result, '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native]); + expect(bind.status, bind.stderr).toBe(0); + const output = readRun(out).phases.ceo.native.output as string; + const rows = fs.readFileSync(path.join(out, `${output}.findings.jsonl`), 'utf8').trim().split('\n').map(l => JSON.parse(l)); + expect(rows.map((r: any) => [r.file, r.line])).toEqual([['plan.md', 5], ['lib/gate-list.ts', 3]]); + expect(fs.readFileSync(path.join(out, output), 'utf8')).toContain(snap); + }); +}); + +describe('B8/B11: pending attempts in status; outside voice labeled host-subagent', () => { + test('status prints PENDING per open attempt; an outside submit without --runner reads model (host-subagent)', () => { + const out = startSmallRun(path.join(TMP, 'pending')); + expect(autoplan(['next', '--out', out]).status).toBe(0); + const ids = attemptsOf(out, 'ceo'); + const run = readRun(out); + const status = autoplan(['status', '--out', out]); + expect(status.stdout).toContain(`PENDING: ${ids.native} phase=ceo voice=native result=${run.phases.ceo.native.attempts[0].result}`); + expect(status.stdout).toContain(`PENDING: ${ids.outside} phase=ceo voice=outside result=${run.phases.ceo.outside.attempts[0].result}`); + for (const voice of ['native', 'outside']) review(path.join(out, `ceo-${voice}-prompt.md`)); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(autoplan(['status', '--out', out]).stdout).not.toContain(`PENDING: ${ids.native}`); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts[0].result, '--model', 'openai/gpt-6.1-sol']).status).toBe(0); + expect(readRun(out).phases.ceo.outside.provider).toBe('host-subagent'); + expect(fs.readFileSync(path.join(out, 'ceo-consensus.md'), 'utf8')).toContain('openai/gpt-6.1-sol(host-subagent)'); + expect(autoplan(['status', '--out', out]).stdout).not.toContain('PENDING:'); + }); +}); + +describe('B9: every gate page reaches the owner', () => { + const item = (i: number) => ({ id: `d${i}`, title: `finding ${i}`, kind: 'auto' as const, recommended: 'a', options: [{ key: 'a', text: 'keep' }, { key: 'b', text: 'change' }] }); + const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [...Array.from({ length: 14 }, (_, i) => item(i + 1)), { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'a', options: [{ key: 'a', text: 'approve' }, { key: 'b', text: 'other' }] }] }; + test('14 findings span three pages; --all prints every id once and paged output says to relay every page', () => { + expect(renderGate(list)).toContain('or every page with `--all`. Relay every page.'); + const file = path.join(TMP, 'big-gate.json'); + fs.writeFileSync(file, JSON.stringify(list)); + const r = spawnSync('bun', [GATE, 'render', file, '--all'], { encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('page=1/1'); + for (const i of list.items) expect(r.stdout.match(new RegExp(`\\[${i.id}\\]`, 'g'))?.length).toBe(1); + const pages = [1, 2, 3].map(p => renderGate(list, p)).join('\n'); + for (const i of list.items) expect(pages).toContain(`[${i.id}]`); + expect(spawnSync('bun', [GATE, 'render', file, '--all', '--page', '2'], { encoding: 'utf8', timeout: 30_000 }).status).toBe(2); + }); +}); + +describe('B6: PLAN_REALITY flag beside the approval item', () => { + test('the plan names a minimum effect with no number: gate.json p1 carries the flag and the render prints it; nothing blocks', () => { + const dir = path.join(TMP, 'vague'); + fs.mkdirSync(dir, { recursive: true }); + const plan = path.join(dir, 'plan.md'); + fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover the cap', 'Ship once the minimum effect shows in the retry metrics. Tests cover the cap')); + const out = path.join(dir, 'run'); + expect(autoplan(['next', '--out', out, '--plan', plan, '--no-ui']).status).toBe(0); + for (const phase of ['ceo', 'eng']) { const r = runPhase(out, phase); expect(r.outside.status, r.outside.stderr).toBe(0); } + const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList; + const p1 = gate.items.find(i => i.id === 'p1')!; + expect(p1.flags).toEqual([expect.stringMatching(/^PLAN_REALITY "minimum effect" has no number, arm list or rule body \(plan\.md:6: .*minimum effect.*\) \(report only\)$/)]); + expect(renderGate(gate)).toMatch(/\n flagged: PLAN_REALITY "minimum effect"/); + expect(readRun(out).status).toBe('gate_pending'); + }); +}); diff --git a/test/autoplan-gate-judgment.test.ts b/test/autoplan-gate-judgment.test.ts index 0baf3ba19..02e841837 100644 --- a/test/autoplan-gate-judgment.test.ts +++ b/test/autoplan-gate-judgment.test.ts @@ -1,24 +1,23 @@ /** - * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments): - * findings carry `changes`; a user challenge both voices raised on a confirmed - * finding with the same `changes` and both remedies recommends `b`, rendering - * both remedies verbatim and the basis; anything weaker keeps `a` with its - * basis. Reopen and APPLY key on the chosen option's `changes_plan`; accepted - * changes are pending amendments that `next` refuses past (PLAN_AMEND_PENDING) - * until `revise --incorporates` binds them to a changed plan or `--withdraw` - * drops them. `all as-is` keeps every direction; `all` leaves an item - * recommended `b` pending (the owner's own token changes their direction; - * field gate rev 1, d3) and the GATE_PENDING line names the tokens. Partial - * replies resolve - * against persisted decisions. On reruns a challenge without `changes` is an - * agent decision that reopens nothing, rerun prompts carry the decided items, - * and answered items from phases that did not rerun carry forward. + * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments and + * next-wave B1/B2/B5): findings carry `changes`; a user challenge both voices + * raised on a confirmed finding with the same `changes` recommends `b` only + * with one shared proposal (the parent's override, or the same remedy from + * both), rendering both remedies verbatim and the basis; anything weaker keeps + * `a` with its basis. APPLY keys on the chosen option's `changes_plan`; + * accepted changes are pending amendments (status amend_pending, nothing + * reopens at answer) that `next` refuses past (PLAN_AMEND_PENDING) until + * `revise --incorporates` binds them to a changed plan or `--withdraw` drops + * them. `as-is` keeps every direction; `rec` (alias `all`) takes every + * recommendation. Partial replies resolve against persisted decisions. On + * reruns a challenge without `changes` is an agent decision that reopens + * nothing, and rerun prompts carry the decided items. */ import { afterAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -33,6 +32,14 @@ const DISAGREE = { native: [{ id: 'F1', severity: 'Critical', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 10, user_challenge: true, changes: 'scope', fix: 'new table' }], outside: [{ id: 'O1', severity: 'Low', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 11, user_challenge: true, changes: 'scope', fix: 'keep the column' }], }; +const SAME = { + native: [{ id: 'F4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'Cap stored attempts at 50 per tenant.' }], + outside: [{ id: 'O4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'cap stored attempts at 50 per tenant' }], +}; +const PACKAGING = { + native: [{ id: 'F5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'land the schedule change as its own PR' }], + outside: [{ id: 'O5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'Land the schedule change as its own PR.' }], +}; const MISMATCH = { native: [{ id: 'F1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'scope', fix: 'remove jitter' }], outside: [{ id: 'O1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'cost', fix: 'remove jitter' }], @@ -54,34 +61,37 @@ const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md` describe('changes and user-challenge defaults', () => { test('`changes` reaches findings.jsonl, the schema and decisions; an unknown value is refused', () => { const g = gated(AGREE, {}); - expect(readJsonl(path.join(g.out, 'ceo-native.md.findings.jsonl'))[0]).toMatchObject({ changes: 'scope', user_challenge: true }); + expect(readJsonl(`${boundOf(g.out, 'ceo', 'native')}.findings.jsonl`)[0]).toMatchObject({ changes: 'scope', user_challenge: true }); const schema = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'schema', 'findings'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); - expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction'] }); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'b', recommended_basis: 'both voices; remedies shown' }); + expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); const dir = path.join(r.host.base, 'badchanges'); const out = path.join(dir, 'run'); expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0); - expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'packaging' }]) }).status).toBe(0); - const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'naming' }]) }).status).toBe(0); + const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(bad.status).toBe(1); - expect(bad.stderr).toMatch(/changes "packaging" is not one of scope\|cost\|direction.*\(RESULT_FINDINGS_MISSING\)$/m); + expect(bad.stderr).toMatch(/changes "naming" is not one of scope\|cost\|direction\|packaging\|ordering.*\(RESULT_FINDINGS_MISSING\)$/m); }); - test('agreeing voices recommend b with both remedies verbatim; disagreement and mismatched changes keep a with the basis', () => { - const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' }))], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' }))] }, DISAGREE); + test('one shared remedy recommends b; different remedies show both verbatim and keep a; disagreement and mismatched changes keep a with the basis', () => { + const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' })), ...SAME.native], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' })), ...SAME.outside] }, DISAGREE); const items = gate(g.out).items; + const same = items.find((i: any) => i.title.startsWith('Cap the retry storage')); + expect(same).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; same remedy' }); const agree = items.find((i: any) => i.title.startsWith('Ship the backoff')); - expect(agree).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; remedies shown' }); + expect(agree).toMatchObject({ kind: 'approval', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); expect(agree.options.find((o: any) => o.key === 'b')).toMatchObject({ changes_plan: true }); expect(agree.options.find((o: any) => o.key === 'b').text).toContain('native: gate the change behind RETRY_BACKOFF=1; outside: add a kill switch flag for the new schedule'); expect(agree.options.find((o: any) => o.key === 'a').changes_plan).toBe(false); expect(items.find((i: any) => i.title.startsWith('Drop the jitter'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices name different changes' }); expect(items.find((i: any) => i.title.startsWith('Persist attempts'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices disagree' }); const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(g.out, 'gate.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); - expect(render.stdout).toMatch(new RegExp(`\\[${agree.id}\\] .*recommended: b \\(both voices; remedies shown\\)`)); + expect(render.stdout).toMatch(new RegExp(`\\[${same.id}\\] .*recommended: b \\(both voices; same remedy\\)`)); expect(render.stdout).toMatch(/recommended: a \(plan direction; voices disagree\)/); const p1 = items.find((i: any) => i.id === 'p1'); - expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${agree.id}b`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${same.id}b`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).not.toContain(`${agree.id}b`); }); test('a parent reconcile override naming one shared proposal renders that proposal as option b', () => { @@ -91,10 +101,10 @@ describe('changes and user-challenge defaults', () => { r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.native) }); r.review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.outside) }); const run = readRunJson(out); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); const overrides = path.join(dir, 'reconcile.jsonl'); fs.writeFileSync(overrides, JSON.stringify({ outside_id: `${run.run}-ceo-outside-1`, native_id: `${run.run}-ceo-native-1`, disposition: 'confirmed', resolution: 'same change', proposal: 'ship behind RETRY_BACKOFF=1, default off' }) + '\n'); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0); phaseVia(r, out, 'eng', {}); const uc = gate(out).items.find((i: any) => i.title.startsWith('Ship the backoff')); expect(uc).toMatchObject({ recommended: 'b', recommended_basis: 'both voices; shared proposal' }); @@ -103,23 +113,27 @@ describe('changes and user-challenge defaults', () => { }); describe('accepted changes are amendments', () => { - test('`all` leaves the agreeing change pending; its own token accepts it: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => { + test('an accepted change prints APPLY and NEXT revise and leaves amend_pending without reopening; next refuses until revise --incorporates binds it; revise reports what reopened', () => { const g = gated(AGREE, {}); - const held = answer(g.out, 1, 'all'); + const held = answer(g.out, 1, 'uc1b'); expect(held.status, held.stderr).toBe(0); - expect(held.stdout).toMatch(/^GATE_PENDING: uc1 unanswered \(reply with uc1a\|uc1b; all does not answer items recommended b\)$/m); + expect(held.stdout).toMatch(/^GATE_PENDING: uc2, p1 unanswered \(reply with uc2a\|uc2b p1a\|p1b\|p1c\|p1d\|p1e or rec\)$/m); expect(held.stdout).not.toMatch(/^APPLY:/m); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ status: 'pending' }); - const res = answer(g.out, 1, 'uc1b'); + const res = answer(g.out, 1, 'rec'); expect(res.status, res.stderr).toBe(0); - expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend`, 'm')); - expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=uc1b$/m); - expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); - expect(res.stdout).not.toMatch(/^NEXT: gstack-autoplan next/m); - expect(readRunJson(g.out).amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending' })]); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend.*; edit the plan, then revise$`, 'm')); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(res.stdout).toMatch(new RegExp(`^NEXT: edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); + expect(res.stdout).toContain('status=amend_pending'); + const before = readRunJson(g.out); + expect(before.status).toBe('amend_pending'); + expect(before.phases.ceo.closed_at).toBeTruthy(); + expect(before.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending', item: 'uc1', changes: 'scope' })]); + expect(r.ap(['status', '--out', g.out]).stdout).toMatch(new RegExp(`^AMEND_PENDING: ${esc(g.run)}-uc1 uc1 changes=scope -> edit the plan, then revise$`, 'm')); + expect(r.validate(g.out).status).toBe(0); const blocked = r.ap(['next', '--out', g.out]); expect(blocked.status).toBe(3); - expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm')); + expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1 \\(APPLY uc1: edit the plan, then revise\\).*\\(PLAN_AMEND_PENDING\\)$`, 'm')); const runFile = path.join(g.out, 'run.json'); const saved = fs.readFileSync(runFile, 'utf8'); fs.writeFileSync(runFile, JSON.stringify({ ...JSON.parse(saved), status: 'complete' }, null, 2) + '\n'); @@ -127,35 +141,110 @@ describe('accepted changes are amendments', () => { expect(exported.status).toBe(3); expect(exported.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm')); fs.writeFileSync(runFile, saved); - const unrelated = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s'))]); - expect(unrelated.status, unrelated.stderr).toBe(0); - expect(unrelated.stdout).toMatch(new RegExp(`^AMENDMENTS: pending=${esc(g.run)}-uc1$`, 'm')); - expect(r.ap(['next', '--out', g.out]).status).toBe(3); - const unchanged = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s')), '--incorporates', `${g.run}-uc1`]); + const unchanged = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', `${g.run}-uc1`]); expect(unchanged.status).toBe(3); expect(unchanged.stderr).toMatch(/did not change.*\(PLAN_AMEND_PENDING\)$/m); - const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s, behind RETRY_BACKOFF=1')), '--incorporates', `${g.run}-uc1`]); + const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc1']); expect(bound.status, bound.stderr).toBe(0); + expect(bound.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m); expect(bound.stdout).toMatch(/^AMENDMENTS: pending=none$/m); + expect(bound.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm')); const j = readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_incorporated'); expect(j).toMatchObject({ decision: `${g.run}-uc1` }); expect(j.old_plan_sha256).not.toBe(j.new_plan_sha256); + expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'plan_revised')).toMatchObject({ reopened: ['ceo', 'eng'] }); + expect(readRunJson(g.out).status).toBe('running'); expect(r.ap(['next', '--out', g.out]).status).toBe(0); }); - test('`all as-is` keeps every direction; --withdraw is a journaled owner decision', () => { + test('`as-is` keeps every direction; --withdraw is a journaled owner decision that completes the run when nothing else is pending', () => { const g = gated(AGREE, {}); - const asIs = answer(g.out, 1, 'all as-is'); + const asIs = answer(g.out, 1, 'as-is'); expect(asIs.status, asIs.stderr).toBe(0); expect(asIs.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m); expect(asIs.stdout).not.toMatch(/^APPLY:/m); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'overridden' }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'approved' }); const w = gated(AGREE, {}); - expect(answer(w.out, 1, 'all except uc1b').stdout).toMatch(/^APPLY:/m); + expect(answer(w.out, 1, 'rec except uc1b').stdout).toMatch(/^APPLY:/m); const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]); expect(withdraw.status, withdraw.stderr).toBe(0); + expect(withdraw.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); expect(readJsonl(path.join(w.out, 'attempts.jsonl')).find(e => e.event === 'amendment_withdrawn')).toMatchObject({ decision: `${w.run}-uc1` }); - expect(r.ap(['next', '--out', w.out]).status).toBe(0); + expect(readRunJson(w.out).status).toBe('complete'); + expect(r.validate(w.out).status).toBe(0); + }); +}); + +describe('record-only changes settle without a plan edit', () => { + test('a packaging change: APPLY says settle, revise --settle reopens nothing and completes, export and validate pass; a scope change never settles', () => { + const g = gated(PACKAGING, {}); + expect(gate(g.out).items.find((i: any) => i.id === 'uc1')).toMatchObject({ recommended: 'b', changes: 'packaging' }); + const res = answer(g.out, 1, 'rec'); + expect(res.status, res.stderr).toBe(0); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 .*; no plan edit needed: revise --settle uc1$`, 'm')); + expect(readRunJson(g.out).status).toBe('amend_pending'); + expect(r.ap(['next', '--out', g.out]).stderr).toMatch(/\(PLAN_AMEND_PENDING\)$/m); + const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc1']); + expect(settled.status, settled.stderr).toBe(0); + expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc1 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm')); + expect(settled.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); + expect(settled.stdout).not.toMatch(/^REOPENED:/m); + const run = readRunJson(g.out); + expect(run.status).toBe('complete'); + expect(run.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'settled', changes: 'packaging' })]); + expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_settled')).toMatchObject({ decision: `${g.run}-uc1`, changes: 'packaging' }); + expect(r.ap(['export', '--out', g.out]).status).toBe(0); + expect(r.validate(g.out).status).toBe(0); + const scope = gated(AGREE, {}); + expect(answer(scope.out, 1, 'rec except uc1b').stdout).toMatch(/; edit the plan, then revise$/m); + const refused = r.ap(['revise', '--out', scope.out, '--settle', 'uc1']); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/changes scope; only packaging or ordering changes settle.*\(AMENDMENT_NOT_SETTLEABLE\)$/m); + expect(readRunJson(scope.out).status).toBe('amend_pending'); + }); +}); + +describe('mixed amendments: a scope change and a packaging change', () => { + const SCOPE_SAME = { native: [{ ...SAME.native[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }], outside: [{ ...SAME.outside[0], title: 'Ship the backoff behind a flag first', fix: 'gate the change behind RETRY_BACKOFF=1' }] }; + const v2 = (dir: string) => v(dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')); + const mixed = () => { + const g = gated(SCOPE_SAME, PACKAGING); + const items = gate(g.out).items.filter((i: any) => i.id.startsWith('uc')); + expect(items.map((i: any) => `${i.id}:${i.recommended}:${i.changes}`)).toEqual(['uc1:b:scope', 'uc2:b:packaging']); + const res = answer(g.out, 1, 'rec'); + expect(res.status, res.stderr).toBe(0); + expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --settle uc2; then edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); + return g; + }; + test('incorporate first, then settle: the settle binds to the revised plan, the reopened phases rerun, and the run completes', () => { + const g = mixed(); + const inc = r.ap(['revise', '--out', g.out, '--plan', v2(g.dir), '--incorporates', 'uc1']); + expect(inc.status, inc.stderr).toBe(0); + expect(inc.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m); + const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc2']); + expect(settled.status, settled.stderr).toBe(0); + expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc2 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm')); + expect(settled.stdout).not.toMatch(/^APPROVED:/m); + expect(settled.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm')); + expect(readRunJson(g.out).status).toBe('running'); + phaseVia(r, g.out, 'ceo'); + expect(phaseVia(r, g.out, 'eng').outside.stdout).toContain('status=gate_pending'); + expect(answer(g.out, 2, 'rec').stdout).toContain('status=complete'); + expect(r.validate(g.out).status).toBe(0); + }); + test('settle and incorporate in one revise; settling alone on an unreviewed plan edit is refused', () => { + const g = mixed(); + fs.writeFileSync(g.plan, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')); + const early = r.ap(['revise', '--out', g.out, '--settle', 'uc2']); + expect(early.status).toBe(3); + expect(early.stderr).toMatch(/the plan file has edits the run has not reviewed.*\(AMENDMENT_NOT_SETTLEABLE\)$/m); + const both = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', 'uc1', '--settle', 'uc2']); + expect(both.status, both.stderr).toBe(0); + expect(both.stdout).toMatch(/^AMENDMENTS: pending=none$/m); + const run = readRunJson(g.out); + expect(run.status).toBe('running'); + expect(run.amendments.map((a: any) => `${a.item}:${a.status}`)).toEqual(['uc1:incorporated', 'uc2:settled']); + expect(r.ap(['next', '--out', g.out]).status).toBe(0); }); }); @@ -167,7 +256,7 @@ describe('partial replies resolve against persisted decisions', () => { const first = answer(g.out, 1, 'uc1b', true); expect(JSON.parse(first.stdout)).toMatchObject({ outcome: 'gate_pending', pending: ['uc2'] }); const second = answer(g.out, 1, 'all', true); - expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] }); + expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], status: 'amend_pending' }); expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'b', status: 'overridden' }); }); @@ -184,11 +273,12 @@ describe('partial replies resolve against persisted decisions', () => { }); describe('reruns', () => { - test('rerun prompts carry the decided items, a ceo item answered at rev 1 carries forward, and a rerun challenge without `changes` reopens nothing', () => { + test('rerun prompts carry the decided items, and a rerun challenge without `changes` reopens nothing', () => { const g = gated({}, AGREE); const res = answer(g.out, 1, 'uc1a uc2b p1a'); - expect(res.stdout).toMatch(/^REOPENED: eng \(Eng last\) reason=uc2b$/m); - expect(r.ap(['revise', '--out', g.out, '--withdraw', `${g.run}-uc2`]).status).toBe(0); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc2']).stdout).toMatch(/reopened=ceo,eng/); + phaseVia(r, g.out, 'ceo', {}); const eng = phaseVia(r, g.out, 'eng', { native: [{ id: 'F9', severity: 'Low', title: 'Name the PR branch per lane', user_challenge: true }], outside: [{ id: 'O9', severity: 'Medium', title: 'Price the retry storage before shipping', user_challenge: true, changes: 'cost' }], @@ -198,8 +288,6 @@ describe('reruns', () => { expect(prompt).toMatch(/uc2b .*Ship the backoff behind a flag first/); expect(prompt).toMatch(/uc1a /); expect(eng.outside.stdout).toContain('status=gate_pending'); - const rev2 = decisions(g.out).filter(d => d.gate_rev === 2); - expect(rev2.find(d => d.label === 'UC1')).toMatchObject({ status: 'approved', chosen: 'a', carried_from: `${g.run}-uc1` }); const items = gate(g.out).items; const demoted = items.find((i: any) => i.title.startsWith('Name the PR branch')); expect(demoted).toMatchObject({ kind: 'auto', reopens: false }); diff --git a/test/autoplan-pairing.test.ts b/test/autoplan-pairing.test.ts new file mode 100644 index 000000000..71e9ab67f --- /dev/null +++ b/test/autoplan-pairing.test.ts @@ -0,0 +1,63 @@ +/** + * Pairing negative control (next-wave B2/B-a): finding pairs copied verbatim + * from a real autoplan run. File:line proximity associates findings but never + * confirms them, and neither does a title match alone: a title match with + * the same remedy confirms, opposite remedies disagree, different remedies stay + * unverified; the parent's override with one shared proposal also confirms. + */ +import { describe, expect, test } from 'bun:test'; +import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json'; +import { reconcile } from '../lib/autoplan-reconcile'; +import { challengeDefault } from '../lib/autoplan-gate'; + +const pair = (name: string) => fixture.pairs.find(p => p.name.startsWith(name))! as any; +const challenge = (rows: any[], changes = 'direction') => rows.map(r => ({ ...r, user_challenge: true, changes })); + +describe('pairing negative control from a real run', () => { + test('two findings three lines apart on one file are associated but never confirmed, and never recommend b', () => { + const p = pair('ceo r1 outside-1'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row).toMatchObject({ disposition: 'unverified', basis: 'file_line', native_id: p.native[0].id }); + expect(row!.resolution).toStartWith('same location, agreement unverified (bin/gstack-capy-install:197)'); + const [n] = challenge(p.native), [o] = challenge(p.outside); + expect(challengeDefault(row!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; same location, agreement unverified' }); + }); + + test('a location match with different titles stays unverified until the parent confirms it with one proposal', () => { + const p = pair('dx r2 outside-2'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row!.disposition).toBe('unverified'); + const [confirmed] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'same trailer grammar break', proposal: 'keep `gstack: fail <components>` and specify the pins exit' }]).rows; + expect(challengeDefault(confirmed!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; shared proposal' }); + }); + + test('the same problem on different files is new until the parent associates it; different remedies keep a', () => { + const p = pair('dx r2 native-2'); + expect(reconcile(p.native, p.outside).rows[0]).toMatchObject({ disposition: 'new', native_id: null }); + const [row] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'both: a recommendation edit changes what a seen reply resolves to' }]).rows; + expect(challengeDefault(row!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' }); + }); + + test('the same title with opposite remedies disagrees, as an ordinary finding and as a user challenge; never b', () => { + const p = pair('constructed same title, opposite'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row).toMatchObject({ disposition: 'disagree', basis: 'title' }); + expect(row!.resolution).toStartWith('remedies conflict (same title): native "move attempts into a new retry_attempts table"'); + const [n] = challenge(p.native, 'scope'), [o] = challenge(p.outside, 'scope'); + const [crow] = reconcile([n], [o]).rows; + expect(crow!.disposition).toBe('disagree'); + expect(challengeDefault(crow!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; voices disagree' }); + }); + + test('the same title with different remedies is only associated; the same remedy confirms and recommends b', () => { + const differ = pair('constructed same title, different'); + const [row] = reconcile(differ.native, differ.outside).rows; + expect(row).toMatchObject({ disposition: 'unverified' }); + expect(row!.resolution).toStartWith('same title, remedies differ'); + expect(challengeDefault(row!, challenge(differ.native, 'scope')[0], challenge(differ.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' }); + const same = pair('constructed same title, same remedy'); + const [ok] = reconcile(same.native, same.outside).rows; + expect(ok).toMatchObject({ disposition: 'confirmed', basis: 'title' }); + expect(challengeDefault(ok!, challenge(same.native, 'scope')[0], challenge(same.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; same remedy' }); + }); +}); diff --git a/test/autoplan-record.test.ts b/test/autoplan-record.test.ts index 9de8316c6..345eef799 100644 --- a/test/autoplan-record.test.ts +++ b/test/autoplan-record.test.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; import { capyRunner, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; +import { boundOf, resultOf } from './helpers/autoplan-run-fixture'; const r = capyRunner(); afterAll(() => r.cleanup()); @@ -64,8 +65,8 @@ describe('submit --record', () => { expect(ok.stdout).toMatch(new RegExp(`^BOUND: ${attempt} phase=ceo voice=native .*findings=2 .*receipt=attested`, 'm')); const v = readRunJson(out).phases.ceo.native; expect(v).toMatchObject({ status: 'completed', receipt: 'attested', attest_reason: 'subagent ran from the prompt file', findings_source: 'table' }); - expect(fs.readFileSync(path.join(out, 'ceo-native.md'), 'utf8')).toBe(TABLE); - const rows = readJsonl(path.join(out, 'ceo-native.md.findings.jsonl')); + expect(fs.readFileSync(boundOf(out, 'ceo', 'native'), 'utf8')).toBe(TABLE); + const rows = readJsonl(`${boundOf(out, 'ceo', 'native')}.findings.jsonl`); expect(rows.map(x => [x.severity, x.title, x.file ?? null, x.line ?? null])).toEqual([['High', 'Retry cap is unbounded on restart', 'lib/worker.ts', 10], ['Medium', 'Split persistence change into its own plan', null, null]]); expect(readRunJson(out).deviations.some((d: string) => /attested.*ceo native/.test(d))).toBe(true); }); @@ -107,7 +108,7 @@ describe('submit --record', () => { test('the canonical --result path binds without --attempt when exactly one attempt is open', () => { const out = start(); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(ok.status, ok.stderr).toBe(0); expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=3/m); }); @@ -139,14 +140,14 @@ describe('submit --record', () => { expect(resumed.stdout).not.toContain(outsideAttempt); expect(readRunJson(out).phases.ceo.outside.status).toBe('unavailable'); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(close.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m); }); test('--failed records a failed outside voice and closes the phase once native is bound', () => { const out = start(); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); const failed = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner exited 1']); expect(failed.status, failed.stderr).toBe(0); expect(failed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m); diff --git a/test/autoplan-residuals.test.ts b/test/autoplan-residuals.test.ts index 43ee6497b..13260852b 100644 --- a/test/autoplan-residuals.test.ts +++ b/test/autoplan-residuals.test.ts @@ -1,23 +1,29 @@ /** * Field-wave residuals (gstack fix wave review, gate revs 1–2, 2026-10-12/13): - * R1 `all` never flips the owner's stated direction: a user-challenge item - * whose recommended option changes the plan stays pending under `all` and - * is answered only by its own token; the rendering says which ids `all` - * leaves open. + * R1 (superseded by next-wave B1, owner decision gate_rev 1): `rec` and its + * silent alias `all` take every recommendation, plan changes included; + * `as-is` keeps every direction; the render prints the `rec` resolution + * from the parser that applies it. * R2 every later phase sees its predecessors' consensus: the DX prompt * carries the CEO consensus, not only the Eng prompt. * R3 lib code spawns the Bun that runs it (`process.execPath`), never `bun` * by PATH name, so the snapshot tool and restamp cannot silently run the * Capy image's 1.3.14 when a parent forgot the launch PATH. * R5 gate answers carry provenance: `answer --by <human|agent:<id>>` is - * required in an unattended run and lands on every decision row. + * required in an unattended run and lands on every decision row; an agent + * answer also carries the owner's verbatim reply (next-wave B-c), and a + * parseable owner reply that resolves differently from the relay is refused. + * A gate.json written before `rec` (no rec_rev) refuses alias replies + * until the owner sees a re-render (--rec-hash); tokens still answer. + * B-b `recommend` changes what `rec` resolves to, so it bumps rec_rev and a + * `rec` must carry the rec_hash of the render the owner saw. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseReply, renderGate, type GateList } from '../lib/gate-list'; -import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, readRun, resultOf, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-residuals-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -34,7 +40,7 @@ const agreed: GateList = { }; const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); -/** Both voices challenge the same item with the same `changes` and a remedy each: the gate recommends `b`. */ +/** Both voices challenge the same item with the same `changes` and the same remedy: the gate recommends `b`. */ function agreedRun(name: string): string { const out = startSmallRun(path.join(TMP, name)); const native = JSON.stringify([ @@ -42,51 +48,82 @@ function agreedRun(name: string): string { { id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }]); const outside = JSON.stringify([ { id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL' }, - { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes A then B' }]); + { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'Stack the lanes.' }]); for (const phase of ['ceo', 'eng']) { expect(autoplan(['next', '--out', out]).status).toBe(0); review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native }); review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside }); const ids = attemptsOf(out, phase); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); } expect(readRun(out).status).toBe('gate_pending'); return out; } -describe('R1: `all` never flips the owner\'s stated direction', () => { - test('an approval item recommended b (changes the plan) stays pending under `all`; its own token answers it', () => { - const r = parseReply('all', agreed); - expect(r.valid).toBe(true); - expect(r.pending).toEqual(['uc1']); - expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc2a', 'd1a', 'p1a']); - const explicit = parseReply('all except uc1b', agreed); - expect(explicit.pending).toEqual([]); - expect(explicit.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'b', how: 'recommended' }); - const kept = parseReply('all except uc1a', agreed); - expect(kept.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); - expect(parseReply('all as-is', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); +describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan changes included', () => { + test('an approval item recommended b is answered by `rec`/`all`; `rec except uc1a` and `as-is` keep the direction', () => { + for (const reply of ['rec', 'all', 'approve all', 'Accept all', 'take all recommendations']) { + const r = parseReply(reply, agreed); + expect(r.valid).toBe(true); + expect(r.mode).toBe('rec'); + expect(r.pending).toEqual([]); + expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc1b', 'uc2a', 'd1a', 'p1a']); + } + expect(parseReply('rec except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + expect(parseReply('all except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + for (const reply of ['as-is', 'all as-is']) { + const r = parseReply(reply, agreed); + expect(r.mode).toBe('as-is'); + expect(r.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); + } + for (const bare of ['yes', 'no', 'rec d1', 'rec yes']) expect(parseReply(bare, agreed).valid).toBe(false); }); - test('the rendering names the ids `all` leaves open and their exact tokens', () => { + test('the rendering offers `rec` first and prints its resolution from the same parser, with the rec_hash', () => { const text = renderGate(agreed); - expect(text).toMatch(/`all` does not answer uc1 \(recommended b changes the plan\); reply uc1a or uc1b/); - expect(text).toMatch(/"choices":\{"uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/Reply with exactly one of: `rec` \(default: take every recommendation, plan changes included\)/); + expect(text).toMatch(/"choices":\{"uc1":"b","uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/rec_hash=[0-9a-f]{12}/); + expect(text).not.toMatch(/does not answer/); }); - test('end to end: both voices agree, the gate recommends b, and `all` keeps the gate pending on that item', () => { + test('end to end: both voices agree with one remedy, the gate recommends b, and `rec` accepts it as a pending amendment', () => { const out = agreedRun('agreed'); const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList; const uc = gate.items.filter(i => i.id.startsWith('uc')); expect(uc.length).toBeGreaterThan(0); - expect(uc.every(i => i.recommended === 'b')).toBe(true); - const all = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all', '--by', 'human']); - expect(all.status, all.stderr).toBe(0); - expect(all.stdout).toMatch(new RegExp(`GATE_PENDING: ${uc.map(i => i.id).join(', ')} unanswered \\(reply with ${uc.map(i => `${i.id}a|${i.id}b`).join(' ')}; all does not answer items recommended b\\)`)); - expect(readRun(out).status).toBe('gate_pending'); + expect(uc.every(i => i.recommended === 'b' && i.recommended_basis === 'both voices; same remedy')).toBe(true); + const rec = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human']); + expect(rec.status, rec.stderr).toBe(0); + for (const i of uc) expect(rec.stdout).toMatch(new RegExp(`^APPLY: ${readRun(out).run}-${i.id} .*; edit the plan, then revise$`, 'm')); + expect(rec.stdout).toContain('GSTACK_RESULT: skill=autoplan status=amend_pending'); + expect(readRun(out).status).toBe('amend_pending'); const rows = decisions(out); - for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase()).status).toBe('pending'); + for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase())).toMatchObject({ status: 'approved', chosen: 'b' }); + }); +}); + +describe('legacy gates: a gate.json written before `rec` (no rec_rev)', () => { + test('alias replies are refused until a re-render binds --rec-hash; explicit tokens still answer', () => { + const out = agreedRun('legacy'); + const gateFile = path.join(out, 'gate.json'); + const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')); + expect(gate.rec_rev).toBe(0); + delete gate.rec_rev; + fs.writeFileSync(gateFile, JSON.stringify(gate, null, 2) + '\n'); + const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'); + for (const reply of ['all', 'rec', 'approve all', 'all except uc1a']) { + const refused = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', reply, '--by', 'human']); + expect(refused.status, reply).toBe(3); + expect(refused.stderr).toMatch(/written before `rec`\/`all` took plan changes.*\(GATE_REC_STALE\)$/m); + } + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const tokens = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a', '--by', 'human']); + expect(tokens.status, tokens.stderr).toBe(0); + const hash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!; + const bound = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'human']); + expect(bound.status, bound.stderr).toBe(0); }); }); @@ -101,15 +138,54 @@ describe('R5: gate answers carry provenance', () => { const bad = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'me']); expect(bad.status).toBe(3); expect(bad.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/); - const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + const noOwner = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + expect(noOwner.status).toBe(3); + expect(noOwner.stderr).toMatch(/\(OWNER_REPLY_REQUIRED\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29', '--owner-reply', 'keep the plan as written']); expect(ok.status, ok.stderr).toBe(0); const answered = decisions(out).filter(r => r.status !== 'pending'); expect(answered.length).toBeGreaterThan(0); - for (const r of answered) expect(r.answered_by).toBe('agent:GSTA-29'); + for (const r of answered) expect(r).toMatchObject({ answered_by: 'agent:GSTA-29', owner_reply: 'keep the plan as written', owner_reply_parsed: false }); expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/answered by agent:GSTA-29/); }); }); +describe('B-b/B-c: recommend binds rec to the render the owner saw; the owner reply must match the relay', () => { + test('recommend bumps rec_rev and rewrites p1 and decisions; a rec without the new rec_hash is refused; a mismatched owner reply is refused', () => { + const out = agreedRun('recommend'); + const gateFile = path.join(out, 'gate.json'); + const uc = (JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList).items.find(i => i.id.startsWith('uc'))!; + const oldHash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!; + const rec = autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'the lanes already split by file', '--by', 'parent']); + expect(rec.status, rec.stderr).toBe(0); + const hash = rec.stdout.match(/^RECOMMENDED: .* rec_hash=([0-9a-f]{12})$/m)![1]!; + expect(hash).not.toBe(oldHash); + const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + expect(gate.rec_rev).toBe(1); + expect(gate.items.find(i => i.id === uc.id)).toMatchObject({ recommended: 'a', recommended_basis: 'the lanes already split by file', recommended_by: 'parent' }); + expect(renderGate(gate)).toContain(`rec_hash=${hash}`); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ recommended: 'a', recommended_by: 'parent' }); + const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'); + for (const extra of [[], ['--rec-hash', oldHash]]) { + const stale = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', ...extra]); + expect(stale.status).toBe(3); + expect(stale.stderr).toMatch(/\(GATE_REC_STALE\)/); + } + const mismatch = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply', `${uc.id}b`]); + expect(mismatch.status).toBe(3); + expect(mismatch.stderr).toMatch(/\(GATE_OWNER_REPLY_MISMATCH\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ownerFile = path.join(TMP, 'owner-reply.txt'); + fs.writeFileSync(ownerFile, 'rec'); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply-file', ownerFile]); + expect(ok.status, ok.stderr).toBe(0); + expect(ok.stdout).not.toMatch(new RegExp(`^APPLY: \\S+-${uc.id} `, 'm')); + expect(ok.stdout).toMatch(/^APPLY: \S+-uc2 /m); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ chosen: 'a', owner_reply: 'rec', owner_reply_parsed: true, recommended_by: 'parent' }); + }); +}); + describe('R2: later phases see their predecessors\' consensus', () => { test('the DX prompt carries the CEO consensus block', () => { const out = startSmallRun(path.join(TMP, 'dx-consensus'), ['--developer-tool']); diff --git a/test/autoplan-revise.test.ts b/test/autoplan-revise.test.ts index 9b00f151a..d3566df66 100644 --- a/test/autoplan-revise.test.ts +++ b/test/autoplan-revise.test.ts @@ -58,7 +58,11 @@ describe('revise', () => { expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1); expect(run.phases.ceo.snapshot).not.toBe(v1); expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1); - expect(fs.existsSync(path.join(s.out, 'ceo-native.r0.md'))).toBe(true); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r0.md'))).toBe(true); + const sealed = run.history.find((h: any) => h.phase === 'ceo').previous.native.output; + expect(sealed).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.existsSync(path.join(s.out, sealed))).toBe(true); + expect(run.artifacts[sealed]).toBeTruthy(); }); test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => { @@ -95,11 +99,11 @@ describe('revise', () => { test('a second revise never overwrites the first archive', () => { const s = gated(); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0); - const first = fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')); + const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace('jitter 10%', 'jitter 20%'))]).status).toBe(0); - expect(fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')).equals(first)).toBe(true); - expect(fs.existsSync(path.join(s.out, 'ceo-native.r1.v2.md'))).toBe(true); + expect(fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')).equals(first)).toBe(true); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r1.v2.md'))).toBe(true); }); test('revise adds a newly in-scope phase and never removes one', () => { diff --git a/test/autoplan-run-concurrency.test.ts b/test/autoplan-run-concurrency.test.ts index b8ac8aa68..b2d6a4340 100644 --- a/test/autoplan-run-concurrency.test.ts +++ b/test/autoplan-run-concurrency.test.ts @@ -10,7 +10,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-concurrency-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -44,7 +44,7 @@ describe('two writers', () => { const out = startSmallRun(path.join(TMP, 'submit'), ['--estimate-usd', '1']); const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); - const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; const results = await parallel(Array.from({ length: 6 }, () => args)); const ok = results.filter(r => r.status === 0); expect(ok).toHaveLength(1); @@ -78,8 +78,8 @@ describe('two writers', () => { review(path.join(out, 'ceo-outside-prompt.md')); const before = readRun(out).revision; const results = await parallel([ - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native], - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], ['status', '--out', out], ]); const bound = results.slice(0, 2).filter(r => r.status === 0).length; diff --git a/test/autoplan-run-recovery.test.ts b/test/autoplan-run-recovery.test.ts index e67885016..74dbf1361 100644 --- a/test/autoplan-run-recovery.test.ts +++ b/test/autoplan-run-recovery.test.ts @@ -7,13 +7,16 @@ * refuses a wrong receipt, a missing findings fence, a same-family outside * model and a stale attempt; the whole wave plan runs through the loop with a * fixture reviewer and validates. Pins tokens, codes and exit codes. + * Next-wave B4: each attempt writes `<phase>-<voice>.<attempt>.md`, and submit + * binds a sealed `.bound.md` copy, so a late write or an interruption between + * sealing and the journal binds nothing twice. */ import { afterAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-recovery-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -46,36 +49,36 @@ describe('next/submit loop', () => { const ids = attemptsOf(out, 'ceo'); const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'receipt' }); - let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'fence' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'row' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(ATTEMPT_MISMATCH\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before); expect(journal(out).filter(j => j.event === 'submitted')).toEqual([]); // a good native, then an outside model from the same family review(path.join(out, 'ceo-native-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status, r.stderr).toBe(0); expect(r.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native model=claude-opus-4-7 findings=3/m); review(path.join(out, 'ceo-outside-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/both anthropic.*\(MODEL_FAMILY_CONFLICT\)$/m); // exactly once: the bound native attempt cannot be submitted again - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); }); @@ -85,14 +88,14 @@ describe('next/submit loop', () => { const ceo = runPhase(out, 'ceo'); expect(ceo.native.status, ceo.native.stderr).toBe(0); expect(ceo.outside.status, ceo.outside.stderr).toBe(0); - expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=2 disagree=0 new=1 native_only=1 coverage=both$/m); + expect(ceo.outside.stdout).toMatch(/^PHASE_CLOSED: ceo confirmed=0 unverified=2 disagree=0 new=1 native_only=1 coverage=both$/m); expect(ceo.outside.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running run='); const consensus = JSON.parse(fs.readFileSync(path.join(out, 'ceo-consensus.json'), 'utf8')); const run = readRun(out).run; expect(consensus.rows.map((r: any) => [r.outside_id, r.native_id, r.disposition, r.basis])).toEqual([ - [`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'confirmed', 'file_line'], + [`${run}-ceo-outside-1`, `${run}-ceo-native-1`, 'unverified', 'file_line'], [`${run}-ceo-outside-2`, null, 'new', 'none'], - [`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'confirmed', 'title'], + [`${run}-ceo-outside-3`, `${run}-ceo-native-3`, 'unverified', 'title'], ]); expect(consensus.native_only_ids).toEqual([`${run}-ceo-native-2`]); const eng = runPhase(out, 'eng'); @@ -107,8 +110,9 @@ describe('next/submit loop', () => { const decisions = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); expect(decisions.every((d: any) => d.status === 'pending' && d.gate_rev === 1)).toBe(true); const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); - expect(tasks.length).toBe(4); - expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`, `${run}-ceo-outside-1`] }); + expect(tasks.length).toBe(6); + expect(tasks[0]).toMatchObject({ schema_version: 2, status: 'blocked_on_decision', blocked_by: [`${run}-p1`], findings: [`${run}-ceo-native-1`] }); + expect(tasks.flatMap((t: any) => t.findings).filter((f: string) => f.endsWith('-ceo-outside-1'))).toHaveLength(1); expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toContain('GUARD_NOT_INSTALLED'); expect(fs.readFileSync(path.join(out, 'plan.md'), 'utf8').startsWith('## URGENT, outside this plan')).toBe(true); // a missing prior consensus refuses the Eng dispatch @@ -140,7 +144,7 @@ describe('recovery', () => { expect(readRun(out).attempt_counter).toBe(2); // the provider had finished after all: the late result binds under the same attempt id review(path.join(out, 'ceo-native-prompt.md')); - const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(late.status, late.stderr).toBe(0); // the outside attempt is redispatched only when named explicitly; its charge stays as unknown, never zero const redo = autoplan(['next', '--out', out, '--redispatch', ids.outside, '--estimate-usd', '1']); @@ -157,7 +161,7 @@ describe('recovery', () => { const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); const snapshotBefore = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); - const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(ok.status, ok.stderr).toBe(0); // simulate the crash: the journal and the ledger have the submit, run.json does not fs.writeFileSync(path.join(out, 'run.json'), snapshotBefore); @@ -173,6 +177,52 @@ describe('recovery', () => { expect(spend.reserved.map((r: any) => r.attempt)).toEqual([ids.outside]); }); + test('killed after sealing, before the journal: nothing is bound, resume reports the attempt unknown, and the same submit then binds once', () => { + const out = startSmallRun(path.join(TMP, 'kill-sealed')); + const ids = attemptsOf(out, 'ceo'); + review(path.join(out, 'ceo-native-prompt.md')); + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const faulted = autoplan(args, { env: env({ GSTACK_AUTOPLAN_FAULT: 'submit-after-seal' }) }); + expect(faulted.status).not.toBe(0); + const sealed = path.join(out, `ceo-native.${ids.native.split('-').pop()}.bound.md`); + expect(fs.existsSync(sealed)).toBe(true); + expect(journal(out).some(r => r.event === 'submitted')).toBe(false); + expect(readRun(out).phases.ceo.native.status).toBe('running'); + const resumed = JSON.parse(autoplan(['resume', '--out', out, '--json']).stdout); + expect(resumed).toMatchObject({ rebound: [], execution_unknown: [ids.native, ids.outside] }); + const ok = autoplan(args); + expect(ok.status, ok.stderr).toBe(0); + expect(journal(out).filter(r => r.event === 'submitted')).toHaveLength(1); + expect(readRun(out).phases.ceo.native).toMatchObject({ status: 'completed', output: path.basename(sealed), attempt: ids.native }); + }); + + test('the bound result is a sealed attempt-owned copy: a late write to the reviewer file changes nothing bound, and another attempt\'s file is refused', () => { + const out = startSmallRun(path.join(TMP, 'sealed')); + runPhase(out, 'ceo'); + const result = resultOf(out, 'ceo', 'native'); + const run = readRun(out); + const bound = path.join(out, run.phases.ceo.native.output); + expect(path.basename(bound)).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.readFileSync(bound, 'utf8')).toBe(fs.readFileSync(result, 'utf8')); + if (process.platform !== 'win32') expect(fs.statSync(bound).mode & 0o222).toBe(0); + fs.appendFileSync(result, '\nLate edit after submit.\n'); + runPhase(out, 'eng'); + expect(readRun(out).status).toBe('gate_pending'); + const v = validate(out); + expect(v.status, v.stdout + v.stderr).toBe(0); + expect(fs.readFileSync(bound, 'utf8')).not.toContain('Late edit'); + const eng = attemptsOf(out, 'eng'); + expect(path.basename(resultOf(out, 'eng', 'native'))).toBe(`eng-native.${eng.native.split('-').pop()}.md`); + const other = startSmallRun(path.join(TMP, 'sealed-other')); + const otherIds = attemptsOf(other, 'ceo'); + review(path.join(other, 'ceo-native-prompt.md')); + const foreign = path.join(other, `ceo-native.a999.md`); + fs.copyFileSync(resultOf(other, 'ceo', 'native'), foreign); + const refused = autoplan(['submit', '--out', other, '--phase', 'ceo', '--voice', 'native', '--result', foreign, '--model', 'claude-opus-4-7', '--attempt', otherIds.native]); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/\(ATTEMPT_MISMATCH\)/); + }); + test('a runner process killed mid-command leaves no lock behind that a later command cannot reclaim; a live owner is never stolen', () => { const out = startSmallRun(path.join(TMP, 'lock')); fs.writeFileSync(path.join(out, 'run.lock'), JSON.stringify({ pid: 999999, host: os.hostname(), at: new Date().toISOString() })); diff --git a/test/bun-resolve.test.ts b/test/bun-resolve.test.ts new file mode 100644 index 000000000..bf04922e4 --- /dev/null +++ b/test/bun-resolve.test.ts @@ -0,0 +1,134 @@ +/** + * The one Bun resolution contract (plan A1, A-d): `gstack_bun_resolve` in + * bin/gstack-bun-version.sh and `resolveBun` / `launchRuntime` in + * lib/pregate/runtime.ts must pick the same binary (by realpath) for the + * GBRA-60 layout: the image's old Bun first on PATH, Bun 1.4.2 in + * $BUN_INSTALL/bin, Bun 1.4.3 elsewhere, a project pinning 1.4.3 only through + * a CI lane, an unpinned project and a directory outside any project. A file + * carrying the shim marker is never returned, and a candidate is chosen only + * when it satisfies every applicable pin. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { BUN_SHIM_MARKER, bunStore, launchRuntime, projectBunPins, resolveBun } from '../lib/pregate/runtime'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const REAL_BUN = fs.realpathSync(process.execPath); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +const fakeBun = (file: string, version: string) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `#!/usr/bin/env bash\n[ "$1" = --version ] && { echo ${version}; exit 0; }\nexec "${REAL_BUN}" "$@"\n`, { mode: 0o755 }); + return file; +}; + +function layout(opts: { storeVersions?: string[]; homeVersion?: string } = {}) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bun-resolve-'))); + bases.push(base); + const home = path.join(base, 'home'), state = path.join(home, '.gstack'); + const image = fakeBun(path.join(base, 'usr-local-bin/bun'), '1.3.14'); + const bunInstall = path.join(base, 'bun-install'); + fakeBun(path.join(bunInstall, 'bin/bun'), '1.4.2'); + for (const v of opts.storeVersions ?? []) fakeBun(path.join(bunStore(state), v, 'bun'), v); + if (opts.homeVersion) fakeBun(path.join(home, '.bun/bin/bun'), opts.homeVersion); + const shimDir = path.join(home, '.local/bin'); + fs.mkdirSync(shimDir, { recursive: true }); + fs.writeFileSync(path.join(shimDir, 'bun'), `#!/bin/sh\n${BUN_SHIM_MARKER}\nexec ${image} "$@"\n`, { mode: 0o755 }); + const pinned = path.join(base, 'gbrain'); + fs.mkdirSync(path.join(pinned, '.github/workflows'), { recursive: true }); + fs.writeFileSync(path.join(pinned, 'package.json'), '{ "name": "gbrain", "engines": { "bun": ">=1.4.0" } }\n'); + fs.writeFileSync(path.join(pinned, '.github/workflows/ci.yml'), 'jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: 1.4.3\n'); + const unpinned = path.join(base, 'plain'); + fs.mkdirSync(unpinned); + fs.writeFileSync(path.join(unpinned, 'package.json'), '{ "name": "plain" }\n'); + for (const d of [pinned, unpinned]) spawnSync('git', ['init', '-q', d], { timeout: 30_000 }); + const outside = path.join(base, 'tmp'); + fs.mkdirSync(outside); + const env = { HOME: home, BUN_INSTALL: bunInstall, PATH: `${shimDir}:${path.dirname(image)}:/usr/bin:/bin`, GSTACK_STATE_ROOT: state }; + return { base, home, state, bunInstall, pinned, unpinned, outside, env }; +} + +/** The Bash twin with the installer's project-pin hook: the doctor's parser, run by a real Bun. */ +function bashResolve(l: ReturnType<typeof layout>, project: string | null): string { + const script = ` +. "$R/bin/gstack-bun-version.sh" +PIN_EXACT="" +if [ -n "$P" ]; then + PIN_EXACT=$("$RB" "$R/bin/gstack-runtime-pins.ts" --project "$P" | sed -n 's/^PIN: bun \\([^ ]*\\) exact [^ ]* lane=\\(any\\|linux\\)$/\\1/p' | sort -u) + [ "$(printf '%s\\n' "$PIN_EXACT" | grep -c .)" -le 1 ] || PIN_EXACT="" +fi +accept() { [ -z "$P" ] || "$RB" "$R/bin/gstack-runtime-pins.ts" --project "$P" --bun "$1" | grep -Eq '^PINS_VERDICT: (pass|none)$'; } +gstack_bun_resolve "$(gstack_bun_store "$GSTACK_STATE_ROOT")" "$PIN_EXACT" accept && printf '%s %s %s\\n' "$_gb_bin" "$_gb_ver" "$_gb_rule"`; + const r = spawnSync('bash', ['-c', script], { cwd: project ?? l.outside, encoding: 'utf8', timeout: 60_000, env: { ...l.env, R: ROOT, RB: REAL_BUN, P: project ?? '' } }); + return r.stdout.trim(); +} + +function tsResolve(l: ReturnType<typeof layout>, project: string | null): string { + const { pinExact, accept } = projectBunPins(project); + const { resolution } = resolveBun({ store: bunStore(l.state), env: l.env, pinExact, accept }); + return resolution ? `${resolution.bin} ${resolution.version} ${resolution.rule}` : ''; +} + +describe('one Bun resolution contract, two twins', () => { + test('GBRA-60: 1.4.3 in the store, 1.4.2 in $BUN_INSTALL, a CI-lane pin of 1.4.3 — both twins pick the store 1.4.3 for the project and for an unpinned or outside directory', () => { + const l = layout({ storeVersions: ['1.4.3'] }); + const store143 = path.join(bunStore(l.state), '1.4.3/bun'); + for (const [project, want] of [[l.pinned, `${store143} 1.4.3 pin`], [l.unpinned, `${store143} 1.4.3 default`], [null, `${store143} 1.4.3 default`]] as const) { + expect(bashResolve(l, project)).toBe(want); + expect(tsResolve(l, project)).toBe(want); + } + const lanes = launchRuntime(l.pinned, l.env); + expect(path.join(lanes.dir!, 'bun')).toBe(store143); + expect(lanes.verdict).toBe('skipped'); + expect(lanes.label).toContain('install verdict: not checked'); + }); + + test('1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin, nothing in the store: the pinned project skips the ineligible 1.4.2; unpinned directories take $BUN_INSTALL first', () => { + const l = layout({ homeVersion: '1.4.3' }); + const home143 = path.join(l.home, '.bun/bin/bun'); + const bi142 = path.join(l.bunInstall, 'bin/bun'); + for (const [project, want] of [[l.pinned, `${home143} 1.4.3 home`], [l.unpinned, `${bi142} 1.4.2 BUN_INSTALL`], [null, `${bi142} 1.4.2 BUN_INSTALL`]] as const) { + expect(bashResolve(l, project)).toBe(want); + expect(tsResolve(l, project)).toBe(want); + } + }); + + test('no eligible candidate: both twins return nothing (the shim and the old image Bun are never chosen)', () => { + const l = layout(); + fs.rmSync(path.join(l.bunInstall, 'bin/bun')); + expect(bashResolve(l, l.pinned)).toBe(''); + expect(tsResolve(l, l.pinned)).toBe(''); + expect(launchRuntime(l.pinned, l.env).dir).toBeNull(); + }); + + test('a symlinked candidate resolves to its target realpath in both twins (portable readlink loop, no realpath(1))', () => { + const l = layout({ storeVersions: ['1.4.3'] }); + const linkDir = path.join(l.base, 'links'); + fs.mkdirSync(linkDir); + fs.symlinkSync('../home/.gstack/runtimes/bun/1.4.3/bun', path.join(linkDir, 'bun')); + fs.rmSync(path.join(l.bunInstall, 'bin/bun')); + const env = { ...l.env, GSTACK_STATE_ROOT: path.join(l.base, 'elsewhere'), PATH: `${linkDir}:${l.env.PATH}` }; + const want = `${path.join(bunStore(l.state), '1.4.3/bun')} 1.4.3 PATH`; + expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want); + expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want); + }); + + // Integration item 8: the gstack checkout itself binds the 'none' verdict. + test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => { + const l = layout({ storeVersions: ['1.4.3'] }); + const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict'); + const checkout = fs.realpathSync(ROOT); + const file = installVerdictPath(checkout, l.state, null); + const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ schema_version: 2, checkout, project: '', head, for: 'ship', required: ['claude', 'codex', 'patch'], pins_planning_only: false, ok: true, trailer: 'gstack: ok', min_revision: '', launch_path: '', time: '', bun_path: REAL_BUN, bun_version: Bun.version })); + const r = launchRuntime(ROOT, l.env); + expect(r.refusal).toBeNull(); + expect(r.verdict).toBe('ok'); + expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`); + }); +}); diff --git a/test/bun-version-drift.test.ts b/test/bun-version-drift.test.ts index 55a83348f..724bc337e 100644 --- a/test/bun-version-drift.test.ts +++ b/test/bun-version-drift.test.ts @@ -28,8 +28,13 @@ const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows'); interface Pin { surface: string; version: string; + /** The workflow job the pin sits in (empty outside a workflow). */ + job?: string; } +/** The one job that runs on GSTACK_BUN_DEFAULT instead of the minimum (plan A-g). */ +const DEFAULT_BUN_JOB = 'quick-bun-default'; + function collectPins(): Pin[] { const pins: Pin[] = []; @@ -37,7 +42,9 @@ function collectPins(): Pin[] { if (!/\.ya?ml$/.test(name)) continue; const source = fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8'); const lines = source.split('\n'); + let job = ''; for (let i = 0; i < lines.length; i++) { + job = lines[i].match(/^ {2}([A-Za-z0-9_-]+):\s*$/)?.[1] ?? job; if (!/uses:\s*oven-sh\/setup-bun@/.test(lines[i])) continue; // A pinned stanza is `with:` + `bun-version: <v>` within the next few // lines; an unpinned setup-bun is itself drift (installs latest). @@ -46,6 +53,7 @@ function collectPins(): Pin[] { pins.push({ surface: `${name}:${i + 1}`, version: m ? m[1] : '<unpinned setup-bun — installs latest>', + job, }); } } @@ -69,8 +77,8 @@ function collectPins(): Pin[] { } describe('bun version pins', () => { - test('every CI surface pins the same bun version', () => { - const pins = collectPins(); + test('every CI surface pins the same bun version (the install-default job aside)', () => { + const pins = collectPins().filter(p => p.job !== DEFAULT_BUN_JOB); // Sanity: the scan found the known surfaces (a regex rot that finds // nothing must fail loudly, not vacuously pass). expect(pins.length).toBeGreaterThanOrEqual(6); @@ -96,19 +104,40 @@ describe('bun version pins', () => { }); }); -function bunVersionsFile(): { floor: string; tested: string } { +function bunVersionsFile(): { floor: string; tested: string; installDefault: string; verified: string[] } { const src = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-bun-version.sh'), 'utf-8'); return { floor: src.match(/^GSTACK_BUN_FLOOR="([^"]+)"$/m)?.[1] ?? '<no GSTACK_BUN_FLOOR>', tested: src.match(/^GSTACK_BUN_TESTED="([^"]+)"$/m)?.[1] ?? '<no GSTACK_BUN_TESTED>', + installDefault: src.match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)?.[1] ?? '<no GSTACK_BUN_DEFAULT>', + verified: (src.match(/^GSTACK_BUN_VERIFIED="([^"]+)"$/m)?.[1] ?? '').split(/\s+/).filter(Boolean), }; } +// Plan A2/A-g: the Capy install default is separate from the floor. It must be +// a verified archive at or above the minimum, and exactly one CI job (the +// quick free tier) runs on it so the default Bun is never untested. +describe('install default Bun (A2, A-g)', () => { + const { tested, installDefault, verified } = bunVersionsFile(); + test('GSTACK_BUN_DEFAULT is a verified archive and not below the supported minimum', () => { + expect(verified).toContain(installDefault); + expect(verified).toContain(tested); + expect(Bun.semver.order(installDefault, tested)).toBeGreaterThanOrEqual(0); + }); + test('one CI job runs the quick free tier on GSTACK_BUN_DEFAULT, and the free-tests aggregate requires it', () => { + const onDefault = collectPins().filter(p => p.job === DEFAULT_BUN_JOB); + expect(onDefault.map(p => p.version)).toEqual([installDefault]); + const workflow = Bun.YAML.parse(fs.readFileSync(path.join(WORKFLOWS_DIR, 'free-tests.yml'), 'utf-8')) as { jobs: Record<string, { needs?: string[]; steps: { run?: string }[] }> }; + expect(workflow.jobs[DEFAULT_BUN_JOB].steps.some(step => step.run?.includes('bun run test:quick'))).toBe(true); + expect(workflow.jobs['free-tests'].needs).toContain(DEFAULT_BUN_JOB); + }); +}); + describe('Bun requirement surfaces agree (E1)', () => { const { floor, tested } = bunVersionsFile(); - test('tested version == every CI pin == engines.bun lower bound; floor sits below it', () => { - const ciVersions = [...new Set(collectPins().map((p) => p.version))]; + test('tested version == every CI pin (the install-default job aside) == engines.bun lower bound; floor sits below it', () => { + const ciVersions = [...new Set(collectPins().filter((p) => p.job !== DEFAULT_BUN_JOB).map((p) => p.version))]; expect(ciVersions).toEqual([tested]); const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); expect(pkg.engines?.bun).toBe(`>=${tested}`); diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index f51dd7dae..b7f8b6c10 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -138,13 +138,13 @@ describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true'); expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.0`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=2 min=1.0.1 from=1.0.0`]); const next = f.run(['--check', '--for', 'autoplan']); expect(next.status, next.stdout + next.stderr).toBe(0); expect(next.stdout).not.toContain('upgraded'); expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`); + expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=2 min=1.0.1 from=`); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); }); @@ -242,7 +242,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(r.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.1`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=2 min=1.0.1 from=1.0.1`]); expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false); const next = f.run(['--for', 'autoplan']); @@ -250,7 +250,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(next.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m); expect(next.stdout).not.toContain('SELF_UPGRADE: done'); expect(next.last).toBe('gstack: ok 1.0.2 for=autoplan'); - expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=`); + expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=2 min=1.0.1 from=`); }); test('--check mode never moves a behind checkout: it names the gap and the fast-forwarding command, then certifies the installed revision', () => { @@ -261,7 +261,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(r.stdout).not.toContain('SELF_UPGRADE'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`]); + expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=2 min=1.0.1 from=`]); }); test('a dirty behind checkout is skipped with gstack-upgrade\'s reason and still ends ok: above the floor, the trailer is the doctor\'s', () => { @@ -274,7 +274,7 @@ describe('capy skill: always current when online (the minimum is the floor, not expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n'); expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan'); - expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=`]); + expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=2 min=1.0.1 from=`]); }); test('GSTACK_CAPY_SELF_UPGRADE=off and GSTACK_CAPY_AUTO_UPGRADE=0 each disable the fast-forward by name and leave HEAD where it is', () => { @@ -291,6 +291,23 @@ describe('capy skill: always current when online (the minimum is the floor, not }); }); +// Plan A9: the `latest:` trailer note comes only from origin's VERSION the +// bootstrapper read in this run; a FETCH_HEAD left by an earlier run never +// reaches the installer. +describe('capy skill bootstrapper: GSTACK_CAPY_REMOTE_VERSION', () => { + test('a fresh fetch exports origin\'s VERSION; a failed fetch exports nothing even with a stale FETCH_HEAD', () => { + const f = fixture({ cloneAt: 'B' }); + write(path.join(f.root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan remote=${GSTACK_CAPY_REMOTE_VERSION:-none}"\n', 0o755); + const fresh = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REMOTE_VERSION: 'from-parent' }); + expect(fresh.last).toBe('gstack: ok 1.0.1 for=autoplan remote=1.0.2'); + expect(f.git(['show', 'FETCH_HEAD:VERSION'])).toBe('1.0.2'); + f.git(['remote', 'set-url', 'origin', `file://${f.host.base}/missing.git`]); + const stale = f.run(['--check', '--for', 'autoplan'], { GSTACK_CAPY_REMOTE_VERSION: 'from-parent' }); + expect(stale.stdout).toContain('remote minimum unknown (fetch failed)'); + expect(stale.last).toBe('gstack: ok 1.0.1 for=autoplan remote=none'); + }); +}); + describe('capy skill: vendored text and support file', () => { const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : ''; test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => { diff --git a/test/cso-distribution.test.ts b/test/cso-distribution.test.ts index 2233d2a54..dd0417e6b 100644 --- a/test/cso-distribution.test.ts +++ b/test/cso-distribution.test.ts @@ -388,7 +388,7 @@ describe('CSO runtime staging gates', () => { expect(gate['continue-on-error']).not.toBe(true); const required = workflow.jobs['free-tests']; expect(required.if).toBe('always()'); - expect(required.needs).toEqual(['free-suite', 'typecheck', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']); + expect(required.needs).toEqual(['free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']); expect(required.steps[0].run).toContain('test "$CSO_DOCKER_RESULT" = success'); for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) { if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6'); diff --git a/test/fixtures/autoplan-pairing/run-20261014-pairs.json b/test/fixtures/autoplan-pairing/run-20261014-pairs.json new file mode 100644 index 000000000..e116d5a61 --- /dev/null +++ b/test/fixtures/autoplan-pairing/run-20261014-pairs.json @@ -0,0 +1,181 @@ +{ + "source": "autoplan run run-20261014225954 (next-wave plan review, 2026-10-14): ceo gate_rev 1 findings (*.findings.r1.jsonl) and dx gate_rev 2 findings (*.findings.jsonl), copied verbatim", + "pairs": [ + { + "name": "ceo r1 outside-1 vs native-11", + "expect": "negative", + "why": "same file, lines 3 apart; different problems and remedies (project-aware Bun launching vs a single verdict owner plus a fresh-machine job); reconciled confirmed on file:line alone at 1.91.101.0", + "outside": [ + { + "id": "run-20261014225954-ceo-outside-1", + "phase": "ceo", + "voice": "outside", + "severity": "High", + "title": "A fixed home-wide Bun link cannot preserve per-project runtime ownership", + "file": "bin/gstack-capy-install", + "line": 197, + "resolution": "Replace A2's mutable fixed-version global link with project-aware launching; bind install verdicts to the calling project and resolved runtime, reconcile pregate's separate resolver, and exercise alternating exact-pin projects plus unrelated Bun commands.", + "user_challenge": true, + "changes": "direction" + } + ], + "native": [ + { + "id": "run-20261014225954-ceo-native-11", + "phase": "ceo", + "voice": "native", + "severity": "Medium", + "title": "Fourth install-honesty wave in about two weeks: add a single verdict owner and a pre-merge fresh-machine acceptance job instead of a post-ship manual rerun", + "file": "bin/gstack-capy-install", + "line": 200, + "resolution": "Route install, upgrade, repair and session-start through one final step: gstack-capy-install --check from ORIG_PWD, printing only its trailer. Make the GBRA-60 reproduction (image Bun on PATH, verified Bun off PATH, project pinned to 1.4.3, fresh shells) an automated job on a fresh Capy or Ubicloud machine that gates Lane A's merge." + } + ] + }, + { + "name": "dx r2 outside-2 vs native-1", + "expect": "positive", + "why": "both find that the revised trailer grammar breaks the existing fail head and leaves the pins exit unspecified", + "outside": [ + { + "id": "run-20261014225954-dx-outside-2", + "phase": "dx", + "voice": "outside", + "severity": "Medium", + "title": "The revised failure trailer has conflicting shapes and an unspecified pins exit code", + "file": "bin/gstack-doctor-check.sh", + "line": 459, + "resolution": "Define one backward-compatible status grammar with failed-component information, specify A7's numeric pins-readiness exit and partial-upgrade state, and synchronize text/JSON/verdict/docs with success and multi-failure note-composition tests." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-1", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "A9's trailer grammar would replace the existing 'gstack: fail <components>' head that the Capy skill (and its lagging Drive copies) dispatch repairs on", + "file": "bin/gstack-doctor-check.sh", + "line": 460, + "resolution": "Scope the grammar to `gstack: ok <rev> for=<skill>[ (<notes>)]`. Keep failures as `gstack: fail <component>[,<component>] \u2014 <detail>; fix: <cmd> (<CODE>; <url>#<anchor>)`, with notes appended in the same fixed order. Add a test that runs the current capy/skill/gstack/SKILL.md dispatch examples (fail revision, fail runtime,pins, fail pins) against the new output." + } + ] + }, + { + "name": "dx r2 native-2 vs outside-4", + "expect": "positive", + "why": "same problem by meaning: a recommendation edit after the owner saw the gate changes what the reply resolves to; different files and titles", + "outside": [ + { + "id": "run-20261014225954-dx-outside-4", + "phase": "dx", + "voice": "outside", + "severity": "High", + "title": "Recommendation edits can change a displayed gate's defaults without invalidating its reply", + "file": "lib/autoplan-gate.ts", + "line": 154, + "resolution": "Version semantic recommendation changes, reject old replies, and refresh plan-approval summaries, decisions and artifact hashes; return the new gate identity and test concurrent/repeated edits, answered/carried choices and restore." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-2", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "The new recommend verb can change what rec resolves to after the owner has seen the gate", + "file": "lib/gate-list.ts", + "line": 112, + "resolution": "Hash the rendered recommendations into the relay (for example rec_hash in the render's resolution line). Make answer reject a rec whose hash no longer matches (re-render and re-relay). Make recommend refuse after a render has been relayed unless it bumps to a fresh render. Test recommend-after-relay, then rec." + } + ] + }, + { + "name": "constructed same title, opposite remedies", + "expect": "negative", + "source": "constructed (integration review, 2026-10-15): the same title associates two findings whose remedies are opposite", + "outside": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-outside-9", + "voice": "outside", + "line": 40, + "resolution": "keep attempts in the existing jobs column; do not add a table" + } + ], + "native": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-native-9", + "voice": "native", + "line": 90, + "resolution": "move attempts into a new retry_attempts table" + } + ] + }, + { + "name": "constructed same title, different remedies", + "expect": "negative", + "source": "constructed: the same title with two different, non-opposite remedies is associated, not confirmed", + "outside": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-outside-10", + "voice": "outside", + "line": 40, + "resolution": "index the attempts by tenant" + } + ], + "native": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-native-10", + "voice": "native", + "line": 90, + "resolution": "partition the attempts table by month" + } + ] + }, + { + "name": "constructed same title, same remedy", + "expect": "positive", + "source": "constructed: the same title and the same remedy (case and punctuation aside) confirm", + "outside": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-outside-11", + "voice": "outside", + "line": 40, + "resolution": "Move attempts into a new retry_attempts table." + } + ], + "native": [ + { + "phase": "eng", + "severity": "High", + "title": "Persist retry attempts in their own table", + "file": "lib/worker.ts", + "id": "r-eng-native-11", + "voice": "native", + "line": 90, + "resolution": "move attempts into a new retry_attempts table" + } + ] + } + ] +} diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index bc1948600..2feaf7eb8 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -8,7 +8,7 @@ "browse": 8961, "browser-skills/hackernews-frontpage": 371, "canary": 13520, - "capy/skill/gstack": 2225, + "capy/skill/gstack": 2601, "careful": 1328, "codex": 15598, "context-restore": 10995, diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 6ccbd249d..2ebc23f34 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -882,15 +882,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 39ca8aa00..fcc7d06ae 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -887,15 +887,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 9b69f681c..a7c6621b6 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -907,15 +907,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index ec1c7ebef..7ca7cc393 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -888,15 +888,14 @@ No edits means an executed audit, not a skip; report the section's verified outc Make bisectable commits; if already committed, continue to Step 16. Never create an empty commit. -1. Group changes with their tests, config/routes, views and Step 14.5 docs. - Migrations may stand alone or accompany their model. - Under 50 lines across fewer than 4 files may be one commit. +1. Group changes with their tests, config/routes, views and Step 14.5 docs; + migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. - Each commit must work on its own, without broken imports or missing code. + Each commit works alone (no broken imports or missing code). Group VERSION + CHANGELOG + TODOS.md after the feature commits. -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body. - Only the final VERSION/CHANGELOG commit gets the release version and co-author - trailer. Do not create a Git tag: +3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a + user-named issue adds a `Fixes: #<n>` trailer. Only the final + VERSION/CHANGELOG commit gets the release version and co-author trailer. No Git tag: ```bash git commit -m "$(cat <<'EOF' diff --git a/test/gate-list.test.ts b/test/gate-list.test.ts index 10a72c836..c4e1aa775 100644 --- a/test/gate-list.test.ts +++ b/test/gate-list.test.ts @@ -9,7 +9,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { applyReply, decisionRows, parseReply, renderGate, resolveAll, validateGateList, type GateList } from '../lib/gate-list'; +import { applyReply, decisionRows, parseReply, recHash, renderGate, validateGateList, type GateList } from '../lib/gate-list'; import { EXIT, validateFile } from '../lib/headless-artifacts'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -116,7 +116,7 @@ describe('reply grammar', () => { }); describe('rendering', () => { - test('ids and numbering are stable across pages; each page prints the reply shape and the `all` resolution', () => { + test('ids and numbering are stable across pages; each page prints the reply shape and the `rec` resolution with its rec_hash', () => { const p1 = renderGate(big, 1); const p5 = renderGate(big, 5); expect(p1).toContain('page=1/5'); @@ -127,10 +127,10 @@ describe('rendering', () => { expect(p5).toContain('29. [d29] decision 29'); expect(p5).toContain('33. [d33] decision 33'); for (const page of [p1, p5]) { - expect(page).toContain('`all`'); - expect(page).toContain('`all except <tokens>`'); - expect(page).toContain(`gate_rev=1`); - expect(page).toContain(JSON.stringify({ gate_rev: 1, choices: resolveAll(big) })); + expect(page).toContain('`rec`'); + expect(page).toContain('`rec except <tokens>`'); + expect(page).toContain(`gate_rev=1 rec_hash=${recHash(big)}`); + expect(page).toContain(JSON.stringify({ gate_rev: 1, rec_hash: recHash(big), choices: Object.fromEntries(big.items.map(i => [i.id, i.recommended])) })); } expect(renderGate(list)).toContain(' d1a) option A (recommended)'); expect(renderGate(list)).not.toContain('page=1/2'); @@ -145,6 +145,33 @@ describe('rendering', () => { }); }); +describe('next wave B1/B-b: rec, aliases, quotes and the rec_hash', () => { + const held: GateList = { ...list, items: [{ ...list.items[0]!, recommended: 'b', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }, { key: 'c', text: 'other' }] }, ...list.items.slice(1)] }; + test('every alias resolves like `rec`, plan changes included; `except` and earlier answers combine; quotes, backticks and newlines are separators', () => { + const want = { uc1: 'b', d1: 'a', d2: 'a', d3: 'a' }; + for (const reply of ['rec', 'REC', 'all', 'approve all', 'accept all', 'take all recommendations', '`rec`', '"rec"', '\u201crec\u201d', 'rec\n']) { + const p = parseReply(reply, held, 1); + expect(p.valid, reply).toBe(true); + expect(p.mode).toBe('rec'); + expect(chosen(p)).toEqual(want); + } + expect(chosen(parseReply('rec except uc1a d3b', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'b' }); + expect(chosen(parseReply('rec', held, 1, { d1: 'c' }))).toEqual({ uc1: 'b', d1: 'c', d2: 'a', d3: 'a' }); + expect(parseReply('rec', held, 1, { d1: 'c' }).choices.find(c => c.id === 'd1')!.prior).toBe(true); + expect(chosen(parseReply('as-is', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'a' }); + expect(parseReply('d3b', held, 1).pending).toEqual(['uc1', 'd1']); + for (const bad of ['approve', 'accept', 'take all', 'rec rec', 'rec as-is', 'yes']) expect(parseReply(bad, held, 1).valid, bad).toBe(false); + }); + test('rec_hash changes with a recommendation, its basis or rec_rev, and the render shows who set it', () => { + const h = recHash(held); + expect(recHash({ ...held })).toBe(h); + expect(recHash({ ...held, rec_rev: 1 })).not.toBe(h); + const moved = { ...held, items: held.items.map(i => (i.id === 'd1' ? { ...i, recommended: 'b', recommended_basis: 'both reviewers name it', recommended_by: 'agent:relay' } : i)) }; + expect(recHash(moved)).not.toBe(h); + expect(renderGate(moved)).toContain('[d1] Delivery shape (approval; recommended: b (both reviewers name it; set by agent:relay))'); + }); +}); + describe('decisions.jsonl rows', () => { test('pending rows carry gate_rev, kind, recommended and the auto default; applying a reply sets chosen/status', () => { const rows = decisionRows(list); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 0b190e68a..8da8b78f6 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -54,11 +54,12 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) { fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode); } write(path.join(root, 'VERSION'), '1.2.3\n'); - script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']); + script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "gstack ready ($2)"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']); script(path.join(root, 'bin/gstack-doctor'), [ 'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"', - 'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', + 'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }', + '[ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ] && { echo "FAIL runtime — bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT; fix: $GSTACK_DOCTOR_RUNTIME_DRIFT_FIX (BUN_RESOLUTION_DRIFT; d)"; echo "gstack: fail runtime"; exit 1; }', 'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"', ]); script(path.join(root, 'bin/gstack-paths'), ['[ "$1" = --get ] && { echo "${GSTACK_HOME}"; exit 0; }', 'exit 1']); @@ -77,10 +78,20 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) { }; const run = (args: string[] = [], extra: Record<string, string> = {}, cwd = home) => spawnSync('bash', [path.join(root, 'bin/gstack-capy-install'), ...args], { cwd, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 }); - return { base, root, home, stub, run, calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [] }; + return { base, root, home, stub, env, run, calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [] }; } const lastLine = (s: string) => s.trim().split('\n').at(-1); +const store = (f: { home: string }, version: string) => path.join(f.home, '.gstack-state/runtimes/bun', version); +/** Point VERSION's verified checksum for TARGET in the fixture's gstack-bun-version.sh at ZIP. */ +function trustZip(root: string, version: string, target: string, zip: string) { + const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim(); + const versions = path.join(root, 'bin/gstack-bun-version.sh'); + const re = new RegExp(`(${version.replace(/\./g, '\\.')}\\) case "\\$1" in[\\s\\S]*?\\s${target}\\) echo )[0-9a-f]{64}`); + const text = fs.readFileSync(versions, 'utf8'); + expect(re.test(text)).toBe(true); + fs.writeFileSync(versions, text.replace(re, `$1${sha}`)); +} describe('gstack-capy-install', () => { test('default install: preflight, both host setups with --no-browser, lazy browser SKIP, launch line, trailer last, exit 0', () => { @@ -104,6 +115,17 @@ describe('gstack-capy-install', () => { expect(f.calls().some(l => l.startsWith('curl '))).toBe(false); }); + // Integration item 7: setup runs once per host, so each run ends with its + // own ready line; the installer prints the one line naming both. + test('after both host setups the last ready line is the installer\'s `gstack ready (claude, codex)`; a skipped setup prints none', () => { + const f = fixture(); + const r = f.run(['--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + const ready = r.stdout.split('\n').filter(l => l.startsWith('gstack ready')); + expect(ready).toEqual(['gstack ready (claude)', 'gstack ready (codex)', 'gstack ready (claude, codex)']); + expect(f.run(['--for', 'autoplan']).stdout).not.toContain('gstack ready'); + }); + test('--for names the workflow; --with-browser drops --no-browser, runs gstack-browser-ensure and times it separately', () => { const f = fixture(); const r = run(f, ['--for', 'qa', '--with-browser']); @@ -185,38 +207,39 @@ describe('gstack-capy-install', () => { expect(g.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false); }); - test('Bun below the minimum: the exact release archive is downloaded and SHA-256 verified; a mismatch installs nothing', () => { + test('Bun below the minimum: the install default\'s exact release archive is downloaded and SHA-256 verified; a mismatch installs nothing', () => { const f = fixture({ bun: '1.3.14' }); const zip = path.join(f.base, 'bad.zip'); fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true }); - script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.2']); + script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.3']); expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0); const r = run(f, [], { STUB_CURL_ZIP: zip }); expect(r.status).toBe(3); expect(r.stderr).toContain('SHA-256 mismatch'); expect(r.stderr).toContain('nothing installed'); expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1); - expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64'); + expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.3/bun-linux-x64'); expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false); + expect(fs.existsSync(store(f, '1.4.3'))).toBe(false); + expect(fs.readdirSync(path.dirname(store(f, '1.4.3')))).toEqual([]); expect(f.calls().some(l => l.startsWith('setup '))).toBe(false); }); - test.skipIf(process.platform !== 'linux')('Bun below the minimum: a verified archive installs into BUN_INSTALL and ./setup runs on the new Bun', () => { + test.skipIf(process.platform !== 'linux')('Bun below the minimum: a verified archive is published in the runtime store (never ~/.bun/bin) and ./setup runs on it', () => { const f = fixture({ bun: '1.3.14' }); const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim(); const zip = path.join(f.base, 'good.zip'); fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true }); - script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.2']); + script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.3']); expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0); - const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim(); - const versions = path.join(f.root, 'bin/gstack-bun-version.sh'); - fs.writeFileSync(versions, fs.readFileSync(versions, 'utf8').replace(new RegExp(`(\\s*${target}\\) echo )[0-9a-f]{64}`), `$1${sha}`)); + trustZip(f.root, '1.4.3', target, zip); const r = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(r.status, r.stdout + r.stderr).toBe(0); - expect(r.stdout).toContain('installing bun 1.4.2 from its release archive'); - expect(r.stdout).toContain(`bun 1.4.2 installed at ${path.join(f.home, '.bun/bin/bun')}`); - expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(true); - expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.2')); + expect(r.stdout).toContain('installing bun 1.4.3 from its release archive'); + expect(r.stdout).toContain(`bun 1.4.3 installed at ${store(f, '1.4.3')}/bun`); + expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (gstack's install default, from gstack's runtime store;`); + expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false); + expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.3')); expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); }); @@ -225,18 +248,37 @@ describe('gstack-capy-install', () => { const before = sh('git rev-parse HEAD', f.root).stdout.trim(); const r = run(f, ['--revision', 'v9.9.9.9']); expect(r.status, r.stdout + r.stderr).toBe(0); - expect(lastLine(r.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run ${f.root}/bin/gstack-upgrade to move)`); + expect(lastLine(r.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested: v9.9.9.9 (run ${f.root}/bin/gstack-upgrade to move))`); expect(sh('git rev-parse HEAD', f.root).stdout.trim()).toBe(before); expect(run(f, ['--revision', '1.2.3']).stdout).not.toContain('requested'); expect(lastLine(run(f, ['--revision', before]).stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); }); + test('trailer grammar: notes ride only on ok, in the fixed order requested, pins, latest; a stale FETCH_HEAD without the wrapper\'s env adds no latest note', () => { + const f = fixture(); + const all = run(f, ['--revision', 'v9.9.9.9'], { STUB_PINS_SKIP: 'planning-only; repo pins differ: x', GSTACK_CAPY_REMOTE_VERSION: '1.10.0' }); + expect(all.status, all.stdout + all.stderr).toBe(0); + expect(lastLine(all.stdout)).toBe(`gstack: ok 1.2.3 for=autoplan (requested: v9.9.9.9 (run ${f.root}/bin/gstack-upgrade to move); pins: skipped (planning-only); latest: 1.10.0 available)`); + expect(lastLine(run(f, [], { STUB_PINS_SKIP: 'no project: /x is not inside a git repository; pass --project <path>' }).stdout)).toBe('gstack: ok 1.2.3 for=autoplan (pins: skipped (no project))'); + expect(lastLine(run(f, [], { GSTACK_CAPY_REMOTE_VERSION: '1.2.3' }).stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); + expect(lastLine(run(f, [], { GSTACK_CAPY_REMOTE_VERSION: '1.0.9' }).stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); + sh(`git update-ref FETCH_HEAD HEAD && printf '%s\\t\\tbranch main\\n' "$(git rev-parse HEAD)" > .git/FETCH_HEAD`, f.root); + write(path.join(f.root, 'VERSION'), '1.2.3\n'); + expect(lastLine(run(f, ['--check']).stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); + const failed = run(f, ['--revision', 'v9.9.9.9'], { STUB_DOCTOR_FAIL: '1', STUB_PINS_SKIP: 'planning-only; x', GSTACK_CAPY_REMOTE_VERSION: '9.0.0' }); + expect(lastLine(failed.stdout)).toBe('gstack: fail codex'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone'); const r = run(f, ['--root', clone], { GSTACK_CAPY_REPO: f.root }); expect(r.status, r.stdout + r.stderr).toBe(0); expect(r.stdout).toContain(`cloning ${f.root} into ${clone}`); + expect(r.stdout).toMatch(new RegExp(`^gstack-capy-install: cloned ${f.root} at [0-9a-f]{12} \\(1\\.2\\.3\\)$`, 'm')); + const handed = run(f, [], { GSTACK_CAPY_CLONED: 'https://github.com/garrytan/gstack.git' }); + expect(handed.stdout).toMatch(/^gstack-capy-install: cloned https:\/\/github\.com\/garrytan\/gstack\.git at [0-9a-f]{12} \(1\.2\.3\)$/m); + expect(handed.stdout).not.toContain('reusing'); expect(fs.existsSync(path.join(clone, 'setup'))).toBe(true); const occupied = path.join(f.base, 'occupied'); write(path.join(occupied, 'README.md'), 'x'); @@ -259,28 +301,26 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd ? [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`] : [`echo ${version}`]); expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0); - const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim(); - const versions = path.join(f.root, 'bin/gstack-bun-version.sh'); - fs.writeFileSync(versions, fs.readFileSync(versions, 'utf8').replace(new RegExp(`(\\s*${target}\\) echo )[0-9a-f]{64}`), `$1${sha}`)); + trustZip(f.root, version, target, zip); sh('git add -A && git -c user.email=t@t -c user.name=t commit -qm sha', f.root); return zip; }; const verdicts = (f: ReturnType<typeof fixture>) => { const dir = path.join(f.home, '.gstack-state/install-verdicts'); - return fs.existsSync(dir) ? fs.readdirSync(dir).map(n => path.join(dir, n)) : []; + return fs.existsSync(dir) ? fs.readdirSync(dir).flatMap(n => fs.statSync(path.join(dir, n)).isDirectory() ? fs.readdirSync(path.join(dir, n)).map(m => path.join(dir, n, m)) : [path.join(dir, n)]) : []; }; test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => { const f = fixture({ bun: '1.3.14' }); - const zip = zipBun(f, '1.4.2'); - const bunDir = path.join(f.home, '.bun/bin'); + const zip = zipBun(f, '1.4.3'); + const bunDir = store(f, '1.4.3'); const first = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(first.status, first.stdout + first.stderr).toBe(0); expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`); expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`); const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(second.status, second.stdout + second.stderr).toBe(0); - expect(second.stdout).toContain(`bun 1.4.2 at ${bunDir}/bun`); + expect(second.stdout).toContain(`bun 1.4.3 at ${bunDir}/bun`); expect(second.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH `); expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1); expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(2); @@ -324,8 +364,9 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(r.status, r.stdout + r.stderr).toBe(0); expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1); expect(f.calls().find(l => l.startsWith('curl '))).toContain('bun-v1.4.2/bun-'); - expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(true); - expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${path.join(f.home, '.bun/bin')}:$PATH `); + expect(fs.existsSync(path.join(store(f, '1.4.2'), 'bun'))).toBe(true); + expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false); + expect(r.stdout).toContain(`GSTACK_LAUNCH: PATH=${store(f, '1.4.2')}:$PATH `); const other = fixture({ bun: '1.4.3' }); script(path.join(other.stub, 'bun'), ['[ "$1" = --version ] && { echo 1.4.3; exit 0; }', `exec "${process.execPath}" "$@"`]); for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(other.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); @@ -336,7 +377,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; '); }); - test('the verdict file records checkout, HEAD, workflow and trailer, keyed by the checkout; --check certifies without setup or downloads', () => { + test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --check certifies without setup or downloads', () => { const f = fixture(); const r = run(f, ['--for', 'autoplan']); expect(r.status, r.stdout + r.stderr).toBe(0); @@ -344,7 +385,10 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(files).toHaveLength(1); expect(r.stdout).toContain(`GSTACK_INSTALL_VERDICT=${files[0]}`); const v = JSON.parse(fs.readFileSync(files[0], 'utf8')); - expect(v).toMatchObject({ schema_version: 1, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan' }); + expect(v).toMatchObject({ schema_version: 2, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan', + project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' }); + const sha = (t: string) => sh(`printf %s '${t}' | sha256sum | cut -d' ' -f1`).stdout.trim(); + expect(files[0]).toBe(path.join(f.home, '.gstack-state/install-verdicts', sha(f.root), `${sha('none')}.json`)); const setups = f.calls().filter(l => l.startsWith('setup ')).length; const c = run(f, ['--check', '--for', 'autoplan']); expect(c.status, c.stdout + c.stderr).toBe(0); @@ -357,6 +401,364 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd }); }); +// Plan A1/A3/A5/A6/A-f: install and --check resolve through one contract, +// never overwrite ~/.bun/bin or $BUN_INSTALL, record the resolution per +// (checkout, project), and report drift with one converging fix. +describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resolution contract, runtime store, drift', () => { + const realBun = (file: string, version: string) => script(file, [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]); + const withPins = (f: ReturnType<typeof fixture>) => { for (const rel of ['bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts']) write(path.join(f.root, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); }; + const ciProject = (f: ReturnType<typeof fixture>, name: string, bunPin: string) => { + const project = path.join(f.base, name); + write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=1.4.0" } }\n'); + write(path.join(project, '.github/workflows/ci.yml'), `jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: ${bunPin}\n`); + sh('git init -q', project); + return project; + }; + const target = () => sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim(); + const zipFor = (f: ReturnType<typeof fixture>, version: string) => { + const t = target(); + const dir = path.join(f.base, `z-${version}`); + realBun(path.join(dir, `bun-${t}/bun`), version); + const zip = path.join(f.base, `bun-${version}.zip`); + expect(sh(`cd "${dir}" && zip -qr "${zip}" bun-${t}`).status).toBe(0); + trustZip(f.root, version, t, zip); + return zip; + }; + const verdictFor = (f: ReturnType<typeof fixture>, r: { stdout: string }) => JSON.parse(fs.readFileSync(r.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8')); + + test('GBRA-60: 1.4.2 in $BUN_INSTALL and 1.4.3 in ~/.bun/bin with a CI-lane pin of 1.4.3 — install and --check agree on ~/.bun/bin/bun, nothing is downloaded or overwritten', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const bi = path.join(f.base, 'bun-install'); + realBun(path.join(bi, 'bin/bun'), '1.4.2'); + realBun(path.join(f.home, '.bun/bin/bun'), '1.4.3'); + const project = ciProject(f, 'gbrain', '1.4.3'); + const env = { BUN_INSTALL: bi }; + const r = run(f, ['--for', 'review'], env, project); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`bun 1.4.3 at ${f.home}/.bun/bin/bun (~/.bun/bin; satisfies gstack's supported minimum 1.4.2 and the project pin 1.4.3)`); + expect(f.calls().some(l => l.startsWith('curl '))).toBe(false); + expect(sh(`"${bi}/bin/bun" --version`).stdout.trim()).toBe('1.4.2'); + expect(verdictFor(f, r)).toMatchObject({ project, pin: '1.4.3', bun_path: `${f.home}/.bun/bin/bun`, bun_version: '1.4.3', bun_rule: 'home' }); + const c = run(f, ['--check', '--for', 'review'], env, project); + expect(c.status, c.stdout + c.stderr).toBe(0); + expect(c.stdout).toContain(`GSTACK_LAUNCH: PATH=${f.home}/.bun/bin:$PATH `); + const outside = run(f, ['--check', '--for', 'autoplan'], env, f.base); + expect(outside.status, outside.stdout + outside.stderr).toBe(0); + expect(outside.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `); + }); + + // Integration item 1: gstack's own CI pins 1.4.2 on every lane and 1.4.3 on + // the install-default job. They are alternatives, so the real checkout as + // --project certifies with either Bun; a repo whose CI pins only 1.4.3 still + // refuses 1.4.2. + test('the real gstack checkout as --project with a separate global install certifies with ~/.bun 1.4.2 and with 1.4.3; nothing is downloaded', () => { + for (const version of ['1.4.2', '1.4.3']) { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + realBun(path.join(f.home, '.bun/bin/bun'), version); + const r = run(f, ['--for', 'review', '--project', REPO], {}, f.home); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stderr).not.toContain('RUNTIME_PIN_CONFLICT'); + expect(r.stdout).toContain(`bun ${version} at ${f.home}/.bun/bin/bun ($BUN_INSTALL/bin; satisfies gstack's supported minimum 1.4.2 and the project's CI pins (alternatives: 1.4.2 1.4.3))`); + expect(f.calls().some(l => l.startsWith('curl '))).toBe(false); + expect(verdictFor(f, r)).toMatchObject({ project: REPO, bun_version: version, bun_rule: 'BUN_INSTALL' }); + expect(run(f, ['--check', '--for', 'review', '--project', REPO], {}, f.home).status).toBe(0); + } + }); + + test('CI alternatives with nothing installed: the install default is published when it is one of them; a gbrain-like repo (CI 1.4.3 only) refuses ~/.bun 1.4.2 and installs 1.4.3', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const r = run(f, ['--for', 'review', '--project', REPO], { STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.home); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (`); + const g = fixture({ bun: '1.3.14' }); + withPins(g); + realBun(path.join(g.home, '.bun/bin/bun'), '1.4.2'); + const project = ciProject(g, 'gbrain', '1.4.3'); + const gr = run(g, ['--for', 'review'], { STUB_CURL_ZIP: zipFor(g, '1.4.3') }, project); + expect(gr.status, gr.stdout + gr.stderr).toBe(0); + expect(gr.stdout).toContain(`judged: bun 1.4.2 at ${g.home}/.bun/bin/bun (BUN_INSTALL)`); + expect(gr.stdout).toContain(`bun 1.4.3 at ${store(g, '1.4.3')}/bun (the project's exact pin`); + }); + + test('no installed Bun satisfies the pins: the line names the Bun actually judged, the exact pin is published in the store, and the next --check agrees', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const bi = path.join(f.base, 'bun-install'); + realBun(path.join(bi, 'bin/bun'), '1.4.2'); + const project = ciProject(f, 'gbrain', '1.4.3'); + const env = { BUN_INSTALL: bi, STUB_CURL_ZIP: zipFor(f, '1.4.3') }; + const r = run(f, ['--for', 'review'], env, project); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`no installed Bun satisfies gstack's supported minimum 1.4.2 and the project pins bun >=1.4.0 (engines.bun, package.json:1); bun 1.4.3 (CI linux lane, ci.yml:7) (judged: bun 1.4.2 at ${bi}/bin/bun (BUN_INSTALL), bun 1.3.14 at ${f.stub}/bun (PATH)); installing bun 1.4.3`); + expect(r.stdout).not.toContain(`bun 1.3.14 at ${f.stub}/bun does not satisfy`); + expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (the project's exact pin, from gstack's runtime store;`); + expect(fs.readlinkSync(path.join(store(f, '1.4.3'), 'bunx'))).toBe('bun'); + expect(sh(`"${bi}/bin/bun" --version`).stdout.trim()).toBe('1.4.2'); + const c = run(f, ['--check', '--for', 'review'], { BUN_INSTALL: bi }, project); + expect(c.status, c.stdout + c.stderr).toBe(0); + }); + + test('a broken store entry is repaired in place: the version directory is never moved aside, bun is renamed over its predecessor and bunx is published', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const dir = store(f, '1.4.3'); + realBun(path.join(dir, 'bun'), '1.4.0'); + const inode = fs.statSync(dir).ino; + const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.base); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.statSync(dir).ino).toBe(inode); + expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe('1.4.3'); + expect(fs.readlinkSync(path.join(dir, 'bunx'))).toBe('bun'); + expect(fs.readdirSync(path.dirname(dir)).filter(n => n.startsWith('.tmp'))).toEqual([]); + }); + + test('drift: a deleted recorded Bun fails --check with BUN_RESOLUTION_DRIFT, and following the printed fix makes the next --check pass', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const bi = path.join(f.base, 'bun-install'); + realBun(path.join(bi, 'bin/bun'), '1.4.2'); + realBun(path.join(f.home, '.bun/bin/bun'), '1.4.3'); + const project = ciProject(f, 'gbrain', '1.4.3'); + expect(run(f, ['--for', 'review'], { BUN_INSTALL: bi }, project).status).toBe(0); + fs.rmSync(path.join(f.home, '.bun/bin/bun')); + const c = run(f, ['--check', '--for', 'review'], { BUN_INSTALL: bi, GSTACK_CAPY_WRAPPER_PATH: '/drive/install.sh' }, project); + expect(c.status).toBe(1); + expect(c.stdout).toContain(`FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at ${f.home}/.bun/bin/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /drive/install.sh --for review (reinstalls bun 1.4.3 into ${path.dirname(store(f, '1.4.3'))})`); + expect(lastLine(c.stdout)).toBe('gstack: fail runtime'); + expect(verdictFor(f, c)).toMatchObject({ ok: false, bun_path: `${f.home}/.bun/bin/bun` }); + expect(run(f, ['--for', 'review'], { BUN_INSTALL: bi, STUB_CURL_ZIP: zipFor(f, '1.4.3') }, project).status).toBe(0); + const again = run(f, ['--check', '--for', 'review'], { BUN_INSTALL: bi }, project); + expect(again.status, again.stdout + again.stderr).toBe(0); + expect(lastLine(again.stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); + }); + + // Integration item 5: drift is only the recorded Bun gone, changed or no + // longer satisfying the pins. A changed BUN_INSTALL or a newly populated + // store default is a note, and the recorded Bun stays the launch Bun. + test('drift false positives: a changed BUN_INSTALL or a newer store default only notes; the recorded Bun stays certified and launched', () => { + const f = fixture({ bun: '1.3.14' }); + const bi = path.join(f.base, 'bun-install'); + realBun(path.join(bi, 'bin/bun'), '1.4.2'); + expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0); + const unset = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: '' }); + expect(unset.status, unset.stdout + unset.stderr).toBe(0); + expect(unset.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `); + realBun(path.join(store(f, '1.4.3'), 'bun'), '1.4.3'); + const newer = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi }); + expect(newer.status, newer.stdout + newer.stderr).toBe(0); + expect(newer.stdout).toContain(`note: the recorded bun 1.4.2 at ${bi}/bin/bun stays certified; bun 1.4.3 at ${store(f, '1.4.3')}/bun (default) would now resolve first; run ${f.root}/bin/gstack-capy-install --for autoplan to record the current resolution`); + expect(newer.stdout).toContain(`GSTACK_LAUNCH: PATH=${bi}/bin:$PATH `); + expect(newer.stdout).not.toContain('BUN_RESOLUTION_DRIFT'); + expect(run(f, ['--for', 'autoplan'], { BUN_INSTALL: bi }).status).toBe(0); + const recorded = run(f, ['--check', '--for', 'autoplan'], { BUN_INSTALL: bi }); + expect(recorded.status).toBe(0); + expect(recorded.stdout).not.toContain('note: the recorded bun'); + }); + + test('drift: a recorded Bun that no longer satisfies the project pins fails --check with BUN_RESOLUTION_DRIFT', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + realBun(path.join(f.home, '.bun/bin/bun'), '1.4.2'); + const project = ciProject(f, 'p', '1.4.2'); + expect(run(f, ['--for', 'review'], { BUN_INSTALL: '' }, project).status).toBe(0); + write(path.join(project, '.github/workflows/ci.yml'), fs.readFileSync(path.join(project, '.github/workflows/ci.yml'), 'utf8').replace('1.4.2', '1.4.3')); + const c = run(f, ['--check', '--for', 'review'], { BUN_INSTALL: '' }, project); + expect(c.status).toBe(1); + expect(c.stdout).toContain('changed: bun 1.4.2 no longer satisfies gstack\'s supported minimum 1.4.2 and the project pins'); + expect(lastLine(c.stdout)).toBe('gstack: fail runtime'); + }); + + test('GSTACK_BUN_INSTALL_VERSION picks a verified install version and is shown; an unverified one stops before anything is installed', () => { + const f = fixture({ bun: '1.3.14' }); + const bad = run(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.9' }); + expect(bad.status).toBe(3); + expect(bad.stderr).toContain('GSTACK_BUN_INSTALL_VERSION=1.4.9 is not a verified Bun at or above gstack\'s supported minimum 1.4.2 (verified archives: 1.4.2 1.4.3)'); + expect(bad.stderr).toContain('(BUN_INSTALL_VERSION_INVALID; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-install-version-invalid)'); + expect(f.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false); + const r = run(f, ['--for', 'autoplan'], { GSTACK_BUN_INSTALL_VERSION: '1.4.2', STUB_CURL_ZIP: zipFor(f, '1.4.2') }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION (default 1.4.3)'); + expect(r.stdout).toContain(`bun 1.4.2 at ${store(f, '1.4.2')}/bun (gstack's install default, from gstack's runtime store;`); + }); + + test('concurrent installs for two projects each record their own verdict', () => { + const f = fixture(); + const a = ciProject(f, 'a', '1.4.2'), b = ciProject(f, 'b', '1.4.2'); + const cmd = `bash "${f.root}/bin/gstack-capy-install" --for review --project "${a}" > "${f.base}/a.out" 2>&1 & bash "${f.root}/bin/gstack-capy-install" --for review --project "${b}" > "${f.base}/b.out" 2>&1 & wait`; + const r = spawnSync('bash', ['-c', cmd], { cwd: f.home, encoding: 'utf8', timeout: 120_000, env: { ...f.env } }); + expect(r.status).toBe(0); + for (const [p, out] of [[a, 'a.out'], [b, 'b.out']] as const) { + const text = fs.readFileSync(path.join(f.base, out), 'utf8'); + expect(lastLine(text), text).toBe('gstack: ok 1.2.3 for=autoplan'); + expect(verdictFor(f, { stdout: text })).toMatchObject({ project: p, ok: true }); + } + }); +}); + +// Plan A4/A-e: on Capy the installer writes marked ~/.local/bin/bun and bunx +// shims that run the Bun recorded for the project around the working +// directory, in a fresh shell with Capy's PATH and no gstack environment. +describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-directory bun shim', () => { + const realBun = (file: string, version: string) => script(file, [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]); + const repo = (f: ReturnType<typeof fixture>, name: string, pin?: string) => { + const dir = path.join(f.base, name); + fs.mkdirSync(dir, { recursive: true }); + if (pin) write(path.join(dir, '.bun-version'), `${pin}\n`); + sh('git init -q', dir); + return dir; + }; + /** A fresh non-login shell: Capy's PATH (~/.local/bin first), HOME, nothing from gstack. */ + const fresh = (f: ReturnType<typeof fixture>, cwd: string, cmd: string, extra: Record<string, string> = {}) => + spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 30_000, env: { HOME: f.home, PATH: `${f.home}/.local/bin:${f.env.PATH}`, ...extra } }); + + test('fresh shells alternate between two exact pins, an unpinned project and a directory outside any project; custom state root, no env', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, 'custom-state'); + const storeDir = path.join(state, 'runtimes/bun'); + realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2'); + realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); + const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c'); + for (const dir of [a, b, c]) { + const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir); + expect(r.status, r.stdout + r.stderr).toBe(0); + } + const shim = fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8'); + expect(shim.split('\n')[1]).toBe('# gstack-bun-shim: gstack-owned; delete this file to remove it'); + expect(shim).toContain(`store='${storeDir}'`); + const outside = path.join(f.base, 'outside'); + fs.mkdirSync(outside); + for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) { + const r = fresh(f, dir, 'bun --version'); + expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want); + expect(r.stderr).toBe(''); + } + const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`)); + expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`); + const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`); + const unrecordedPinned = repo(f, 'd', '1.4.2'); + const d = fresh(f, unrecordedPinned, 'bun --version'); + expect([d.stdout.trim(), d.stderr]).toEqual(['1.4.2', '']); + const unrecorded = repo(f, 'e'); + // Integration item 6: an unrecorded, unpinned repo falls through quietly; + // only GSTACK_BUN_SHIM_EXPLAIN=1 names the missing record. + const e = fresh(f, unrecorded, 'bun --version'); + expect([e.stdout.trim(), e.stderr]).toEqual(['1.4.3', '']); + expect(fresh(f, unrecorded, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toContain(`gstack bun shim: ${unrecorded} has no recorded Bun and no exact pin; run ${f.root}/bin/gstack-capy-install --check here to record one\n`); + }); + + // Integration item 3: a repo's pin never steers the shim outside the store. + test('the generated shim rejects a traversal pin, a store entry that is a symlink escape, and a marker-carrying store binary; none of them runs', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, 'state'); + const storeDir = path.join(state, 'runtimes/bun'); + realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); + expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); + const pwned = path.join(f.base, 'pwned'); + const payload = (dir: string) => script(path.join(dir, 'bun'), [`touch "${pwned}"`, 'echo payload']); + const evilRepo = repo(f, 'repo'); + payload(path.join(evilRepo, 'payload')); + write(path.join(evilRepo, '.bun-version'), '../../../repo/payload\n'); + expect(fs.existsSync(path.join(storeDir, '../../../repo/payload/bun'))).toBe(true); + const t = fresh(f, evilRepo, 'bun --version'); + expect(t.stdout.trim()).toBe('1.4.3'); + expect(t.stderr).toContain(`${evilRepo} pins bun ../../../repo/payload, which is not in gstack's runtime store`); + payload(path.join(f.base, 'evil')); + fs.symlinkSync(path.join(f.base, 'evil'), path.join(storeDir, '1.4.9')); + const escape = fresh(f, repo(f, 'escape', '1.4.9'), 'bun --version'); + expect(escape.stdout.trim()).toBe('1.4.3'); + write(path.join(storeDir, '1.4.8/bun'), '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\necho loop\n', 0o755); + const marked = fresh(f, repo(f, 'marked', '1.4.8'), 'bun --version'); + expect(marked.stdout.trim()).toBe('1.4.3'); + expect(fs.existsSync(pwned)).toBe(false); + }); + + // Integration item 4: outside a project and inside the gstack checkout the + // shim reads the 'none' record, then falls back in the resolver's order. + test('empty store, Capy-style PATH with the image bun 1.3.14: an install that reused off-PATH ~/.bun/bin 1.4.2 is what a fresh shell runs outside a project and in the checkout; without the record ~/.bun/bin still wins over PATH', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + realBun(path.join(f.home, '.bun/bin/bun'), '1.4.2'); + const outside = path.join(f.base, 'outside'); + fs.mkdirSync(outside); + const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '' }, outside); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`bun 1.4.2 at ${f.home}/.bun/bin/bun (~/.bun/bin;`); + expect(fs.existsSync(path.join(f.home, '.gstack-state/runtimes/bun/1.4.3'))).toBe(false); + for (const dir of [outside, f.root]) { + const e = fresh(f, dir, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(e.stdout.trim(), e.stderr).toBe('1.4.2'); + expect(e.stderr).toContain(`chose=${f.home}/.bun/bin/bun rule=record`); + } + fs.rmSync(path.join(f.home, '.gstack-state/install-verdicts'), { recursive: true, force: true }); + const home = fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(home.stdout.trim()).toBe('1.4.2'); + expect(home.stderr).toContain(`record=none chose=${f.home}/.bun/bin/bun rule=home`); + fs.rmSync(path.join(f.home, '.bun'), { recursive: true, force: true }); + const image = fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(image.stdout.trim()).toBe('1.3.14'); + expect(image.stderr).toContain(`chose=${f.stub}/bun rule=PATH`); + }); + + test('paths with a single quote are baked safely into both shims', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, "it's state"); + realBun(path.join(state, 'runtimes/bun/1.4.3/bun'), '1.4.3'); + expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); + expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0); + const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim().split('\n')[0]).toBe('1.4.3'); + expect(r.stderr.split(`chose=${state}/runtimes/bun/1.4.3/bun rule=record`), r.stderr).toHaveLength(3); + }); + + test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => { + const f = fixture(); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + write(path.join(f.home, '.local/bin/bun'), '#!/bin/sh\necho mine\n', 0o755); + fs.symlinkSync(path.join(f.base, 'missing'), path.join(f.home, '.local/bin/bunx')); + const r = run(f, ['--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8')).toBe('#!/bin/sh\necho mine\n'); + expect(fs.readlinkSync(path.join(f.home, '.local/bin/bunx'))).toBe(path.join(f.base, 'missing')); + expect(r.stdout).toContain(`${f.home}/.local/bin/bun is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix`); + const g = fixture(); + fs.mkdirSync(path.join(g.home, '.capy/system'), { recursive: true }); + expect(run(g, [], { GSTACK_BUN_SHIM: '0' }).status).toBe(0); + expect(fs.existsSync(path.join(g.home, '.local/bin/bun'))).toBe(false); + expect(run(g).status).toBe(0); + const first = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8'); + expect(run(g, [], { GSTACK_STATE_ROOT: path.join(g.base, 'moved') }).status).toBe(0); + const second = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8'); + expect(second).not.toBe(first); + expect(second).toContain(`store='${path.join(g.base, 'moved')}/runtimes/bun'`); + const plain = fixture(); + expect(run(plain).status).toBe(0); + expect(fs.existsSync(path.join(plain.home, '.local/bin/bun'))).toBe(false); + }); + + test('the shim adds little over the Bun it runs (fixture timing bound)', () => { + const f = fixture(); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun'); + realBun(storeBun, '1.4.3'); + expect(run(f).status).toBe(0); + const time = (cmd: string) => { + const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y); + return samples[3]; + }; + const overhead = time('bun --version') - time(`"${storeBun}" --version`); + expect(overhead).toBeLessThan(250); + }); +}); + function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) { return f.run(args, extra, cwd); } diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index fa1528c09..3dddefea5 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -473,10 +473,12 @@ describe('gstack-doctor --check', () => { installCodex(f); const r = row(f, []); expect(r.status, r.out).toBe(0); - expect(r.required).toEqual(['claude', 'codex', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']); - for (const id of r.required.filter(x => x !== 'pins')) expect(r.rows[id]?.status, `${id}\n${r.out}`).toBe('PASS'); + expect(r.required).toEqual(['claude', 'codex', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'wrapper']); + for (const id of r.required.filter(x => x !== 'pins' && x !== 'wrapper')) expect(r.rows[id]?.status, `${id}\n${r.out}`).toBe('PASS'); // The work dir is not a git repository, so there is no project to read pins from: SKIP with the reason, never silent. expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project') }); + // No ~/.capy/drive skill copy and no active wrapper: not a Capy machine. + expect(r.rows.wrapper).toEqual({ status: 'SKIP', rest: expect.stringContaining('not a Capy machine') }); expect(r.rows.patch.status).toBe('PASS'); expect(r.rows['codex-cli'].status).toBe('PASS'); expect(r.rows.browser).toEqual({ status: 'SKIP', rest: '(lazy; gstack-browser-ensure installs on first use)' }); @@ -495,7 +497,7 @@ describe('gstack-doctor --check', () => { fs.rmSync(path.join(f.stub, 'codex')); const r = row(f, ['--for', 'autoplan']); expect(r.status, r.out).toBe(0); - expect(r.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']); + expect(r.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'wrapper']); expect(r.rows.patch).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/SKILL.md)' }); expect(r.rows['codex-cli'].status).toBe('SKIP'); expect(r.rows['codex-cli'].rest).toContain('optional'); @@ -694,8 +696,8 @@ describe('gstack-doctor --check', () => { expect(json.for).toBe('autoplan'); expect(json.ok).toBe(false); expect(json.failed).toEqual(['runtime']); - expect(json.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']); - expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']); + expect(json.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'wrapper']); + expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'wrapper']); const runtime = json.rows.find((x: { id: string }) => x.id === 'runtime'); expect(runtime.status).toBe('FAIL'); expect(runtime.code).toBe('RUNTIME_BELOW_MINIMUM'); @@ -784,3 +786,107 @@ describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pi } }); }); + +// Plan A8/A-h: the Capy skill copy. Active-wrapper provenance decides; without +// it the Drive copies do, failing only when every copy is stale. The printed +// workaround (the checkout's own wrapper) certifies with the stale Drive copy +// left unchanged. Plan A5: a drifted resolution is a runtime FAIL. +describe('gstack-doctor --check: wrapper and runtime drift', () => { + const posixPathTest = process.platform === 'win32' ? test.skip : test; + const PRE_BOOTSTRAPPER = '#!/usr/bin/env bash\n# gstack Capy skill installer (pre-bootstrapper)\nset -u\nroot="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"\nexec "$root/bin/gstack-capy-install" "$@"\n'; + const parse = (f: Fixture, args: string[], extra: Record<string, string> = {}) => { + const r = doctor(f, ['--check', ...args], extra); + const line = (id: string) => r.out.split('\n').find(l => new RegExp(`^(PASS|FAIL|SKIP|WARN) ${id}\\b`).test(l)) ?? ''; + return { status: r.status, out: r.out, line, trailer: r.out.split('\n').filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; + }; + const drive = (f: Fixture, scope: string, text: string) => { const file = path.join(f.home, '.capy/drive', scope, 'skills/gstack/scripts/install.sh'); write(file, text); return file; }; + const withSupport = (f: Fixture, min = 1) => write(path.join(f.root, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.0.0\nGSTACK_CAPY_MIN_WRAPPER=${min}\n`); + const current = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/scripts/install.sh'), 'utf8'); + const currentRev = current.match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)![1]; + + posixPathTest('direct doctor run: a pre-bootstrapper Drive copy (no revision line) fails wrapper with both fixes; a current copy is not flagged', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); + const r = parse(f, []); + expect(r.status).toBe(1); + expect(r.trailer).toBe('gstack: fail wrapper'); + const row = r.line('wrapper'); + expect(row).toContain(`FAIL wrapper — every Capy skill copy is stale (minimum wrapper 1): ${stale} (wrapper 0); fix: bash ${f.root}/capy/skill/gstack/scripts/install.sh --check (this session); owner: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes`); + expect(row).toContain('(CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)'); + write(stale, current); + const fresh = parse(f, []); + expect(fresh.line('wrapper')).toBe(`PASS wrapper Drive copy ${stale} (wrapper ${currentRev}) (minimum 1)`); + expect(fresh.status, fresh.out).toBe(0); + }); + + posixPathTest('two Drive copies, one current: PASS naming the stale one; GSTACK_CAPY_WRAPPER_PATH alone decides when set without a revision', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'org', PRE_BOOTSTRAPPER); + const good = drive(f, 'user', current); + const r = parse(f, []); + expect(r.line('wrapper')).toBe(`PASS wrapper Drive copy ${good} (wrapper ${currentRev}) (minimum 1); stale copies: ${stale} (wrapper 0)`); + expect(parse(f, [], { GSTACK_CAPY_WRAPPER_PATH: stale }).trailer).toBe('gstack: fail wrapper'); + }); + + posixPathTest('the printed workaround certifies: a current active wrapper passes with the stale Drive copy unchanged (WARN, owner action); a stale active wrapper is a WARN too', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); + const checkoutCopy = path.join(f.root, 'capy/skill/gstack/scripts/install.sh'); + const r = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }); + expect(r.status, r.out).toBe(0); + expect(r.trailer).toMatch(/^gstack: ok /); + expect(r.line('wrapper')).toBe(`WARN wrapper — active wrapper 1 (${checkoutCopy}) is current; stale Drive copies (minimum 1): ${stale} (wrapper 0); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + expect(fs.readFileSync(stale, 'utf8')).toBe(PRE_BOOTSTRAPPER); + const json = JSON.parse(doctor(f, ['--check', '--json'], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }).out); + expect(json.ok).toBe(true); + expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); + withSupport(f, 2); + const old = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: stale }); + // Integration item 2: an active copy below the minimum still works, so it + // is an owner-action WARN and the trailer stays ok. + expect(old.status, old.out).toBe(0); + expect(old.trailer).toMatch(/^gstack: ok /); + expect(old.line('wrapper')).toBe(`WARN wrapper — the active Capy skill copy ${stale} is wrapper 1, minimum 2 (it still works); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + }); + + test('a drifted Bun resolution is a runtime FAIL carrying the installer\'s detail, fix and BUN_RESOLUTION_DRIFT', () => { + const f = makeFixture(); + const r = parse(f, [], { GSTACK_DOCTOR_RUNTIME_DRIFT: 'recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone', GSTACK_DOCTOR_RUNTIME_DRIFT_FIX: 'bash /d/install.sh --for review' }); + expect(r.trailer).toBe('gstack: fail runtime'); + expect(r.line('runtime')).toBe('FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /d/install.sh --for review (BUN_RESOLUTION_DRIFT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-resolution-drift)'); + }); + + posixPathTest('the runtime row shows gstack\'s bun shim and where it resolves from here, or names a foreign ~/.local/bin/bun it left alone', () => { + const f = makeFixture(); + const shim = path.join(f.home, '.local/bin/bun'); + write(shim, '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\n[ "$GSTACK_BUN_SHIM_EXPLAIN" = 1 ] && echo "gstack bun shim: project=none record=none chose=/store/1.4.3/bun rule=default" >&2\necho 1.4.3\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; shim ${shim} -> /store/1.4.3/bun`); + write(shim, '#!/bin/sh\necho mine\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; ${shim} is not gstack's shim (no marker), left untouched: launch with the GSTACK_LAUNCH line`); + expect(parse(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.2' }).line('runtime')).toContain('; install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION'); + }); + + test('A-a: the Capy skill\'s dispatch examples (fail revision, fail runtime,pins, fail pins) match the real failure heads, unchanged', () => { + const skill = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/SKILL.md'), 'utf8'); + const step = skill.split('\n').find(l => l.includes('If the trailer names `revision`, `runtime` or `pins`'))!; + const examples = [...step.matchAll(/`(gstack: fail [^`]+)`/g)].map(m => m[1]); + expect(examples).toEqual(['gstack: fail revision …', 'gstack: fail runtime,pins', 'gstack: fail pins']); + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, '.bun-version'), '1.4.2\n'); + const real: Record<string, string> = { + revision: parse(f, ['--require', 'claude', '--min-revision', '99.0.0.0']).trailer, + 'runtime,pins': parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.3.14' }).trailer, + pins: parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.4.3' }).trailer, + }; + for (const example of examples) { + const ids = example.slice('gstack: fail '.length).replace(/ …$/, ''); + const head = real[ids]; + expect(head === `gstack: fail ${ids}` || head.startsWith(`gstack: fail ${ids} — `), `${example} vs ${head}`).toBe(true); + expect(head.match(/^gstack: fail ([a-z,-]+)/)?.[1].split(',')).toEqual(ids.split(',')); + } + }); +}); diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index 13c97b6d3..98f4e3420 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -232,6 +232,124 @@ describe('gstack-upgrade', () => { expect(again.stdout).toContain('gstack-upgrade: current 1.0.1'); }); + // Plan A7/A-b: every success path certifies the caller's project. + const STUB_CAPY = [ + '#!/usr/bin/env bash', + 'echo "capy-install $* cwd=$(pwd -P)" >> "$UPGRADE_LOG"', + 'case " $* " in *" --project "*)', + ' [ -n "${STUB_PIN_CONFLICT:-}" ] && { echo "gstack-capy-install: Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), $2/package.json pins exactly 1.3.14; fix: raise the project\'s pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)" >&2; exit 3; }', + ' [ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — bun 1.4.2 outside CI 1.4.3; fix: x (PROJECT_PIN_MISMATCH; d)"; echo "gstack: fail pins"; exit 1; }', + ' [ -n "${STUB_PROJECT_STOP:-}" ] && { echo "gstack-capy-install: preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; } ;;', + 'esac', + 'echo "wrapper-env rev=${GSTACK_CAPY_WRAPPER_REV:-} path=${GSTACK_CAPY_WRAPPER_PATH:-}" >> "$UPGRADE_LOG.env"', + '[ -n "${STUB_STOP:-}" ] && { echo "gstack-capy-install: preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; }', + '[ -n "${STUB_WRAPPER_FAIL:-}" ] && { echo "FAIL wrapper — every Capy skill copy is stale; fix: x (CAPY_WRAPPER_STALE; d)"; echo "gstack: fail wrapper"; exit 1; }', + '[ -f "${STUB_FAIL_ONCE:-/nonexistent}" ] && { rm -f "$STUB_FAIL_ONCE"; echo "gstack: fail setup — ./setup failed"; exit 1; }', + 'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"', + ].join('\n') + '\n'; + const capyFixture = () => { + const f = fixture(); + write(path.join(f.root, 'bin/gstack-capy-install'), STUB_CAPY, 0o755); + write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`); + const project = path.join(f.host.base, 'project'); + fs.mkdirSync(project, { recursive: true }); + spawnSync('git', ['init', '-q', project], { timeout: 30_000 }); + const from = (cwd: string, args: string[] = [], extra: Record<string, string> = {}) => + spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade'), ...args], { cwd, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), ...extra }, timeout: 60_000 }); + return { ...f, project: fs.realpathSync(project), from, pending: path.join(f.root, '.git/gstack-upgrade.pending') }; + }; + + test('A-b: from a RUNTIME_PIN_CONFLICT project the move is kept, `upgraded` then `gstack: fail pins`, exit 1, no pending marker; the no-op path says the same', () => { + const f = capyFixture(); + const r = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' }); + expect(r.status, r.stdout + r.stderr).toBe(1); + const lines = r.stdout.trim().split('\n'); + expect(lines[0]).toBe('gstack-upgrade: upgraded 1.0.0 -> 1.0.1'); + expect(lines.at(-1)).toBe(`gstack: fail pins — Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), ${f.project}/package.json pins exactly 1.3.14; fix: raise the project's pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)`); + expect(r.stdout).not.toContain('gstack: ok'); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); + expect(fs.existsSync(f.pending)).toBe(false); + expect(f.calls()).toEqual([`capy-install cwd=${f.root}`, `capy-install --project ${f.project} cwd=${f.project}`]); + const again = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' }); + expect(again.status).toBe(1); + expect(again.stdout.trim().split('\n')[0]).toBe('gstack-upgrade: current 1.0.1'); + expect(again.stdout.trim().split('\n').at(-1)).toMatch(/^gstack: fail pins — Bun conflict: /); + expect(f.from(f.project, ['--check'], { STUB_PIN_CONFLICT: '1' }).status).toBe(0); + }); + + test('a verified-pin project ends on the installer\'s ok for that project; a pins failure ends on its `gstack: fail pins`; from /tmp nothing is certified', () => { + const f = capyFixture(); + const ok = f.from(f.project); + expect(ok.status, ok.stdout + ok.stderr).toBe(0); + expect(ok.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan'); + expect(ok.stdout.match(/^gstack: /gm)).toHaveLength(1); + const fail = f.from(f.project, [], { STUB_PINS_FAIL: '1' }); + expect(fail.status).toBe(1); + expect(fail.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins'); + expect(fs.existsSync(f.pending)).toBe(false); + const g = capyFixture(); + const outside = g.from(fs.realpathSync(g.host.base), [], { STUB_PINS_FAIL: '1' }); + expect(outside.status, outside.stdout + outside.stderr).toBe(0); + expect(g.calls()).toEqual([`capy-install cwd=${g.root}`]); + expect(outside.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan'); + }); + + test('a pending repair resumed from another project certifies that project', () => { + const f = capyFixture(); + const once = path.join(f.host.base, 'fail-once'); + write(once, ''); + const failed = f.from(fs.realpathSync(f.host.base), [], { STUB_FAIL_ONCE: once }); + expect(failed.status).toBe(1); + expect(failed.stdout).toContain('gstack: fail setup'); + expect(fs.existsSync(f.pending)).toBe(true); + const resumed = f.from(f.project); + expect(resumed.status, resumed.stdout + resumed.stderr).toBe(0); + expect(resumed.stdout).toContain('gstack-upgrade: current 1.0.1; reinstall pending from 1.0.0'); + expect(fs.existsSync(f.pending)).toBe(false); + expect(f.calls().at(-1)).toBe(`capy-install --project ${f.project} cwd=${f.project}`); + }); + + // Integration items 2 and 9: the wrapper row is never a reinstall failure, + // and every installer stop line becomes a trailer. + test('a reinstall whose only failing row is wrapper leaves no pending marker; the installer runs with the checkout copy as the active wrapper', () => { + const f = capyFixture(); + write(path.join(f.root, 'capy/skill/gstack/scripts/install.sh'), 'GSTACK_CAPY_WRAPPER=7\n'); + const r = f.from(fs.realpathSync(f.host.base), [], { STUB_WRAPPER_FAIL: '1' }); + expect(r.status).toBe(1); + expect(r.stdout).toContain('gstack: fail wrapper'); + expect(r.stdout).toContain('only the wrapper row failed (an owner action on the Drive copy, CAPY_WRAPPER_STALE), so no reinstall is pending'); + expect(r.stderr).not.toContain('completes the pending reinstall'); + expect(fs.existsSync(f.pending)).toBe(false); + const again = f.from(fs.realpathSync(f.host.base)); + expect(again.stdout).not.toContain('reinstall pending'); + expect(fs.readFileSync(path.join(f.host.base, 'calls.log.env'), 'utf8')).toContain(`rev=7 path=${f.root}/capy/skill/gstack/scripts/install.sh`); + }); + + test('an installer stop line maps to `gstack: fail runtime — <msg>`: in the caller-project check (no pending) and in the reinstall (pending kept)', () => { + const f = capyFixture(); + const cert = f.from(f.project, [], { STUB_PROJECT_STOP: '1' }); + expect(cert.status).toBe(1); + expect(cert.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)'); + expect(fs.existsSync(f.pending)).toBe(false); + const g = capyFixture(); + const re = g.from(g.project, [], { STUB_STOP: '1' }); + expect(re.status).toBe(1); + expect(re.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)'); + expect(fs.existsSync(g.pending)).toBe(true); + }); + + test('off Capy the caller\'s project is certified by the doctor\'s runtime and pins rows', () => { + const f = fixture(); + write(path.join(f.root, 'bin/gstack-doctor'), '#!/usr/bin/env bash\necho "doctor $*" >> "$UPGRADE_LOG"\n[ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — x"; echo "gstack: fail pins"; exit 1; }\necho "gstack: ok 1.0.1 for=require=runtime,pins"\n', 0o755); + const project = path.join(f.host.base, 'project'); + fs.mkdirSync(project); + spawnSync('git', ['init', '-q', project], { timeout: 30_000 }); + const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade')], { cwd: project, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), STUB_PINS_FAIL: '1' }, timeout: 60_000 }); + expect(r.status).toBe(1); + expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins'); + expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]); + }); + test('every bin a trailer or fix line names exists in the repo', () => { const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md', 'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh']; diff --git a/test/helpers/autoplan-capy.ts b/test/helpers/autoplan-capy.ts index fbf2c54d4..00e6bd589 100644 --- a/test/helpers/autoplan-capy.ts +++ b/test/helpers/autoplan-capy.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import { capyHost, type CapyHost } from './capy-host'; import { SKILL_REQUIREMENTS } from '../../lib/doctor-components'; import type { InstallVerdict } from '../../lib/install-verdict'; -import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture'; +import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN, resultOf } from './autoplan-run-fixture'; const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_'); const RUNNER_KIND = 'un' + 'attended'; @@ -75,7 +75,7 @@ export function phaseVia(r: CapyRunner, out: string, phase: string, findings: { if (rv.status !== 0) throw new Error(`fixture reviewer failed: ${rv.stderr}`); } const run = readRunJson(out); - const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']); - const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']); + const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']); + const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']); return { next, native, outside }; } diff --git a/test/helpers/autoplan-guard-pty.ts b/test/helpers/autoplan-guard-pty.ts index 6ec2da015..3223510d2 100644 --- a/test/helpers/autoplan-guard-pty.ts +++ b/test/helpers/autoplan-guard-pty.ts @@ -82,7 +82,7 @@ console.log(close); '6. Read the file whose path step 3 printed, completely (no offset or limit).', '7. In one message, write exactly this line as your own text: **Phase 1 complete.** and make exactly one tool call in that ' + 'same message: the Bash command `true autoplan-published ceo`.', - `8. In a later message, Read the file ${path.join(sections, 'design-phase.md')} with offset 1 and limit 20.`, + `8. In your next assistant message, without waiting for any user input, Read the file ${path.join(sections, 'design-phase.md')} with offset 1 and limit 20.`, `9. Run this Bash command alone: cat "${path.join(artifacts, 'dispatch-design.txt')}"`, '10. Call the Agent tool once with these inputs: prompt = the exact, complete output of step 9 (every line, unchanged), ' + 'description "Design review", subagent_type "general-purpose". If the agent runs in the background, end your turn and wait ' + diff --git a/test/helpers/autoplan-run-fixture.ts b/test/helpers/autoplan-run-fixture.ts index 1d478423b..c602886f6 100644 --- a/test/helpers/autoplan-run-fixture.ts +++ b/test/helpers/autoplan-run-fixture.ts @@ -49,6 +49,15 @@ export function readRun(out: string): any { export function journal(out: string): any[] { return fs.readFileSync(path.join(out, 'attempts.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } +/** The result path the open (else the latest) attempt of a voice owns: `<phase>-<voice>.<attempt>.md`. */ +export function resultOf(out: string, phase: string, voice: 'native' | 'outside'): string { + const attempts: Array<{ state: string; result: string }> = readRun(out).phases[phase]?.[voice]?.attempts ?? []; + const attempt = attempts.find(a => a.state === 'dispatched' || a.state === 'execution_unknown') ?? attempts[attempts.length - 1]; + if (!attempt) throw new Error(`${phase} ${voice} has no attempt in ${out}`); + return attempt.result; +} +/** The sealed copy a completed voice is bound to. */ +export const boundOf = (out: string, phase: string, voice: 'native' | 'outside'): string => path.join(out, readRun(out).phases[phase][voice].output); export function attemptsOf(out: string, phase: string): { native: string; outside: string } { const run = readRun(out); return { native: run.phases[phase].native.attempt, outside: run.phases[phase].outside.attempt }; @@ -65,8 +74,8 @@ export function runPhase(out: string, phase: string, o: PhaseRunOptions = {}): { if (r.status !== 0) throw new Error(`fixture reviewer failed: ${r.stderr}`); } const ids = attemptsOf(out, phase); - const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); - const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); + const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); + const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); return { next, native, outside }; } diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 36a1a3074..de46c65cd 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = { // wave's headline capability) grows the union to 1.195x. Deliberate: // the section is on-demand (loads only for Apple store targets), so // per-invocation cost for non-iOS ships is one manifest line. - maxSizeRatio: 1.6450, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integrated tree (2026-10-10). + maxSizeRatio: 1.6488, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integrated tree (2026-10-10). + next-wave Lane D (GBRA-40 item 12): the pr-body Issue links step (promotion candidates and `fix #N`, owner-confirmed Supersedes/credit, `--author` for app-identity gh, lookup failures, ISSUE_LINKS_MISSING) and the handoff's issue-links run, with the `## Issues` placeholder shortened; Step 15's `Fixes:` trailer rule offset by tightening Step 15 so the skeleton stays at 80,998; measured 309,483 / 187,706 = 1.6488 (2026-10-14). }, 'plan-ceo-review': { skill: 'plan-ceo-review', diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index c6a473980..06d53089a 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -18,6 +18,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = { 'test/fixtures/autoplan-edit-digests-al.json': ['test/autoplan-edit-digests-al.test.ts'], 'test/fixtures/autoplan-home-phase-entry-fb10.json': ['test/autoplan-method-read-audit.test.ts'], 'test/fixtures/autoplan-owned-state-edit.json': ['test/autoplan-owned-state.test.ts'], + 'test/fixtures/autoplan-pairing/run-20261014-pairs.json': ['test/autoplan-pairing.test.ts'], 'test/fixtures/autoplan-phase-consumption-491.json': ['test/autoplan-publication-guard.test.ts'], 'test/fixtures/autoplan-phase-dash-ao.json': ['test/autoplan-phase-observer.test.ts'], 'test/fixtures/autoplan-phase-entry-alias-f359.json': ['test/autoplan-method-read-audit.test.ts'], diff --git a/test/helpers/pregate-fixture.ts b/test/helpers/pregate-fixture.ts index 3226e90b5..3d1e819e5 100644 --- a/test/helpers/pregate-fixture.ts +++ b/test/helpers/pregate-fixture.ts @@ -160,11 +160,26 @@ export function cleanup(repo: FixtureRepo): void { fs.rmSync(repo.root, { recursive: true, force: true }); } -/** A `gh` on PATH answering `gh issue view <n> --json ...` from a map and `gh issue list` / `gh run view` from fixed data. */ -export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknown>; issueList?: unknown[]; runs?: Record<string, unknown> }): { PATH: string } { +/** + * A `gh` on PATH answering `gh issue view <n> --json ...` from a map, `gh issue list` / `gh run view` from fixed data, + * `gh repo view` from `repo` (null fails), the closing-PR `gh api graphql` query from `closing` (issue → pages of + * nodes; `after=c<i>` reads page i; a missing issue fails like an API error) and the PR-author query (`head=`) + * from `headAuthor` (the HEAD commit's GitHub user, null when GitHub has no such commit) and `ownerType`. + */ +export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknown>; issueList?: unknown[]; runs?: Record<string, unknown>; repo?: string | null; viewer?: string; closing?: Record<string, unknown[][]>; headAuthor?: string | null; ownerType?: 'User' | 'Organization' }): { PATH: string } { const dir = path.join(repo.root, 'gh-shim'); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ issues: {}, issueList: [], runs: {}, ...data })); + fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ issues: {}, issueList: [], runs: {}, repo: 'owner/repo', viewer: 'me', closing: {}, headAuthor: null, ownerType: 'User', ...data })); + fs.writeFileSync(path.join(dir, 'api.js'), [ + 'const d = require(process.argv[2]); const a = process.argv.slice(3);', + 'const arg = k => { const i = a.findIndex(x => x.startsWith(k + "=")); return i < 0 ? undefined : a[i].slice(k.length + 1); };', + 'if (a[0] === "repo") { if (d.repo === null) { console.error("no git remotes found"); process.exit(1); } console.log(JSON.stringify({ nameWithOwner: d.repo })); process.exit(0); }', + 'if (arg("head") !== undefined) { console.log(JSON.stringify({ data: { repository: { owner: { __typename: d.ownerType, login: d.repo.split("/")[0] }, object: d.headAuthor === null ? null : { author: { user: { login: d.headAuthor } } } } } })); process.exit(0); }', + 'const pages = d.closing[arg("number")]; if (!pages) { console.error("HTTP 502: Bad Gateway (https://api.github.com/graphql)"); process.exit(1); }', + 'const i = Number((arg("after") || "c0").slice(1));', + 'console.log(JSON.stringify({ data: { viewer: { login: d.viewer }, repository: { issue: { closedByPullRequestsReferences: { nodes: pages[i] || [], pageInfo: { hasNextPage: i + 1 < pages.length, endCursor: "c" + (i + 1) } } } } } }));', + '', + ].join('\n')); const script = [ '#!/bin/sh', `DATA="${path.join(dir, 'data.json')}"`, @@ -172,8 +187,10 @@ export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknow ' node -e "const d=require(process.argv[1]);const p=d.issues[process.argv[2]];if(!p){console.error(\'GraphQL: Could not resolve to an Issue\');process.exit(1)}console.log(JSON.stringify(p))" "$DATA" "$3"', ' exit $?', 'fi', - '# issue list: entries whose body contains the quoted --search token, numbers only (what gstack-issue-links asks for)', - 'if [ "$1" = "issue" ] && [ "$2" = "list" ]; then node -e "const a=process.argv.slice(2);const q=(a[a.indexOf(\'--search\')+1]||\'\').replace(/^\\"|\\" in:body$/g,\'\');console.log(JSON.stringify(require(process.argv[1]).issueList.filter(i=>!q||String(i.body||\'\').includes(q)).map(i=>({number:i.number}))))" "$DATA" "$@"; exit $?; fi', + '# issue list: entries whose body contains the quoted --search token (number, title, body, author: what gstack-issue-links asks for)', + 'if [ "$1" = "issue" ] && [ "$2" = "list" ]; then node -e "const a=process.argv.slice(2);const q=(a[a.indexOf(\'--search\')+1]||\'\').replace(/^\\"|\\" in:body$/g,\'\');console.log(JSON.stringify(require(process.argv[1]).issueList.filter(i=>!q||String(i.body||\'\').includes(q))))" "$DATA" "$@"; exit $?; fi', + `if [ "$1" = "repo" ] && [ "$2" = "view" ]; then exec node "${path.join(dir, 'api.js')}" "$DATA" repo; fi`, + `if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then shift 2; exec node "${path.join(dir, 'api.js')}" "$DATA" "$@"; fi`, 'if [ "$1" = "run" ] && [ "$2" = "view" ]; then', ' node -e "const d=require(process.argv[1]);const p=d.runs[process.argv[2]];if(!p){console.error(\'run not found\');process.exit(1)}console.log(JSON.stringify(p))" "$DATA" "$3"', ' exit $?', diff --git a/test/helpers/qa-functional-fixture.ts b/test/helpers/qa-functional-fixture.ts index 882d01c9b..e9e48e0f1 100644 --- a/test/helpers/qa-functional-fixture.ts +++ b/test/helpers/qa-functional-fixture.ts @@ -276,6 +276,8 @@ test('successful delivery', () => { fixtureGit(root, ['config', 'user.name', 'QA Fixture'], remaining()); fixtureGit(root, ['config', 'user.email', 'qa-fixture@gstack.test'], remaining()); fixtureGit(root, ['config', 'commit.gpgsign', 'false'], remaining()); + fixtureGit(root, ['config', 'gc.auto', '0'], remaining()); + fixtureGit(root, ['config', 'maintenance.auto', 'false'], remaining()); fixtureGit(root, ['add', '.'], remaining()); fixtureGit(root, ['commit', '-m', 'Seed owned functional QA fixture'], remaining()); const revision = fixtureGit(root, ['rev-parse', 'HEAD'], remaining()); diff --git a/test/host-conformance.test.ts b/test/host-conformance.test.ts index a3b21f474..6e7ea0f42 100644 --- a/test/host-conformance.test.ts +++ b/test/host-conformance.test.ts @@ -129,6 +129,7 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { const rows = registryRows(f); expect(rows.map(row => [row[0], row[1], row[3], row[5], row[6]])).toEqual([[host, 'global', dest, realpathSync(src), readFileSync(join(src, 'VERSION'), 'utf8').trim()]]); expect(r.stdout + r.stderr).toMatch(new RegExp(`Install summary:[\\s\\S]*\\b${host}\\s+\\S+\\s+global\\s+installed`)); + expect(r.stdout.trim().split('\n').at(-1)).toBe(`gstack ready (${host})`); const status = runSetup(f, join(src, 'setup'), ['--status']); expect(status.status).toBe(0); @@ -231,6 +232,7 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { expect(upgrade.status, upgrade.stdout + upgrade.stderr).toBe(0); expect(upgrade.stdout).toContain(`Refreshing registered gstack installs from source: ${realpathSync(src)}`); expect(upgrade.stdout).toMatch(/Upgrade summary:\n[\s\S]*claude\s+full\s+global\s+unchanged\s+1\.91\.14\.0[\s\S]*codex\s+experimental\s+global\s+installed\s+1\.91\.14\.0/); + expect(upgrade.stdout.trim().split('\n').at(-1)).toBe('gstack ready (claude, codex)'); expect(registryRows(f).map(r => [r[0], r[6]]).sort()).toEqual([['claude', '1.91.14.0'], ['codex', '1.91.14.0']]); // The refreshed Codex install runs the new checkout's render. expect(realpathSync(join(f.home, '.codex/skills/gstack-review/SKILL.md'))).toBe(join(realpathSync(src), '.agents/skills/gstack-review/SKILL.md')); diff --git a/test/install-verdict.test.ts b/test/install-verdict.test.ts index 64055d14b..5729b55b0 100644 --- a/test/install-verdict.test.ts +++ b/test/install-verdict.test.ts @@ -12,7 +12,7 @@ import { createHash } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { checkInstallAdmission, installVerdictPath, readInstallVerdict, sourceRoot } from '../lib/install-verdict'; +import { checkInstallAdmission, installVerdictPath, legacyInstallVerdictPath, readInstallVerdict, sourceRoot } from '../lib/install-verdict'; const ROOT = path.resolve(import.meta.dir, '..'); const REAL = fs.realpathSync(ROOT); @@ -20,8 +20,7 @@ const HEAD = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: const dirs: string[] = []; afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); }); const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'install-verdict-')); dirs.push(d); return d; }; -const writeVerdict = (stateRoot: string, over: Record<string, unknown> = {}) => { - const file = installVerdictPath(REAL, stateRoot); +const writeVerdict = (stateRoot: string, over: Record<string, unknown> = {}, file = installVerdictPath(REAL, stateRoot, (over.project as string) || null)) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ schema_version: 1, checkout: REAL, head: HEAD, for: 'autoplan', @@ -32,9 +31,14 @@ const writeVerdict = (stateRoot: string, over: Record<string, unknown> = {}) => }; describe('install verdict (TypeScript)', () => { - test('the verdict path is keyed by sha256 of the checkout realpath under <state root>/install-verdicts', () => { + test('the verdict path is keyed by sha256 of the checkout and of the project (or "none") under <state root>/install-verdicts; the schema 1 path stays readable', () => { const sr = tmp(); - expect(installVerdictPath(REAL, sr)).toBe(path.join(sr, 'install-verdicts', `${createHash('sha256').update(REAL).digest('hex')}.json`)); + const sha = (t: string) => createHash('sha256').update(t).digest('hex'); + expect(installVerdictPath(REAL, sr)).toBe(path.join(sr, 'install-verdicts', sha(REAL), `${sha('none')}.json`)); + expect(installVerdictPath(REAL, sr, '/p/a')).toBe(path.join(sr, 'install-verdicts', sha(REAL), `${sha('/p/a')}.json`)); + expect(legacyInstallVerdictPath(REAL, sr)).toBe(path.join(sr, 'install-verdicts', `${sha(REAL)}.json`)); + const bashPath = spawnSync('bash', ['-c', `. "${path.join(ROOT, 'bin/gstack-install-verdict.sh')}" && gstack_install_verdict_path "$1" "$2" "$3" && gstack_install_verdict_path "$1" "$2" "" && gstack_install_verdict_legacy_path "$1" "$2"`, '_', sr, REAL, '/p/a'], { encoding: 'utf8', timeout: 30_000 }); + expect(bashPath.stdout.trim().split('\n')).toEqual([installVerdictPath(REAL, sr, '/p/a'), installVerdictPath(REAL, sr), legacyInstallVerdictPath(REAL, sr)]); expect(readInstallVerdict(path.join(sr, 'nope.json'))).toBeNull(); expect(readInstallVerdict(writeVerdict(sr))?.head).toBe(HEAD); }); @@ -64,6 +68,59 @@ describe('install verdict (TypeScript)', () => { }); }); +// Plan A-c/A5: one record per (checkout, project) holding the Bun resolution; +// both twins pick the same file, bind workflows that run the repo's tests to +// the project, and refuse a running Bun the record did not certify. +describe('install verdict keyed by (checkout, project): both twins', () => { + const BUN = fs.realpathSync(process.execPath); + const project = (name: string) => { const d = path.join(fs.realpathSync(tmp()), name); fs.mkdirSync(d); execFileSync('git', ['init', '-q', d], { timeout: 30_000 }); return d; }; + const bashAdmit = (skill: string, cwd: string, env: Record<string, string>) => { + const r = spawnSync('bash', ['-c', `. "$R/bin/gstack-bun-version.sh" && . "$R/bin/gstack-doctor-components.sh" && . "$R/bin/gstack-install-verdict.sh" && gstack_install_admission "$R/bin" "$1" unattended; printf '%s|%s|%s\n' "$_ia_refuse" "$_ia_status" "$_ia_line"`, '_', skill], + { cwd, encoding: 'utf8', timeout: 30_000, env: { PATH: `${path.dirname(BUN)}:/usr/bin:/bin`, HOME: os.homedir(), R: ROOT, GSTACK_CAPY: '1', GSTACK_SESSION_KIND: 'unattended', ...env } }); + const [refuse, status, line] = r.stdout.trim().split('|'); + return { refuse: refuse === '1', status, line: line ?? '' }; + }; + const tsAdmit = (skill: string, cwd: string, env: Record<string, string>, bunPath = BUN) => + checkInstallAdmission({ skill, gstackRoot: ROOT, cwd, bunVersion: '1.4.2', bunPath, env: { GSTACK_CAPY: '1', GSTACK_SESSION_KIND: 'unattended', ...env } }); + const both = (skill: string, cwd: string, env: Record<string, string>) => { + const b = bashAdmit(skill, cwd, { _GH: env.GSTACK_STATE_ROOT, ...env }); + const t = tsAdmit(skill, cwd, env); + expect({ refuse: b.refuse, status: b.status }).toEqual({ refuse: t.refuse, status: t.status }); + return t; + }; + + test('A-B-A across two projects, a no-project directory, explicit GSTACK_INSTALL_VERDICT and the old checkout-only schema', () => { + const sr = tmp(); + const env = { GSTACK_STATE_ROOT: sr }; + const a = project('a'), b = project('b'), none = fs.realpathSync(tmp()); + const fileA = writeVerdict(sr, { project: a, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'review' }); + writeVerdict(sr, { project: b, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'review' }); + for (const dir of [a, b, a]) expect(both('review', dir, env)).toMatchObject({ refuse: false, status: 'ok' }); + expect(both('review', none, env)).toMatchObject({ refuse: true, status: 'verdict' }); + writeVerdict(sr, { project: '', bun_path: BUN, bun_version: '1.4.2', schema_version: 2 }); + expect(both('autoplan', none, env)).toMatchObject({ refuse: false }); + fs.rmSync(installVerdictPath(REAL, sr, b)); + const viaExplicit = both('review', b, { ...env, GSTACK_INSTALL_VERDICT: fileA }); + expect(viaExplicit).toMatchObject({ refuse: true, status: 'verdict' }); + expect(viaExplicit.detail).toBe(`the install verdict checked ${a}, not ${b}, and review runs the repo's tests`); + expect(viaExplicit.line).toContain(`from ${b}`); + expect(both('autoplan', b, { ...env, GSTACK_INSTALL_VERDICT: fileA })).toMatchObject({ refuse: false }); + const legacySr = tmp(); + writeVerdict(legacySr, { for: 'review' }, legacyInstallVerdictPath(REAL, legacySr)); + expect(both('review', b, { GSTACK_STATE_ROOT: legacySr })).toMatchObject({ refuse: false, status: 'ok' }); + }); + + test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => { + const sr = tmp(); + const a = project('a'); + writeVerdict(sr, { project: a, bun_path: '/opt/other/bun', bun_version: '1.4.3', schema_version: 2, for: 'review' }); + const t = both('review', a, { GSTACK_STATE_ROOT: sr }); + expect(t).toMatchObject({ refuse: true, status: 'runtime', code: 'BUN_RESOLUTION_DRIFT' }); + expect(t.line).toBe(`INSTALL: fail runtime — bun 1.4.2 at ${BUN} is running, but the install verdict recorded bun 1.4.3 at /opt/other/bun; fix: launch with the GSTACK_LAUNCH line (PATH=/opt/other:$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for review) to see what changed (BUN_RESOLUTION_DRIFT)`); + expect(bashAdmit('review', a, { _GH: sr }).line).toContain('(BUN_RESOLUTION_DRIFT)'); + }); +}); + describe('source root and support file (one helper, direct and symlinked layouts)', () => { const bash = (bin: string, fn: string) => spawnSync('bash', ['-c', `. "${path.join(ROOT, 'bin/gstack-install-verdict.sh')}" && ${fn}`, '_', bin], { encoding: 'utf8', timeout: 30_000 }); test('a checkout bin resolves to the checkout; an installed root follows .source-path; both read capy/support.env', () => { diff --git a/test/plan-reality.test.ts b/test/plan-reality.test.ts index 9bccc7a6b..ecdf21d55 100644 --- a/test/plan-reality.test.ts +++ b/test/plan-reality.test.ts @@ -13,7 +13,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality'; +import { PLAN_REALITY_CHECKER, REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows, vaguePlanTerms } from '../lib/plan-reality'; import { extractHeading } from '../lib/autoplan-prompts'; import { RESULT_CODES } from '../lib/result-codes'; import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality'; @@ -311,3 +311,57 @@ describe('field wave item 12: a receipt per measured number, the rule text for a expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6); }); }); + +describe('next wave B6/B10: PLAN_REALITY flags without false positives; plan quotes need no receipt', () => { + const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n'); + + test('B6: item and section references are not quantities; a phrase in backticks is named, not claimed (plan scope)', () => { + const plan = "B1. `rec`: one word takes every recommendation (item 5).\nB6. When the plan names `minimum effect`, `margin`, `decision rule` or a dollar cost, the gate flags it (items 10, 14–19; section 3).\n"; + const r = checkPlanReality(plan); + expect(r.unreceipted_numbers).toEqual([]); + expect(r.unquoted).toEqual([]); + expect(checkPlanReality('Startup takes roughly 9 s on a cold machine.\n').unreceipted_numbers).toEqual(['Startup takes roughly 9 s on a cold machine.']); + expect(checkPlanReality('The decision rule was written first.\n').unquoted).toEqual(['The decision rule was written first.']); + expect(PLAN_REALITY_CHECKER).toBe('plan-reality/3'); + }); + + test('B-f negative control: a measured number inside backticks in review scope stays flagged; a command in backticks still receipts', () => { + const quiet = 'REALITY: numbers pass the cold install took `86 s` on Capy'; + expect(checkRealityRows(`${others('numbers')}\n${quiet}`, { phase: 'eng' }).unreceipted_numbers).toEqual(['the cold install took `86 s` on Capy']); + const cmd = 'REALITY: numbers pass the cold install took 86 s `bun run bench --json` on Capy'; + expect(checkRealityRows(`${others('numbers')}\n${cmd}`, { phase: 'eng' }).unreceipted_numbers).toEqual([]); + }); + + test('B10: in reviewer scope a number quoted verbatim from the reviewed plan needs no receipt; a reworded or unknown quote does', () => { + const plan = 'Add exponential backoff: base 500 ms, factor 2, cap 30 s, jitter 10%.\n'; + const quoted = 'REALITY: numbers pass the plan says "base 500 ms, factor 2, cap 30 s" and gives no reason for the cap (plan.md:1)'; + expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual([]); + expect(checkRealityRows(`${others('numbers')}\n${quoted}`, { phase: 'eng' }).unreceipted_numbers.length).toBeGreaterThan(0); + const reworded = 'REALITY: numbers pass the plan says "a 45 s cap" and gives no reason'; + expect(checkRealityRows(`${others('numbers')}\n${reworded}`, { phase: 'eng', planText: plan }).unreceipted_numbers.length).toBe(1); + const own = 'REALITY: numbers pass the plan says "cap 30 s"; retries took 900 ms in my run'; + expect(checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng', planText: plan }).unreceipted_numbers).toEqual(['retries took 900 ms in my run']); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-reality-quote-')); + fs.writeFileSync(path.join(dir, 'plan.md'), plan); + const bin = run(['check', '--phase', 'eng', '--plan', path.join(dir, 'plan.md'), '-'], `${others('numbers')}\n${quoted}`); + expect(bin.code, bin.err).toBe(EXIT.ok); + expect(run(['check', '--phase', 'eng', '-'], `${others('numbers')}\n${quoted}`).code).toBe(EXIT.fail); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('B6: vaguePlanTerms flags minimum effect, margin, decision rule and dollar cost only without a number, arm list or rule body', () => { + const plan = [ + 'Ship when the minimum effect is reached.', + 'Keep a safety margin on the spend.', + 'A margin of 15% over the pilot price.', + 'The decision rule:', + '- arm A: keep the default', + '- arm B: switch', + 'We price the dollar cost later (item 9).', + 'The decision rule is "stop when recall drops below the base".', + 'Name `minimum effect` in the template.', + '```', 'margin inside a fence', '```', + ].join('\n'); + expect(vaguePlanTerms(plan).map(v => [v.phrase, v.line])).toEqual([['minimum effect', 1], ['margin', 2], ['dollar cost', 7]]); + }); +}); diff --git a/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 9b70a6c03..7719b9ec9 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -127,6 +127,65 @@ describe('guards (CD1)', () => { }); }); +// Integration item 8: under the admission condition (GSTACK_CAPY=1, or a +// verdict exists for this checkout and project) the guards and lanes validate +// the install verdict as workflow admission does and return incomplete before +// launching anything; with neither, contributors and CI run as before. +describe('guards and lanes: install verdict admission (integration item 8)', () => { + const GSTACK = path.resolve(import.meta.dir, '..'); + const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict'); + const head = () => require('node:child_process').spawnSync('git', ['-C', GSTACK, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const guard = { id: 'marker', when: ['lib/**'], command: 'touch ../guard-ran' }; + const verdict = (repo: FixtureRepo, fields: Record<string, unknown>) => { + const project = fs.realpathSync(repo.work); + const file = installVerdictPath(fs.realpathSync(GSTACK), repo.home, project); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ schema_version: 2, checkout: fs.realpathSync(GSTACK), project, head: head(), for: 'ship', required: ['claude', 'codex', 'patch'], pins_planning_only: false, ok: true, trailer: 'gstack: ok', min_revision: '', launch_path: '', time: '', ...fields })); + }; + const run = (repo: FixtureRepo, env: Record<string, string> = {}) => pregate(repo, ['--allow-repo-commands', '--stage', 'preflight', '--only', 'guards'], { GSTACK_STATE_ROOT: repo.home, ...env }); + const ran = (repo: FixtureRepo) => fs.existsSync(path.join(repo.root, 'guard-ran')); + + test('GSTACK_CAPY=1 without a verdict: guards incomplete INSTALL_UNVERIFIED and nothing launched; without GSTACK_CAPY the guard runs', () => { + const repo = guardedFixture({ ...BASE_PREGATE, guards: [guard] }); + commit(repo, { 'lib/util.js': 'exports.x = 1;\n' }); + const r = run(repo, { GSTACK_CAPY: '1' }); + expect(r.status).toBe(EXIT.fail); + expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'INSTALL_UNVERIFIED' }); + expect(checkRow(repo, 'guards').detail).toContain('install verdict: no install verdict at'); + expect(ran(repo)).toBe(false); + expect(run(repo, { GSTACK_CAPY: '' }).status).toBe(EXIT.ok); + expect(ran(repo)).toBe(true); + }); + + test('an existing verdict is enforced without GSTACK_CAPY: ok:false and a stale HEAD are INSTALL_UNVERIFIED, a gone recorded Bun is BUN_RESOLUTION_DRIFT, a valid one launches its Bun', () => { + const repo = guardedFixture({ ...BASE_PREGATE, guards: [guard] }); + commit(repo, { 'lib/util.js': 'exports.x = 1;\n' }); + verdict(repo, { ok: false, trailer: 'gstack: fail pins' }); + expect(run(repo).status).toBe(EXIT.fail); + expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'INSTALL_UNVERIFIED' }); + expect(checkRow(repo, 'guards').detail).toContain('the install verdict says: gstack: fail pins'); + verdict(repo, { head: '0'.repeat(40) }); + expect(run(repo).status).toBe(EXIT.fail); + expect(checkRow(repo, 'guards').detail).toContain('the install verdict is for another HEAD'); + verdict(repo, { pins_planning_only: true, for: 'autoplan' }); + expect(run(repo).status).toBe(EXIT.fail); + expect(checkRow(repo, 'guards').detail).toContain('planning-only'); + verdict(repo, { bun_path: path.join(repo.root, 'gone/bun'), bun_version: '1.4.3' }); + expect(run(repo).status).toBe(EXIT.fail); + expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'BUN_RESOLUTION_DRIFT' }); + expect(checkRow(repo, 'guards').detail).toContain(`recorded bun 1.4.3 at ${repo.root}/gone/bun is gone`); + expect(ran(repo)).toBe(false); + const stub = path.join(fs.realpathSync(repo.root), 'pinned', 'bun'); + fs.mkdirSync(path.dirname(stub), { recursive: true }); + fs.writeFileSync(stub, `#!/bin/sh\n[ "$1" = --version ] && { echo 1.4.2; exit 0; }\nexec '${fs.realpathSync(process.execPath)}' "$@"\n`, { mode: 0o755 }); + verdict(repo, { bun_path: stub, bun_version: '1.4.2' }); + const ok = run(repo); + expect(ok.status, ok.out).toBe(EXIT.ok); + expect(ok.out).toContain(`runtime: bun 1.4.2 at ${stub} (recorded by the install verdict)`); + expect(ran(repo)).toBe(true); + }); +}); + describe('pregate config: sealed (CD4)', () => { test('`gstack-pregate validate` accepts a sealed policy and names each bad field', () => { expect(codes({ sealed: { paths: ['heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], answer_markers: ['SEALED ANSWER:'], seed_patterns: ['seed=\\d+'], local_paths: true, visibility: 'private' } })).toEqual([]); @@ -162,7 +221,7 @@ describe('lanes runtime and serial files (CD1)', () => { expect(r.status).toBe(EXIT.ok); expect(r.out).toContain('lanes: bun=1.4.2'); expect(r.out).not.toContain('bun=1.3.14'); - expect(r.out).toContain(`lanes: runtime: bun 1.4.2 from ${host.bunInstallBin}`); + expect(r.out).toContain(`lanes: runtime: bun 1.4.2 at ${fs.realpathSync(host.bunInstallBin)}/bun (resolved: `); }); test('serial-pattern test files run in their own process, apart from the unit files', () => { diff --git a/test/pregate-remote.test.ts b/test/pregate-remote.test.ts new file mode 100644 index 000000000..5c2dc8022 --- /dev/null +++ b/test/pregate-remote.test.ts @@ -0,0 +1,179 @@ +/** + * Lane C4 (GBRA-95): on a machine under 8 cores, a remote test gate the base + * branch declares (`remote_gate`) or a remote `test_backend` records the + * local test tier as a requires-remote obligation with the exact command, + * instead of running thousands of files locally. `--remote` runs the + * declared gate now under a spend cap; the obligation clears only on the + * gate's `PREGATE_REMOTE: tree=<tested tree> tests=<n> failed=0` line. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { buildContext } from '../lib/pregate/context'; +import { runPregate } from '../lib/pregate/index'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const GATE = 'node scripts/remote-gate.js'; +/** A fake remote gate: reports the tree it was handed (or GATE_TREE), GATE_TESTS / GATE_FAILED, and records its env. */ +const GATE_SCRIPT = [ + "const fs = require('node:fs');", + "fs.writeFileSync('../gate-env.json', JSON.stringify({ tree: process.env.PREGATE_TREE, cap: process.env.PREGATE_SPEND_CAP_USD, files: fs.readFileSync(process.env.PREGATE_FILES_FILE, 'utf8') }));", + "if (process.env.GATE_SILENT) process.exit(0);", + "const spent = process.env.GATE_SPENT ? ` spent=${process.env.GATE_SPENT}` : '';", + "console.log(`PREGATE_REMOTE: tree=${process.env.GATE_TREE || process.env.PREGATE_TREE} tests=${process.env.GATE_TESTS ?? 3} failed=${process.env.GATE_FAILED ?? 0}${spent}`);", +].join('\n') + '\n'; + +function gated(pregate: Record<string, unknown> = { ...BASE_PREGATE, remote_gate: GATE }): FixtureRepo { + const repo = makeFixtureRepo({ pregate }); + repos.push(repo); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'scripts/remote-gate.js': GATE_SCRIPT }, 'remote gate'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--ff-only', 'main'); + commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + return repo; +} + +async function lanesOn(repo: FixtureRepo, o: { cores: number; testBackend: string }) { + const lines: string[] = []; + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => lines.push(l), ...o }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const file = await runPregate(loaded.ctx, { stage: 'tests' }); + return { file, lanes: file.checks.find(c => c.id === 'lanes')!, lines }; +} + +describe('small machines record the test tier for the remote gate (C4)', () => { + test('under 8 cores a declared remote_gate becomes a requires-remote obligation naming the command; nothing runs locally', async () => { + const repo = gated(); + const { file, lanes, lines } = await lanesOn(repo, { cores: 4, testBackend: 'local' }); + expect(lanes.status).toBe('requires-remote'); + expect(lanes.detail).toContain('recorded as requires-remote pregate.json/remote_gate (4 cores, under 8)'); + expect(file.requires_remote).toEqual([expect.objectContaining({ lane: 'pregate.json/remote_gate', platform: 'remote', command: GATE, tests: ['test/util.test.js'], receipt: null })]); + expect(file.verdict).toBe('requires-remote (1 uncleared)'); + expect(fs.existsSync(path.join(repo.root, 'gate-env.json'))).toBe(false); + expect(lines.some(l => l.startsWith('PREGATE_TEST:'))).toBe(false); + expect(lines.at(-1)).toMatch(/^PREGATE: tests requires-remote \(1 uncleared\) in /); + }); + + test('a remote test_backend without remote_gate names gstack\'s Ubicloud runner with the selection; 8 cores or more, or local with no gate, runs locally', async () => { + const backend = gated(BASE_PREGATE); + const viaBackend = await lanesOn(backend, { cores: 4, testBackend: 'ubicloud' }); + expect(viaBackend.lanes.status).toBe('requires-remote'); + const obligation = viaBackend.file.requires_remote![0]!; + expect(obligation.lane).toBe('test_backend/ubicloud'); + expect(obligation.command).toMatch(/scripts\/ubicloud\/ubi-runner\.sh run --src '.*\/work' -- 'node --test '\\''test\/util\.test\.js'\\'''$/); + expect((await lanesOn(backend, { cores: 4, testBackend: 'local' })).lanes.status).toBe('pass'); + expect((await lanesOn(gated(), { cores: 16, testBackend: 'local' })).lanes.status).toBe('pass'); + }); + + test('`clear` on the remote_gate obligation needs the gate\'s PREGATE_REMOTE receipt for the tested tree; a run URL or a bad receipt leaves it uncleared', async () => { + const repo = gated(); + const fileAt = path.join(repo.work, '.gstack/tmp/pregate.json'); + const { file } = await lanesOn(repo, { cores: 4, testBackend: 'local' }); + fs.mkdirSync(path.dirname(fileAt), { recursive: true }); + fs.writeFileSync(fileAt, JSON.stringify(file)); + const clear = (args: string[]) => runBin(repo, 'gstack-pregate', ['clear', '--remote', 'pregate.json/remote_gate', ...args]); + const receiptOf = (line: string) => { const f = path.join(repo.root, `receipt-${Math.random().toString(36).slice(2)}.log`); fs.writeFileSync(f, `gate log\n${line}\n`); return f; }; + const url = clear(['--run-url', 'https://ci.example/run/1']); + expect(url.status).toBe(EXIT.usage); + expect(url.err).toContain('clears only with --receipt <file>'); + for (const [line, why] of [ + [`PREGATE_REMOTE: tree=${'deadbeef'.repeat(8)} tests=3 failed=0`, 'the remote gate tested tree deadbeefdead'], + [`PREGATE_REMOTE: tree=${file.tree} tests=0 failed=0`, 'the remote gate reported 0 tests'], + [`PREGATE_REMOTE: tree=${file.tree} tests=3 failed=1`, 'the remote gate reported 1 failed of 3'], + ['all green, trust me', 'has no PREGATE_REMOTE: line'], + ] as const) { + const r = clear(['--receipt', receiptOf(line)]); + expect(r.status).toBe(EXIT.fail); + expect(r.err).toContain(why); + expect(r.err).toContain('(PREGATE_RECEIPT_INVALID; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pregate-receipt-invalid)'); + expect(JSON.parse(fs.readFileSync(fileAt, 'utf8')).requires_remote[0].receipt).toBeNull(); + } + const ok = clear(['--receipt', receiptOf(`PREGATE_REMOTE: tree=${file.tree} tests=3 failed=0`)]); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(new RegExp(`^PREGATE_CLEARED: pregate\\.json/remote_gate receipt=remote-gate tree=${file.tree.slice(0, 12)} tests=3 receipt=\\S+ sha256=[0-9a-f]{12} verdict=requires-remote cleared$`, 'm')); + expect(ok.out).not.toContain('attested'); + }); + + test('`clear` on a test_backend obligation records the run URL as attested, not validated, and says so', async () => { + const repo = gated(BASE_PREGATE); + const fileAt = path.join(repo.work, '.gstack/tmp/pregate.json'); + const { file, lanes } = await lanesOn(repo, { cores: 4, testBackend: 'ubicloud' }); + expect(lanes.detail).toContain('gstack-pregate clear --remote test_backend/ubicloud --run-url <url>'); + fs.mkdirSync(path.dirname(fileAt), { recursive: true }); + fs.writeFileSync(fileAt, JSON.stringify(file)); + expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'test_backend/ubicloud', '--receipt', fileAt]).status).toBe(EXIT.usage); + const r = runBin(repo, 'gstack-pregate', ['clear', '--remote', 'test_backend/ubicloud', '--run-url', 'https://ci.example/run/7']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('PREGATE_CLEARED: test_backend/ubicloud receipt=attested:https://ci.example/run/7 verdict=requires-remote cleared (attested, not validated: test_backend/ubicloud has no machine-checkable receipt)'); + expect(JSON.parse(fs.readFileSync(fileAt, 'utf8')).requires_remote[0].receipt).toBe('attested:https://ci.example/run/7'); + }); + + test('config: stall_timeout_s and remote_gate are typed', async () => { + const { validateConfig } = await import('../lib/pregate/config'); + const codes = (c: unknown) => validateConfig(c).map(e => `${e.code}:${e.path}`); + expect(codes({ stall_timeout_s: 0, remote_gate: GATE })).toEqual([]); + expect(codes({ stall_timeout_s: -1 })).toEqual(['PREGATE_CONFIG_INVALID:stall_timeout_s']); + expect(codes({ stall_timeout_s: 1.5 })).toEqual(['PREGATE_CONFIG_INVALID:stall_timeout_s']); + expect(codes({ remote_gate: 'a\nb' })).toEqual(['PREGATE_CONFIG_INVALID:remote_gate']); + }); +}); + +describe('gstack-pregate --remote (C-c)', () => { + const remote = (repo: FixtureRepo, args: string[], env: Record<string, string> = {}) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'tests', ...args], env); + const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes'); + const pregateFile = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')); + + test('needs --allow-repo-commands, the base policy and a spend cap', () => { + const repo = gated(); + expect(remote(repo, ['--remote', '--spend-cap', '5']).status).toBe(EXIT.refused); + expect(remote(repo, ['--remote', '--spend-cap', '5', '--allow-repo-commands', '--policy-from', 'head']).status).toBe(EXIT.refused); + const noCap = remote(repo, ['--remote', '--allow-repo-commands']); + expect(noCap.status).toBe(EXIT.usage); + expect(noCap.err).toContain('pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)'); + expect(remote(repo, ['--spend-cap', '5', '--allow-repo-commands']).status).toBe(EXIT.usage); + expect(fs.existsSync(path.join(repo.root, 'gate-env.json'))).toBe(false); + }); + + test('a gate reporting the tested tree, tests > 0 and failed = 0 clears the obligation and reports its spend', () => { + const repo = gated(); + const r = remote(repo, ['--remote', '--allow-repo-commands'], { GSTACK_SPEND_CAP_USD: '4', GATE_SPENT: '0.37' }); + expect(r.status).toBe(EXIT.ok); + const file = pregateFile(repo); + expect(file.verdict).toBe('requires-remote cleared'); + expect(file.requires_remote[0]).toMatchObject({ lane: 'pregate.json/remote_gate', receipt: `remote-gate tree=${file.tree.slice(0, 12)} tests=3` }); + expect(lanes(repo).detail).toContain('spend: spent $0.37, reserved $0, unknown $0 (cap $4)'); + expect(r.err).toContain('PREGATE_REMOTE_SPEND: spent $0.37, reserved $0, unknown $0 (cap $4)'); + const env = JSON.parse(fs.readFileSync(path.join(repo.root, 'gate-env.json'), 'utf8')); + expect(env).toEqual({ tree: file.tree, cap: '4', files: 'test/util.test.js\n' }); + expect(runBin(repo, 'gstack-pregate', ['verify']).status).toBe(EXIT.ok); + }); + + test('a wrong tree, zero tests, a failure, a missing line or a non-zero exit leaves the obligation uncleared and the check incomplete; unreported spend counts at the cap', () => { + const repo = gated(); + const cases: Array<[Record<string, string>, string]> = [ + [{ GATE_TREE: 'deadbeef'.repeat(8) }, 'the remote gate tested tree deadbeefdead'], + [{ GATE_TESTS: '0' }, 'the remote gate reported 0 tests'], + [{ GATE_FAILED: '1' }, 'the remote gate reported 1 failed of 3'], + [{ GATE_SILENT: '1' }, 'the remote gate exited 0 without a PREGATE_REMOTE: line'], + ]; + for (const [env, why] of cases) { + const r = remote(repo, ['--remote', '--allow-repo-commands', '--spend-cap', '2'], env); + expect(r.status).toBe(EXIT.fail); + const row = lanes(repo); + expect(row.status).toBe('incomplete'); + expect(row.detail).toContain(why); + expect(row.detail).toContain('requires-remote pregate.json/remote_gate stays uncleared'); + expect(pregateFile(repo).requires_remote[0].receipt).toBeNull(); + } + expect(lanes(repo).lines).toContain('spend: spent $0, reserved $0, unknown $2 (no spent= reported; counted at the cap) (cap $2)'); + const backendOnly = gated(BASE_PREGATE); + expect(remote(backendOnly, ['--remote', '--allow-repo-commands', '--spend-cap', '2']).status).toBe(EXIT.fail); + expect(lanes(backendOnly).detail).toContain('--remote runs only a declared remote gate'); + }); +}); diff --git a/test/pregate-supervise.test.ts b/test/pregate-supervise.test.ts new file mode 100644 index 000000000..abdf3909b --- /dev/null +++ b/test/pregate-supervise.test.ts @@ -0,0 +1,227 @@ +/** + * Lane C (GBRA-95: `gstack-pregate run` sat silent for 90 minutes in its test + * stage on gbrain). The test tier now runs through lib/pregate/supervise.ts: + * its own process group, output to a log file instead of pipes, live + * progress on stderr, a stall timeout on log growth and a per-check deadline. + * Fixtures: a test that blocks forever, a child that backgrounds and holds + * stdout, a command that ignores SIGTERM, a quiet file and a printing one. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { buildContext, withFingerprint } from '../lib/pregate/context'; +import { adapterFor, supervise, type SuperviseOptions } from '../lib/pregate/supervise'; +import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const dirs: string[] = []; +const repos: FixtureRepo[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); while (repos.length) cleanup(repos.pop()!); }); + +function scratch(): string { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-supervise-')); dirs.push(d); return d; } +function alive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } +async function goneSoon(pid: number, ms = 3000): Promise<boolean> { + const until = Date.now() + ms; + while (Date.now() < until) { if (!alive(pid)) return true; await Bun.sleep(50); } + return !alive(pid); +} +async function pidFrom(file: string, ms = 5000): Promise<number> { + const until = Date.now() + ms; + while (Date.now() < until) { const t = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : ''; if (t) return Number(t); await Bun.sleep(25); } + throw new Error(`no pid in ${file}`); +} + +function run(dir: string, command: string, o: Partial<SuperviseOptions> = {}) { + const lines: string[] = []; + const log = path.join(dir, 'run.log'); + fs.writeFileSync(log, ''); + const started = Date.now(); + return supervise({ command, cwd: dir, env: process.env, log, deadline: Date.now() + 30_000, stallMs: 0, graceMs: 300, pollMs: 50, label: 'fixture', progress: l => lines.push(l), ...o }) + .then(r => ({ ...r, lines, wall: Date.now() - started, log: fs.readFileSync(log, 'utf8') })); +} + +describe('supervise: process group, log file, stall and deadline', () => { + test('a backgrounded child holding stdout does not keep the run waiting, and is killed with the group after the exit', async () => { + const dir = scratch(); + const r = await run(dir, `sleep 1000 & echo $! > ${dir}/bg.pid; echo done`); + expect(r.outcome).toBe('exited'); + expect(r.exit).toBe(0); + expect(r.wall).toBeLessThan(5000); + expect(r.log).toContain('done'); + expect(await goneSoon(await pidFrom(`${dir}/bg.pid`))).toBe(true); + }); + + test('a command that prints nothing for the stall timeout is killed as stalled; a long command that keeps printing is not', async () => { + const dir = scratch(); + const quiet = await run(dir, `echo $$ > ${dir}/quiet.pid; echo starting; sleep 1000`, { stallMs: 700 }); + expect(quiet.outcome).toBe('stalled'); + expect(quiet.exit).toBeNull(); + expect(quiet.wall).toBeLessThan(5000); + expect(await goneSoon(await pidFrom(`${dir}/quiet.pid`))).toBe(true); + const printing = await run(dir, 'for i in 1 2 3 4 5 6 7 8; do echo tick $i; sleep 0.25; done', { stallMs: 700 }); + expect(printing.outcome).toBe('exited'); + expect(printing.exit).toBe(0); + expect(printing.wall).toBeGreaterThan(1500); + }); + + test('a group that ignores SIGTERM is SIGKILLed after the grace period', async () => { + const dir = scratch(); + const r = await run(dir, `trap '' TERM; echo $$ > ${dir}/stubborn.pid; while :; do sleep 0.1; done`, { stallMs: 500, graceMs: 400 }); + expect(r.outcome).toBe('stalled'); + expect(r.wall).toBeLessThan(5000); + expect(await goneSoon(await pidFrom(`${dir}/stubborn.pid`))).toBe(true); + }); + + test('the deadline kills the group as timeout; a launch that cannot start is spawn-failed, never a pass', async () => { + const dir = scratch(); + const slow = await run(dir, 'while :; do echo busy; sleep 0.1; done', { deadline: Date.now() + 600, stallMs: 0 }); + expect(slow.outcome).toBe('timeout'); + expect(slow.exit).toBeNull(); + const missing = await run(dir, 'true', { cwd: path.join(dir, 'does-not-exist') }); + expect(missing.outcome).toBe('spawn-failed'); + expect(missing.exit).toBeNull(); + }); + + test('heartbeats arrive after each quiet interval without progress lines', async () => { + const dir = scratch(); + const r = await run(dir, 'sleep 1.2; echo late', { heartbeatMs: 300 }); + expect(r.outcome).toBe('exited'); + expect(r.lines.filter(l => l.startsWith('PREGATE_HEARTBEAT: fixture running ')).length).toBeGreaterThanOrEqual(2); + expect(r.lines.some(l => l.startsWith('PREGATE_TEST:'))).toBe(false); + }); +}); + +describe('supervise: per-file progress adapters', () => { + const bunTest = (body: string) => `import { test, expect } from 'bun:test';\n${body}\n`; + + test('bun test has an adapter: start and pass|fail per file from its headers; a runner without one reports nothing per file', async () => { + expect(adapterFor('bun test {files}')).toBe('bun'); + expect(adapterFor('bun test --timeout=60000 {files}')).toBe('bun'); + expect(adapterFor('node --test {files}')).toBeNull(); + expect(adapterFor('npx vitest run {files}')).toBeNull(); + const dir = scratch(); + fs.writeFileSync(path.join(dir, 'a.test.ts'), bunTest("test('a', () => expect(1).toBe(1));")); + fs.writeFileSync(path.join(dir, 'b.test.ts'), bunTest("test('b', () => expect(1).toBe(2));")); + const files = ['a.test.ts', 'b.test.ts']; + for (const ci of ['', 'true']) { + const r = await run(dir, `GITHUB_ACTIONS=${ci} ${process.execPath} test ${files.join(' ')}`, { files, adapter: 'bun' }); + expect(r.exit).toBe(1); + const events = r.lines.filter(l => l.startsWith('PREGATE_TEST:')).map(l => l.replace(/ \d+\.\d$/, '')); + expect(events).toEqual(['PREGATE_TEST: 1/2 a.test.ts start', 'PREGATE_TEST: 1/2 a.test.ts pass', 'PREGATE_TEST: 2/2 b.test.ts start', 'PREGATE_TEST: 2/2 b.test.ts fail']); + } + const unsupported = await run(dir, 'echo "test a.test.ts ok"; echo "test b.test.ts ok"', { files, adapter: null }); + expect(unsupported.lines.some(l => l.startsWith('PREGATE_TEST:'))).toBe(false); + }); + + test('a one-file run reports its file from the launch for any runner; a bun test that blocks forever stalls naming that file', async () => { + const dir = scratch(); + const one = await run(dir, 'echo ok', { files: ['test/x.test.js'], adapter: null, counter: { done: 4, total: 9 } }); + expect(one.lines.map(l => l.replace(/ \d+\.\d$/, ''))).toEqual(['PREGATE_TEST: 5/9 test/x.test.js start', 'PREGATE_TEST: 5/9 test/x.test.js pass']); + fs.writeFileSync(path.join(dir, 'ok.test.ts'), bunTest("test('ok', () => expect(1).toBe(1));")); + fs.writeFileSync(path.join(dir, 'hang.test.ts'), bunTest("test('blocks', () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); });")); + const files = ['ok.test.ts', 'hang.test.ts']; + const hung = await run(dir, `${process.execPath} test ${files.join(' ')}`, { files, adapter: 'bun', stallMs: 1500 }); + expect(hung.outcome).toBe('stalled'); + expect(hung.lastFile).toBe('hang.test.ts'); + expect(hung.lines.some(l => /^PREGATE_TEST: [12]\/2 hang\.test\.ts start$/.test(l))).toBe(true); + expect(hung.lines.some(l => /^PREGATE_TEST: [12]\/2 hang\.test\.ts fail /.test(l))).toBe(true); + }); +}); + +describe('withFingerprint over async checks', () => { + test('a rejected check promise is incomplete, never pass', async () => { + const repo = makeFixtureRepo(); repos.push(repo); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, testBackend: 'local', cores: 16 }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const r = await withFingerprint(loaded.ctx, 'lanes', 'tests', async () => { throw new Error('runner vanished'); }); + expect(r).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: 'threw: runner vanished' }); + }); +}); + +describe('gstack-pregate: the test stage never hangs silently', () => { + const NODE_HANG = "const test = require('node:test');\ntest('blocks', () => { require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', '..', 'hang.pid'), String(process.pid)); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); });\n"; + const NODE_BACKGROUND = "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('backgrounds a child holding stdout', () => { const c = spawn('sleep', ['1000'], { stdio: 'inherit' }); require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', '..', 'bg.pid'), String(c.pid)); c.unref(); });\n"; + const pregate = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...args]); + const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes'); + + test('a test that blocks forever is killed at the stall timeout, named, and recorded incomplete; --json stdout stays one JSON document', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'test/hang.test.js': NODE_HANG }); + const started = Date.now(); + const r = pregate(repo, ['--stall-timeout', '2', '--json']); + expect(Date.now() - started).toBeLessThan(60_000); + expect(r.status).toBe(EXIT.fail); + const doc = JSON.parse(r.out); + expect(doc.ok).toBe(false); + const row = doc.checks.find((c: any) => c.id === 'lanes'); + expect(row).toMatchObject({ status: 'incomplete', code: 'PREGATE_STALLED' }); + expect(row.detail).toContain('last file started: test/hang.test.js'); + expect(r.err).toContain('PREGATE_TEST: 1/1 test/hang.test.js start'); + expect(r.err).toMatch(/^PREGATE: tests incomplete in \d+\.\ds — lanes: no test output for 2 s/m); + expect(alive(Number(fs.readFileSync(path.join(repo.root, 'hang.pid'), 'utf8')))).toBe(false); + }, 90_000); + + test('the base branch stall_timeout_s applies without the flag', () => { + const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, stall_timeout_s: 2 } }); repos.push(repo); + commit(repo, { 'test/hang.test.js': NODE_HANG }); + const r = pregate(repo, []); + expect(r.status).toBe(EXIT.fail); + expect(lanes(repo)).toMatchObject({ status: 'incomplete', code: 'PREGATE_STALLED' }); + expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*no test output for 2 s.*\(PREGATE_STALLED\)$/m); + expect(r.err).toMatch(/^gstack-pregate: lanes stalled — .*; fix: .* \(PREGATE_STALLED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pregate-stalled\)$/m); + }, 90_000); + + test('a test whose child backgrounds and holds stdout passes promptly instead of waiting out --timeout', () => { + const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, runner: 'node {files}' } }); repos.push(repo); + commit(repo, { 'test/bg.test.js': NODE_BACKGROUND }); + const started = Date.now(); + const r = pregate(repo, ['--timeout', '90']); + expect(Date.now() - started).toBeLessThan(45_000); + expect(r.status).toBe(EXIT.ok); + expect(lanes(repo).status).toBe('pass'); + expect(alive(Number(fs.readFileSync(path.join(repo.root, 'bg.pid'), 'utf8')))).toBe(false); + }, 90_000); + + test('--timeout bounds each runner process: serial files that each fit run to a pass even when their total exceeds it; one that overruns is incomplete', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 1500)); });\n"; + commit(repo, { 'test/a.serial.test.js': slow, 'test/b.serial.test.js': slow, 'test/c.serial.test.js': slow }); + const r = pregate(repo, ['--timeout', '3']); + expect(r.status).toBe(EXIT.ok); + expect(lanes(repo)).toMatchObject({ status: 'pass' }); + expect(lanes(repo).detail).toContain('3 tests ran in 3 file(s) (3 serial), exit 0'); + commit(repo, { 'test/c.serial.test.js': "const test = require('node:test');\ntest('slower', async () => { await new Promise(r => setTimeout(r, 5000)); });\n" }); + expect(pregate(repo, ['--timeout', '3']).status).toBe(EXIT.fail); + expect(lanes(repo)).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE' }); + expect(lanes(repo).detail).toContain('a runner process timed out after 3000 ms (--timeout applies to each runner process; 3 files; last file started: test/c.serial.test.js)'); + }, 90_000); + + test('a late tree mutation by a test\'s child is caught while the check runs, and a leftover child cannot mutate the tree after it', async () => { + const during = makeFixtureRepo(); repos.push(during); + commit(during, { 'test/mutate.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('mutates soon', async () => { spawn('sh', ['-c', 'sleep 0.3; echo changed >> README.md'], { stdio: 'ignore' }); await new Promise(r => setTimeout(r, 1500)); });\n" }); + expect(pregate(during, []).status).toBe(EXIT.fail); + expect(lanes(during).detail).toContain('working tree changed during the check'); + const after = makeFixtureRepo(); repos.push(after); + commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }).unref(); });\n" }); + expect(pregate(after, []).status).toBe(EXIT.ok); + await Bun.sleep(2500); + expect(fs.readFileSync(path.join(after.work, 'README.md'), 'utf8')).toBe('# fixture\n'); + }, 90_000); + + test('SIGTERM to pregate kills the running test process group, exits 143 and writes no pregate.json', async () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'test/hang.test.js': NODE_HANG }); + const child = spawn(BIN('gstack-pregate'), ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--stall-timeout', '0'], { cwd: repo.work, env: { ...process.env, ...repo.env }, stdio: ['ignore', 'pipe', 'pipe'] }); + let err = ''; + child.stderr!.on('data', d => { err += d; }); + const exited = new Promise<number | null>(resolve => child.on('exit', code => resolve(code))); + const testPid = await pidFrom(path.join(repo.root, 'hang.pid'), 30_000); + child.kill('SIGTERM'); + expect(await exited).toBe(143); + expect(err).toContain('gstack-pregate: cancelled by SIGTERM; killed 1 test process group(s); no pregate.json written'); + expect(await goneSoon(testPid)).toBe(true); + expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/pregate.json'))).toBe(false); + }, 60_000); +}); diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 66727b828..964e4c511 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -406,6 +406,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { describe("one-time consent prompt in setup (#1946, decision 6)", () => { const setup = fs.readFileSync(path.join(ROOT, "setup"), "utf8"); const block = setup.slice(setup.indexOf("# ─── Redact pre-push guard consent")); + const readyLine = setup.slice(setup.indexOf("_setup_ready_line() {"), setup.indexOf("\n}\n", setup.indexOf("_setup_ready_line() {")) + 3); test("prompt is gated on key ABSENCE and a real TTY, with a timed default-N read", () => { expect(block).toContain("grep -q '^redact_prepush_hook:'"); @@ -432,6 +433,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { "QUIET=0", 'log() { echo "$@"; }', `GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`, + readyLine, block, ].join("\n"); const r = spawnSync("bash", ["-c", script], { @@ -459,6 +461,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { "QUIET=0", 'log() { echo "$@"; }', `GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`, + readyLine, block, ].join("\n"); const r = spawnSync("bash", ["-c", script], { diff --git a/test/runtime-pins.test.ts b/test/runtime-pins.test.ts index 021f8fc10..6374f3421 100644 --- a/test/runtime-pins.test.ts +++ b/test/runtime-pins.test.ts @@ -136,6 +136,54 @@ describe('collectPins and evaluatePins', () => { }); }); +// Integration item 1: CI exact pins for this platform are alternatives (any +// one satisfies CI); non-CI pins stay required. +describe('CI exact pins are alternatives', () => { + const lane = (name: string, version: string, os = 'ubuntu-latest') => [` ${name}:`, ` runs-on: ${os}`, ' steps:', ' - uses: oven-sh/setup-bun@v2', ' with:', ` bun-version: ${version}`]; + const workflow = (...jobs: string[][]) => ['jobs:', ...jobs.flat(), ''].join('\n'); + + test('the real gstack checkout (CI 1.4.2 lanes plus the 1.4.3 install-default job) passes on 1.4.2 and on 1.4.3, and names the alternatives on 1.4.1', () => { + const pins = collectPins(ROOT); + const ci = [...new Set(pins.filter(p => p.tool === 'bun' && p.field === 'CI' && p.kind === 'exact' && p.lane !== 'windows' && p.lane !== 'macos').map(p => p.constraint))].sort(); + expect(ci).toEqual(['1.4.2', '1.4.3']); + for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]); + const low = evaluatePins(pins, { bun: '1.4.1' }, 'linux').failures.find(f => f.startsWith('bun '))!; + expect(low).toStartWith('bun 1.4.1 outside engines.bun >=1.4.2 (package.json:'); + expect(low).toContain('CI exact pins are alternatives, any one of 1.4.2, 1.4.3 satisfies CI'); + expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain('PIN_CI_ALTERNATIVES: bun 1.4.2 1.4.3'); + }); + + test('gbrain-like (every CI lane 1.4.3) still fails a local 1.4.2 exactly as before', () => { + const dir = project({ '.github/workflows/ci.yml': workflow(lane('a', '1.4.3'), lane('b', '1.4.3')) }); + const fail = evaluatePins(collectPins(dir), { bun: '1.4.2' }, 'linux'); + expect(fail.verdict).toBe('fail'); + expect(fail.failures).toEqual(['bun 1.4.2 outside CI 1.4.3 (.github/workflows/ci.yml:7)']); + expect(evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux').verdict).toBe('pass'); + }); + + test('GBRA-39, gbrain-evals-like: one workflow pins 1.4.2, another 1.4.3; a local 1.4.3 used to fail "outside CI 1.4.2" and now passes', () => { + const dir = project({ + '.github/workflows/test.yml': workflow(lane('unit', '1.4.3')), + '.github/workflows/evals.yml': workflow(lane('evals', '1.4.2')), + }); + for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(collectPins(dir), { bun }, 'linux').verdict).toBe('pass'); + expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').failures).toEqual([expect.stringContaining('any one of ')]); + }); + + test('non-CI exact pins stay required beside CI alternatives; another platform\'s CI pin is not an alternative here', () => { + const dir = project({ + '.bun-version': '1.4.2\n', + '.github/workflows/ci.yml': workflow(lane('linux', '1.4.3'), lane('mac', '1.4.4', 'macos-latest')), + }); + const r = evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux'); + expect(r.failures).toEqual(['bun 1.4.3 outside .bun-version 1.4.2 (.bun-version:1)']); + const lines = renderPinLines(r, 'linux'); + expect(lines).toContain('PIN_EXACT_REQUIRED: bun 1.4.2 .bun-version:1'); + expect(lines).toContain('PIN_CI_ALTERNATIVES: bun 1.4.3'); + expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').verdict).toBe('fail'); + }); +}); + describe('bin/gstack-runtime-pins.ts', () => { const run = (args: string[]) => spawnSync(process.execPath, [path.join(ROOT, 'bin/gstack-runtime-pins.ts'), ...args], { encoding: 'utf8', timeout: 30_000 }); diff --git a/test/script-invocation-tripwire.test.ts b/test/script-invocation-tripwire.test.ts index 79d9d9eeb..c0596d6c8 100644 --- a/test/script-invocation-tripwire.test.ts +++ b/test/script-invocation-tripwire.test.ts @@ -23,8 +23,6 @@ const SPAWN_BARE_BASH = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync)\(\ const COMMENT = /^\s*(?:\/\/|\*|\/\*)/; const KNOWN_SITES: Record<string, string> = { - 'lib/pregate/checks/guards.ts': 'PR 5 (a #3190 file): repo guard commands move to runBashSync', - 'lib/pregate/checks/lanes.ts': 'PR 5 (a #3190 file): the lane runner moves to runBashSync', 'lib/evidence-bundle.ts': 'outside this wave: bundle replay commands (bash -lc) move to runBashSync', 'bin/gstack-brain-context-load.ts': 'outside this wave: the brain context loader moves to runBashSync', 'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync', diff --git a/test/setup-hook-syntax-gate.test.ts b/test/setup-hook-syntax-gate.test.ts index 4990bf20c..c86c7126a 100644 --- a/test/setup-hook-syntax-gate.test.ts +++ b/test/setup-hook-syntax-gate.test.ts @@ -36,6 +36,7 @@ function slice(from: string, to: string | null): string { } const HOOK_SECTIONS = [ + slice('_setup_ready_line() {', '\n}\n') + '\n}\n', slice('CANONICAL_GSTACK_ROOT="$(gstack_canonical_claude_root)"', '# ─── GBrain detection'), slice('# 11. Plan-tune cathedral hook install', '# ─── Redact pre-push guard consent'), slice('if [ -n "${_HOOK_REFUSED:-}" ]; then', null), diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index 4ae90662a..689e2b67c 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -531,6 +531,7 @@ function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelud 'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }', `SOURCE_GSTACK_DIR="${tmp}"`, `_PW_FAIL_REASON="${reason}"`, + extractFn('_setup_ready_line'), ...prelude, tail, 'echo "REACHED_END=1"', diff --git a/test/ship-issue-intent.test.ts b/test/ship-issue-intent.test.ts index e65b281ec..d59c1deb9 100644 --- a/test/ship-issue-intent.test.ts +++ b/test/ship-issue-intent.test.ts @@ -8,12 +8,13 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { mentionsIn, renderIssueLines, resolveLinks } from '../lib/issue-links'; +import { lineSources, mentionsIn, promotionCandidates, relatedPrs, renderIssueLines, resolveLinks, selectFlakes, type ClosingPr, type IssueRow } from '../lib/issue-links'; import { cleanup, commit, ghShim, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; const fixture = () => { const r = makeFixtureRepo(); repos.push(r); return r; }; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const BOT = { login: 'app/github-actions', is_bot: true }; describe('issue intent (pure)', () => { test('trailers carry closure; bare mentions, partial fixes, deferred asks and cross-repo ids are references', () => { @@ -37,7 +38,11 @@ describe('gstack-issue-links (bin)', () => { test('reads origin/<base>..HEAD, probes closure issues through gstack-issue-guard, maps CI run ids to flake issues, writes the receipt object', () => { const repo = fixture(); commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }, 'fix add\n\nSee #12 for context, partially fixes #13.\n\nFixes #14\nCloses #15\nRefs other/repo#9\n'); - const env = ghShim(repo, { issues: { '14': { title: 't', body: 'b', comments: [] }, '16': { title: 'u', body: '', comments: [] } }, issueList: [{ number: 40, body: 'Flake in run 123456' }, { number: 41, body: 'unrelated' }] }); + const env = ghShim(repo, { + issues: { '14': { title: 't', body: 'b', comments: [] }, '16': { title: 'u', body: '', comments: [] } }, + issueList: [{ number: 40, title: 'Flake: test/util.test.js', author: BOT, body: 'Flake in https://github.com/owner/repo/actions/runs/123456' }, { number: 41, body: 'unrelated' }], + closing: { '14': [[]], '16': [[]] }, + }); const out = path.join(repo.work, '.gstack/tmp/issues.json'); const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456', '--issue', '16', '--out', out], env); expect(r.status).toBe(EXIT.fail); @@ -64,3 +69,184 @@ describe('gstack-issue-links (bin)', () => { expect(runBin(none, 'gstack-issue-links', ['--help']).out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag'); }); }); + +const flakeJson = (run: number, file: string, test: string) => + `failed on master\n\n<!-- flake:json -->\n\`\`\`json\n${JSON.stringify({ schema: 'flake/v1', file, test, first_seen: { run_id: run } }, null, 2)}\n\`\`\`\n`; + +describe('promotion candidates (D1)', () => { + test('a subject parenthetical is a Refs candidate the owner promotes with fix #N; a Fixes: trailer closes', () => { + const subject = 'sync groups: a member after an uncommitted member is cancelled, never published ahead of it (#6252)\n\nContributor PR #6253 took the counting.\n'; + const mentions = mentionsIn(subject, 'commit a33b20ea8a'); + const links = resolveLinks(mentions, [], () => true); + expect(renderIssueLines(links)).toEqual(['Refs #6252', 'Refs #6253']); + expect(promotionCandidates(mentions, links)).toEqual([ + { issue: 6252, source: 'commit a33b20ea8a: sync groups: a member after an uncommitted member is cancelled, never published ahead of it (#6252)' }, + { issue: 6253, source: 'commit a33b20ea8a: Contributor PR #6253 took the counting.' }, + ]); + const promoted = resolveLinks(mentions, [6252], () => true); + expect(renderIssueLines(promoted)).toEqual(['Fixes #6252', 'Refs #6253']); + expect(promotionCandidates(mentions, promoted).map(c => c.issue)).toEqual([6253]); + expect(renderIssueLines(resolveLinks(mentionsIn(`${subject}\nFixes: #6252\n`), [], () => true))).toEqual(['Fixes #6252', 'Refs #6253']); + const unreadable = resolveLinks(mentionsIn('Fixes #9'), [], () => false); + expect(promotionCandidates(mentionsIn('Fixes #9'), unreadable)).toEqual([]); + }); +}); + +describe('flake issues (D3): bot-opened, this repository, this run, a named test', () => { + const repo = 'garrytan/gbrain'; + const rows: IssueRow[] = [ + { number: 6304, title: 'Flake: test/bounded-child-exec.test.ts › keeps stdout', author: BOT, body: flakeJson(37769719459, 'test/bounded-child-exec.test.ts', 'keeps stdout') }, + { number: 6310, title: 'CI is red again', author: { login: 'andreineacsu', is_bot: false }, body: 'see https://github.com/garrytan/gbrain/actions/runs/37769719459' }, + { number: 6311, title: 'Flake: test/a.test.ts', author: { login: 'github-actions[bot]' }, body: 'first seen https://github.com/other/fork/actions/runs/37769719459' }, + { number: 6312, title: 'nightly summary', author: BOT, body: 'run https://github.com/garrytan/gbrain/actions/runs/37769719459' }, + { number: 6313, title: 'Flake: test/b.test.ts', author: BOT, body: 'run https://github.com/garrytan/gbrain/actions/runs/377697194590' }, + { number: 6490, title: 'Flake: test/write-gate-perf.slow.test.ts › p95', author: BOT, body: flakeJson(37769719459, 'test/write-gate-perf.slow.test.ts', 'p95') }, + { number: 6570, title: 'Flake: test/write-gate-perf.slow.test.ts › p95', author: BOT, body: flakeJson(37769719459, 'test/write-gate-perf.slow.test.ts', 'p95') }, + { number: 6600, title: 'Flake: test/c.test.ts', author: { login: 'app/capy-ai', is_bot: true }, body: 'Failed in https://github.com/GarryTan/GBrain/actions/runs/37769719459.' }, + ]; + + test('keeps only qualifying issues and says why each other one was refused', () => { + const picked = selectFlakes('37769719459', repo, [...rows].reverse()); + expect(picked.flakes).toEqual([ + { issue: 6304, run: '37769719459', test: 'test/bounded-child-exec.test.ts › keeps stdout' }, + { issue: 6490, run: '37769719459', test: 'test/write-gate-perf.slow.test.ts › p95' }, + { issue: 6600, run: '37769719459', test: 'test/c.test.ts' }, + ]); + expect(picked.rejected).toEqual([ + { issue: 6310, reason: 'opened by @andreineacsu, not a bot' }, + { issue: 6311, reason: 'names run 37769719459 of other/fork, not garrytan/gbrain' }, + { issue: 6312, reason: 'names no test (no flake:json file, no "Flake: <test>" title)' }, + { issue: 6313, reason: 'does not name run 37769719459 of garrytan/gbrain' }, + { issue: 6570, reason: 'duplicate of #6490 (test/write-gate-perf.slow.test.ts › p95)' }, + ]); + expect(selectFlakes('1', repo, [{ number: 1, title: 'Flake: x', author: BOT, body: '<!-- flake:json -->\n```json\n{not json\n```' }]).rejected).toEqual([{ issue: 1, reason: 'does not name run 1 of garrytan/gbrain' }]); + }); +}); + +describe('related PRs (D2)', () => { + const pr = (number: number, login: string, extra: Partial<ClosingPr> = {}): ClosingPr => ({ number, state: 'OPEN', headRefName: `b${number}`, author: { login }, repository: { nameWithOwner: 'garrytan/gbrain' }, ...extra }); + + test('open PRs by other authors closing a fixed issue are Related; own, own-branch and closed PRs are not; Supersedes and credit only when confirmed', () => { + const closing = new Map<number, ClosingPr[]>([ + [6252, [pr(6253, 'andreineacsu'), pr(6279, 'garrytan'), pr(6290, 'someone', { headRefName: 'feature' }), pr(6291, 'x', { state: 'CLOSED' }), pr(12, 'forker', { repository: { nameWithOwner: 'forker/gbrain' } })]], + [6275, [pr(6253, 'andreineacsu'), pr(6299, 'other', { author: null })]], + ]); + const related = relatedPrs(closing, { repo: 'GarryTan/gbrain', viewer: 'GarryTan', branch: 'feature' }); + expect(related).toEqual([ + { pr: 6253, author: 'andreineacsu', closes: [6252, 6275] }, + { pr: 6299, author: 'ghost', closes: [6275] }, + { pr: 12, repo: 'forker/gbrain', author: 'forker', closes: [6252] }, + ]); + const links = resolveLinks(mentionsIn('Fixes #6252\nFixes #6275'), [], () => true, [], related); + expect(renderIssueLines(links)).toEqual(['Fixes #6252', 'Fixes #6275', 'Related: #6253 (@andreineacsu)', 'Related: #6299 (@ghost)', 'Related: forker/gbrain#12 (@forker)']); + related[0]!.supersedes = true; + related[0]!.credit = true; + expect(renderIssueLines(links).slice(2, 4)).toEqual(['Supersedes #6253', 'Contributed by @andreineacsu']); + expect(lineSources(links).slice(0, 2)).toEqual([ + 'Supersedes #6253 ← open PR closing #6252, #6275 by @andreineacsu; owner confirmed --supersedes', + 'Contributed by @andreineacsu ← #6253, open PR closing #6252, #6275; owner confirmed --credit', + ]); + expect(resolveLinks([], [], () => true, [], [])).not.toHaveProperty('related'); + }); +}); + +describe('gstack-issue-links (bin): related PRs, flake validation, lookup failures', () => { + const setup = (data: Parameters<typeof ghShim>[1]) => { + const repo = fixture(); + commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }, 'fix: cancel members after an uncommitted head (#30)\n\nFixes #14\n'); + return { repo, env: ghShim(repo, { issues: { '14': { title: 't', body: 'b', comments: [] }, '30': { title: 'c', body: '', comments: [] } }, ...data }) }; + }; + + test('pages through open PRs closing a fixed issue, prints Related lines with their sources, and records owner-confirmed credit', () => { + const { repo, env } = setup({ closing: { '14': [[{ number: 7, state: 'OPEN', headRefName: 'contrib', author: { login: 'alice' } }, { number: 8, state: 'OPEN', headRefName: 'feature', author: { login: 'bob' } }], [{ number: 9, state: 'OPEN', headRefName: 'x', author: { login: 'me' } }, { number: 11, state: 'OPEN', headRefName: 'y', author: { login: 'carol' }, repository: { nameWithOwner: 'carol/repo' } }]] } }); + const r = runBin(repo, 'gstack-issue-links', ['--base', 'main'], env); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toBe('Fixes #14\nRefs #30\nRelated: #7 (@alice)\nRelated: carol/repo#11 (@carol)\n'); + expect(r.err).toContain('reference #30 ← commit '); + expect(r.err).toContain('(promote: fix #30)'); + expect(r.err).toContain(' Related: #7 (@alice) ← open PR closing #14\n'); + const out = path.join(repo.work, '.gstack/tmp/issues.json'); + const credited = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--supersedes', '7', '--credit', '7', '--out', out, '--json'], env); + expect(credited.status).toBe(EXIT.ok); + const j = JSON.parse(credited.out); + expect(j.lines).toEqual(['Fixes #14', 'Refs #30', 'Supersedes #7', 'Contributed by @alice', 'Related: carol/repo#11 (@carol)']); + expect(j.candidates.map((c: { issue: number }) => c.issue)).toEqual([30]); + expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual({ fixes: [14], refs: [30], flakes: [], related: [{ pr: 7, author: 'alice', closes: [14], supersedes: true, credit: true }, { pr: 11, repo: 'carol/repo', author: 'carol', closes: [14] }] }); + expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--author', 'Alice'], env).out).toBe('Fixes #14\nRefs #30\nRelated: #9 (@me)\nRelated: carol/repo#11 (@carol)\n'); + expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--supersedes', '99'], env)).toMatchObject({ status: EXIT.usage, err: expect.stringContaining('--supersedes #99 is not a Related PR (#7, carol/repo#11)') }); + }); + + test('--no-auto-links makes no Related lookup; a failed lookup is ISSUE_LOOKUP_FAILED with its anchor and leaves the closure lines', () => { + const { repo, env } = setup({}); + const off = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--no-auto-links'], env); + expect(off).toMatchObject({ status: EXIT.ok, out: 'Fixes #14\nRefs #30\n' }); + expect(off.err).not.toContain('ISSUE_LOOKUP_FAILED'); + const failed = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--json'], env); + expect(failed.status).toBe(EXIT.ok); + expect(JSON.parse(failed.out)).toMatchObject({ fixes: [14], unavailable: ['open PRs closing #14: HTTP 502: Bad Gateway (https://api.github.com/graphql)'] }); + expect(failed.err).toContain('gstack-issue-links: gstack-issue-links could not read'); + expect(failed.err).toContain('(ISSUE_LOOKUP_FAILED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#issue-lookup-failed)'); + const noRepo = ghShim(repo, { repo: null, issues: { '14': { title: 't', body: 'b', comments: [] } } }); + const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456', '--json'], noRepo); + expect(JSON.parse(r.out)).toMatchObject({ flakes: [], unavailable: ['repository: no git remotes found'] }); + }); + + test('--ci-run keeps the bot flake issue and lists the refused matches', () => { + const { repo, env } = setup({ closing: { '14': [[]] }, issueList: [ + { number: 40, title: 'Flake: test/util.test.js', author: BOT, body: 'https://github.com/owner/repo/actions/runs/123456' }, + { number: 41, title: 'me too', author: { login: 'dev', is_bot: false }, body: 'https://github.com/owner/repo/actions/runs/123456' }, + { number: 42, title: 'Flake: test/util.test.js', author: BOT, body: 'https://github.com/elsewhere/repo/actions/runs/123456' }, + ] }); + const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456'], env); + expect(r.out).toBe('Fixes #14\nRefs #30\nFlake: #40 (run 123456)\n'); + expect(r.err).toContain(' Flake: #40 (run 123456) ← bot flake issue for test/util.test.js\n'); + expect(r.err).toContain(' not a flake link: #41 (run 123456) ← opened by @dev, not a bot\n'); + expect(r.err).toContain(' not a flake link: #42 (run 123456) ← names run 123456 of elsewhere/repo, not owner/repo\n'); + }); + + test('gstack-ship-receipt write refuses a body missing a printed issue line (ISSUE_LINKS_MISSING) and embeds the issues without the lines', () => { + const { repo, env } = setup({ closing: { '14': [[{ number: 7, state: 'OPEN', headRefName: 'contrib', author: { login: 'alice' } }]] } }); + const out = path.join(repo.work, '.gstack/tmp/issues.json'); + expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', out], env).status).toBe(EXIT.ok); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\nx\n\n## Issues\nFixes #14\nRefs #30 (a context mention)\n'); + const args = ['write', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--issues', `@${out}`, '--body', body, '--json']; + const refused = runBin(repo, 'gstack-ship-receipt', args, env); + expect(refused.status).toBe(EXIT.fail); + expect(refused.err).toContain(`— ${body} lacks "Related: #7 (@alice)"; fix: `); + expect(refused.err).toContain('(ISSUE_LINKS_MISSING; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#issue-links-missing)'); + expect(fs.readFileSync(body, 'utf8').startsWith('## Summary')).toBe(true); + fs.appendFileSync(body, 'Related: #7 (@alice)\n'); + const ok = runBin(repo, 'gstack-ship-receipt', args, env); + expect(ok.status).toBe(EXIT.ok); + expect(JSON.parse(ok.out).issues).toEqual({ fixes: [14], refs: [30], flakes: [], related: [{ pr: 7, author: 'alice', closes: [14] }] }); + expect(fs.readFileSync(body, 'utf8').startsWith('```gstack-ship-receipt')).toBe(true); + }); +}); + +describe('gstack-issue-links (bin): this PR\'s author when gh runs as an app', () => { + const ownPr = { number: 8, state: 'OPEN', headRefName: 'other-wave', author: { login: 'garrytan' } }; + const contrib = { number: 7, state: 'OPEN', headRefName: 'contrib', author: { login: 'alice' } }; + const setup = (data: Parameters<typeof ghShim>[1]) => { + const repo = fixture(); + commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }, 'fix: tidy\n\nFixes #14\n'); + return { repo, env: ghShim(repo, { repo: 'garrytan/gbrain', viewer: 'capy-ai[bot]', issues: { '14': { title: 't', body: 'b', comments: [] } }, closing: { '14': [[contrib, ownPr]] }, ...data }) }; + }; + + test('an app viewer is replaced by the HEAD commit author, else a user-owned repository\'s owner, so the owner\'s own PRs are not Related', () => { + const byCommit = setup({ headAuthor: 'GarryTan', ownerType: 'Organization' }); + const r = runBin(byCommit.repo, 'gstack-issue-links', ['--base', 'main', '--json'], byCommit.env); + expect(JSON.parse(r.out)).toMatchObject({ lines: ['Fixes #14', 'Related: #7 (@alice)'], author: { login: 'GarryTan', source: 'HEAD commit author' }, unavailable: [] }); + const byOwner = setup({}); + expect(JSON.parse(runBin(byOwner.repo, 'gstack-issue-links', ['--base', 'main', '--json'], byOwner.env).out)).toMatchObject({ lines: ['Fixes #14', 'Related: #7 (@alice)'], author: { login: 'garrytan', source: 'repository owner' } }); + expect(JSON.parse(runBin(byOwner.repo, 'gstack-issue-links', ['--base', 'main', '--author', 'alice', '--json'], byOwner.env).out)).toMatchObject({ lines: ['Fixes #14', 'Related: #8 (@garrytan)'], author: { login: 'alice', source: '--author' } }); + }); + + test('an app viewer with no user to stand in keeps every PR and reports the gap as ISSUE_LOOKUP_FAILED', () => { + const { repo, env } = setup({ ownerType: 'Organization' }); + const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--json'], env); + expect(r.status).toBe(EXIT.ok); + expect(JSON.parse(r.out)).toMatchObject({ lines: ['Fixes #14', 'Related: #7 (@alice)', 'Related: #8 (@garrytan)'], author: { login: 'capy-ai[bot]', source: 'gh viewer' } }); + expect(JSON.parse(r.out).unavailable).toEqual(['this PR\'s author: gh runs as capy-ai[bot] and neither the HEAD commit nor the repository owner names a user; pass --author <login>']); + }); +}); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index 939f5eef6..6fcf688aa 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -92,6 +92,31 @@ describe('ubicloud free-suite runner', () => { expect(result.stderr.toString()).toContain('UBICLOUD_API_KEY is not set'); expect(result.stdout.toString()).toBe(''); }); + + test('up and run accept only the storage sizes Ubicloud offers, before any API call; UBI_ALLOWED_STORAGE widens the list', () => { + const root = mkdtempSync(join(tmpdir(), 'ubi-storage-')); + try { + const bin = join(root, 'bin'), log = join(root, 'curl.log'); + mkdirSync(bin); + writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash\necho "curl $*" >> ${JSON.stringify(log)}\necho 000\n`); + chmodSync(join(bin, 'curl'), 0o755); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(root, 'state'), UBI_VM_RECORD: join(root, 'vms.tsv'), UBI_OWNER: 't' }; + const ubi = (args: string[], extra: Record<string, string> = {}) => Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, timeout: 10_000 }); + for (const args of [['up', '-S', '100'], ['run', '--storage', '1000', '--', 'true'], ['up', '-S', '']]) { + const r = ubi(args); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toContain('(allowed: 320 640; set UBI_ALLOWED_STORAGE when Ubicloud adds sizes)'); + } + expect(existsSync(log)).toBe(false); + for (const [size, extra] of [['320', {}], ['1280', { UBI_ALLOWED_STORAGE: '320 640 1280' }]] as const) { + const accepted = ubi(['run', '-S', size, '--bogus'], extra); + expect(accepted.stderr.toString()).toContain('run: unknown option --bogus'); + } + expect(existsSync(log)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('ubi-runner teardown is confirmed and never sweeps other runners by default', () => {