v1.91.304.0 fix(capy-install): an install for one workflow keeps the other current verdicts (#3561) (#3563)

* fix(capy-install): an install for one workflow keeps the other current verdicts (#3561)

The install verdict is one file per checkout and project, and an ok --for document-release run replaced the --for ship verdict with its narrower required list, so /ship's docs step left the session without a ship admission. An ok run at the same checkout, HEAD and Bun now merges the earlier ok verdict: required is the union, pins are planning-only only when both runs skipped them, and for= lists every workflow. A failing run, another HEAD or another Bun still replaces it.

Fixes #3561

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

* docs(changelog): installs for two workflows keep both verdicts (#3561)

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

* docs(capy-install): an ok install keeps the other current verdicts (#3561)

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-29 02:33:42 +00:00
1 parent 128dd06f09
commit 4729f4752b
8 files changed
+77 -8

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.91.304.0] - 2026-10-29
**Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13).
### Fixed
- **`bin/gstack-capy-install`**: an ok run keeps what an earlier ok install certified for the same checkout, project, HEAD and Bun. The verdict's `required` list becomes the union, pins count as planning-only only when both runs skipped them, and `for` lists every workflow (`for=ship,document-release`). The installer says so in one line. A failing run, a new HEAD or another Bun still replaces the verdict, and the review-voice profile still never narrows a fuller one.
### Tests
- `test/gstack-capy-install.test.ts`: `--for ship` then `--for document-release` leaves one ok verdict that admits both workflows through `verdictProblem`; a `--check --for ship` keeps it; after a new commit, `--for document-release` replaces it and ship is refused again. It failed before this change.
## [1.91.303.0] - 2026-10-29
**`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13).
+1 -1
View File
@@ -1 +1 @@
1.91.303.0
1.91.304.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.303.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.304.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,
+19 -2
View File
@@ -407,15 +407,32 @@ verdict_kept() {
[ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)"
}
# An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun,
# so one machine serving /ship and its docs step stays admitted for both (#3561): required is
# the union, pins count as planning-only only when both runs skipped them, and for= lists every
# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict.
record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded)
local v_for="${FOR:-}" v_req v_plan e_for id
if verdict_kept; then
say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)"
return 0
fi
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" \
v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)
v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)
if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \
&& [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \
&& ! gstack_doctor_install_profile "$e_for"; then
for id in $(gstack_install_verdict_field "$VERDICT" required); do
case " $v_req " in *" $id "*) ;; *) v_req="$v_req $id" ;; esac
done
[ "$(gstack_install_verdict_field "$VERDICT" pins_planning_only)" = true ] || v_plan=0
case ",$e_for," in *",$v_for,"*) v_for="$e_for" ;; *) v_for="${e_for:+$e_for${v_for:+,}}$v_for" ;; esac
[ "$v_for" = "$e_for" ] || [ -z "$e_for" ] || say "the install verdict at $VERDICT keeps for=$e_for and adds ${FOR:-$REQUIRE} (for=$v_for)"
fi
gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "$v_for" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \
"$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" \
|| say "could not write the install verdict $VERDICT"
}
+3 -1
View File
@@ -8,7 +8,9 @@
# 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 2, checkout, head, for, required[], pins_planning_only, ok,
# schema_version 2, checkout, head, for (comma-separated: an ok run at the same
# HEAD and Bun keeps the workflows an earlier ok install certified, #3561),
# required[] (their union), 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,
# node_path (realpath), node_version, node_rule (gstack_node_resolve; empty
+10 -1
View File
@@ -274,7 +274,16 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and
`bun_install` when this run downloaded it), the resolved Node when the
project pins one (`node_path`, `node_version`, `node_rule`), the trailer, the remote
minimum, the launch PATH and the time. Admission refuses a workflow whose
minimum, the launch PATH and the time. An ok run (install or `--check`) at
the same checkout, project, HEAD and Bun as an earlier ok verdict keeps what
that verdict certified, so one machine stays admitted for /ship and its
`--for document-release` docs step (#3561): `required` becomes the union,
pins count as planning-only only when both runs skipped them, and the
verdict's `for` lists every workflow, comma-separated (`for=ship,document-release`;
the installer prints `the install verdict at <file> keeps for=<old> and adds
<new> (for=<list>)`, and the trailer still names this run's workflow). A
failing run, a new HEAD or another Bun replaces the verdict, and so does any
workflow install over a review-voice verdict. 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.303",
"version": "1.91.304",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+32 -1
View File
@@ -13,6 +13,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { verdictProblem } from '../lib/install-verdict';
const REPO = path.resolve(import.meta.dir, '..');
const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
@@ -58,7 +59,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
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 "doctor-node $(node --version 2>/dev/null)" >> "$CAPY_LOG"',
'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
'echo "required: ${STUB_REQUIRED:-claude codex patch}"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"',
'[ "${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"',
@@ -351,6 +352,36 @@ describe('gstack-capy-install', () => {
expect(run(f, ['--for', 'review-voice', '--project', project]).stdout).not.toMatch(upgrading);
});
// #3561: on a shared machine /ship's docs step runs the installer --for document-release, whose
// narrower verdict replaced the --for ship one, so /ship lost admission mid-run.
test('--for ship then --for document-release: the verdict keeps both and admits both workflows; another HEAD replaces it', () => {
const f = fixture();
const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') };
const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home);
const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
expect(ship.status, ship.stdout + ship.stderr).toBe(0);
const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!;
expect(admits('ship')).toBeNull();
const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
expect(docs.status, docs.stdout + docs.stderr).toBe(0);
expect(docs.stdout).toContain(`gstack-capy-install: the install verdict at ${file} keeps for=ship and adds document-release (for=ship,document-release)`);
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(v).toMatchObject({ ok: true, for: 'ship,document-release', pins_planning_only: false });
expect([...v.required].sort()).toEqual(['claude', 'codex', 'patch', 'pins', 'runtime']);
expect(admits('ship')).toBeNull();
expect(admits('document-release')).toBeNull();
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(v);
const again = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' });
expect(again.status, again.stdout + again.stderr).toBe(0);
expect(again.stdout).not.toContain('keeps for=');
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship,document-release' });
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root);
const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' });
expect(moved.status, moved.stdout + moved.stderr).toBe(0);
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', required: ['claude', 'runtime', 'pins'] });
expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs');
});
test('--root clones when the directory is absent and refuses a non-gstack directory', () => {
const f = fixture();
const clone = path.join(f.base, 'clone');