v1.93.11.0 fix(capy): installer picks the loadout from the owner's agent_model when no model is named (GST-982) (#3872)

* fix(capy): installer picks the loadout from the owner's agent_model preference when no model is named (GST-982)

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

* fix(capy): label config-sourced agent_model, note the assumption, skip it inside eval runs (GST-982 review)

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

* docs(loadouts): the Capy preference is an assumption; non-Opus sessions set GSTACK_MODEL (GST-982 review)

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

* fix(capy): an unresolvable agent_model prints LOADOUT_FALLBACK (GST-982 review)

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

* test(capy): type the eval env rows for the test typecheck ratchet (GST-982)

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-11-02 17:54:07 +00:00
1 parent daf661374f
commit 24a47db3d9
8 files changed
+115 -13

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## [1.93.11.0] - 2026-11-02
**Capy sessions get their model's loadout again without a sentence in the skill (GST-982).** The browse wave (#3858) removed the Capy skill's instruction to run the installer with `GSTACK_MODEL`, to keep `capy/skill/gstack/SKILL.md` under its context budget. After that, every Capy session installed the full skills, including Opus 5.5 sessions that should get `lite`. Now the installer finds the model by itself.
### Fixed
- **`gstack-capy-install` reads the owner's model preference.** When it gets no `--model` and no `GSTACK_MODEL`, it uses the `agent_model` in the session's `GSTACK_PREFERENCES_FILE` (the Drive skill's `preferences.yaml`, which `gstack-capy-session` already exports), resolved by `gstack-models resolve --role agent`. So `opus` means `claude-opus-5-5` today. The line reads `LOADOUT: … source=preferences` (`source=config` when this machine's `config.yaml` sets it), followed by a `LOADOUT_ASSUMED:` note that the model came from the preference and not the session. Eval runs (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set) skip the preference, and a preference `gstack-models` rejects prints `LOADOUT_FALLBACK` instead of serving full silently. `GSTACK_MODEL` and `--model` still win, and a `host` preference or no file still serves full. The frozen bootstrapper `capy/skill/gstack/scripts/install.sh` and the skill text are unchanged.
### Tests
- `test/gstack-capy-install.test.ts`: an `opus` preference picks lite with `source=preferences` and its own launch file, `GSTACK_MODEL` overrides the preference, a `config.yaml` preference reads `source=config`, eval runs skip the preference, a rejected preference prints `LOADOUT_FALLBACK`, a `host` preference prints no loadout lines, and the real `gstack-models resolve --role agent` keeps the `agent: <provider>/<model>` first line the installer reads.
## [1.93.10.0] - 2026-11-02 ## [1.93.10.0] - 2026-11-02
### Changed ### Changed
+1 -1
View File
@@ -1 +1 @@
1.93.10.0 1.93.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.93.10.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.93.11.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+18 -4
View File
@@ -42,7 +42,8 @@
# mode-0600 file beside the verdict; callers and their children `. <file>` it, # mode-0600 file beside the verdict; callers and their children `. <file>` it,
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`; # never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail). # grep that, never pipe through tail).
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), names the model this session runs: # --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), else the agent_model of the session's
# GSTACK_PREFERENCES_FILE as gstack-models resolve --role agent reads it (GST-982), names the model this session runs:
# bin/gstack-modules install picks its loadout, publishes a non-full one beside # bin/gstack-modules install picks its loadout, publishes a non-full one beside
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the # the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
# launch lines; a non-full loadout gets its own launch file, keyed by loadout # launch lines; a non-full loadout gets its own launch file, keyed by loadout
@@ -723,16 +724,29 @@ fi
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; } [ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
LOADOUT_LINES="" LOADOUT_LINES=""
if [ "$RC" -eq 0 ] && [ -z "$MODEL" ] && [ -n "${GSTACK_PREFERENCES_FILE:-}" ] && [ -z "${GSTACK_EVAL_DIR:-}${EVALS_RUN_ID:-}" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_ag_out=$(cd "$ROOT" && bin/gstack-models resolve --role agent 2>&1) && _ag_rc=0 || _ag_rc=$?
_ag_first=$(printf '%s\n' "$_ag_out" | head -1)
case "$_ag_first" in
*", this machine") MODEL_SOURCE=config ;;
*", the preferences file") MODEL_SOURCE=preferences ;;
esac
[ "$_ag_rc" -eq 0 ] || LOADOUT_LINES="LOADOUT_FALLBACK: agent_model unreadable: $(printf '%s' "$_ag_first" | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
[ "$MODEL_SOURCE" = GSTACK_MODEL ] || MODEL=$(printf '%s' "$_ag_first" | sed -n 's|^agent: [a-z0-9-]*/\([A-Za-z0-9._-]*\) .*|\1|p')
fi
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)") _lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
if [ "$MODEL_SOURCE" = model-flag ]; then if [ "$MODEL_SOURCE" = GSTACK_MODEL ]; then
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
else
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1) _lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
else
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
fi fi
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ') _lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
case "$MODEL_SOURCE" in preferences|config) _lo_line=$(printf '%s' "$_lo_line" | sed "s/ source=model-flag/ source=$MODEL_SOURCE/") ;; esac
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1) _lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ') _lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
case "$MODEL_SOURCE" in preferences|config) _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_ASSUMED: model=$MODEL is the owner's agent_model ($MODEL_SOURCE), not read from the session; a session on another model sets GSTACK_MODEL=<its id>" ;; esac
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p') _lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p') _lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
+3 -2
View File
@@ -472,8 +472,9 @@ make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line (LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
and removes the file an earlier passing run left beside the verdict it rewrites and removes the file an earlier passing run left beside the verdict it rewrites
(a profile check that keeps a fuller ok verdict leaves that verdict's file). (a profile check that keeps a fuller ok verdict leaves that verdict's file).
With `--model <id>`, or `GSTACK_MODEL=<id>` in its environment (what the Capy With `--model <id>`, `GSTACK_MODEL=<id>` in its environment, or else an
skill passes), an ok run other than `--for review-voice` also prints `LOADOUT: …` `agent_model` in the session's `GSTACK_PREFERENCES_FILE` outside an eval run (GST-982, with a
`LOADOUT_ASSUMED:` note), an ok run other than `--for review-voice` also prints `LOADOUT: …`
and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than
`full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`, `full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`,
a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the
+5 -4
View File
@@ -44,17 +44,18 @@ bin/gstack-modules install --full-root "$HOME/.claude/skills" --model claude-opu
### How gstack picks a loadout ### How gstack picks a loadout
gstack never guesses the model. It uses, in order: gstack does not detect the running model. It uses, in order:
1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model. 1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model.
2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads. 2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads.
3. Nothing: `full`. 3. On Capy only, with neither and outside an eval run: the owner's `agent_model` (this machine's `config.yaml`, else the Drive skill's `preferences.yaml`), as `gstack-models resolve --role agent` resolves it (`opus` is the newest Opus). This is an assumption, not a detection, and the installer prints `LOADOUT_ASSUMED:` beside it. `host` or no preference means no model.
4. Nothing: `full`.
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so a Sonnet subagent of an Opus session gets `full` unless its own install names Opus. A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so on Claude Code a Sonnet subagent of an Opus session gets `full` unless its own install names Opus. On Capy every session shares the owner's preference, so a session on another model sets `GSTACK_MODEL=<its id>` to get its own loadout.
### Capy ### Capy
The Capy skill passes the thread's model id in the environment: `GSTACK_MODEL=<id> bash scripts/install.sh --check --for <skill>`. The variable, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines: A plain `bash scripts/install.sh --check --for <skill>` picks the loadout from the owner's `agent_model` preference, so the skill needs no model sentence (`source=preferences` on the line below, or `source=config` when this machine's `config.yaml` sets `agent_model`). The preference names the model Garry starts sessions on, not the model reading the skill, so the installer says so in a `LOADOUT_ASSUMED:` line, and it skips the preference inside an eval run (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set), where the model under test may differ. A session on another model names its own with `GSTACK_MODEL=<id>`, which wins over the preference and, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
``` ```
LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.93.10", "version": "1.93.11",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+76
View File
@@ -593,6 +593,82 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(fs.existsSync(lite)).toBe(true); expect(fs.existsSync(lite)).toBe(true);
}); });
test('with no --model and no GSTACK_MODEL, the agent_model in the session preferences file picks the loadout (GST-982); GSTACK_MODEL still wins and a host preference serves full', () => {
const f = fixture();
script(path.join(f.root, 'bin/gstack-modules'), [
'echo "modules $*" >> "$CAPY_LOG"',
'model="${GSTACK_MODEL:-}" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done',
'case "$model" in',
' claude-opus-5-5) mkdir -p "$HOME/lite-0123456789ab"; echo "LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no"; echo "SKILL_ROOT: $HOME/lite-0123456789ab" ;;',
' *) echo "LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped"; echo "SKILL_ROOT: $full" ;;',
'esac',
]);
script(path.join(f.root, 'bin/gstack-models'), [
'echo "models $* prefs=${GSTACK_PREFERENCES_FILE:-}" >> "$CAPY_LOG"',
'[ -z "${FAKE_BAD_MODEL:-}" ] || { echo "agent: agent_model $FAKE_BAD_MODEL is not a model"; exit 1; }',
'[ -z "${FAKE_CONFIG_MODEL:-}" ] || { echo "agent: anthropic/$FAKE_CONFIG_MODEL (opus) - from agent_model in $HOME/.gstack/config.yaml, this machine"; exit 0; }',
'case "$(sed -n "s/^agent_model: *//p" "${GSTACK_PREFERENCES_FILE:-/dev/null}" 2>/dev/null)" in',
' opus) echo "agent: anthropic/claude-opus-5-5 (opus) - from agent_model in $GSTACK_PREFERENCES_FILE, the preferences file" ;;',
' *) echo "agent: host - keep the running agent\'s own model and omit the model field (no preference set)" ;;',
'esac',
]);
const prefs = path.join(f.home, 'drive-skill/preferences.yaml');
write(prefs, 'agent_model: opus\n');
const launchOf = (out: string) => out.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)![1];
const opus = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
expect(opus.status, opus.stdout + opus.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('modules '))).toEqual([`modules install --model claude-opus-5-5 --full-root ${f.home}/.claude/skills`]);
expect(opus.stdout).toContain(`LOADOUT: lite model=claude-opus-5-5 source=preferences digest=0123456789ab reused=no\nSKILL_ROOT: ${f.home}/lite-0123456789ab\n`);
expect(launchOf(opus.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.loadout-lite-0123456789ab.env'));
expect(opus.stdout).toContain("LOADOUT_ASSUMED: model=claude-opus-5-5 is the owner's agent_model (preferences), not read from the session; a session on another model sets GSTACK_MODEL=<its id>\n");
const config = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_CONFIG_MODEL: 'claude-opus-5-5' });
expect(config.status, config.stdout + config.stderr).toBe(0);
expect(config.stdout).toContain('LOADOUT: lite model=claude-opus-5-5 source=config digest=0123456789ab');
expect(config.stdout).toContain("is the owner's agent_model (config)");
const modulesBefore = f.calls().filter(l => l.startsWith('modules ')).length;
for (const evalEnv of [{ GSTACK_EVAL_DIR: path.join(f.home, 'evals') }, { EVALS_RUN_ID: 'r1' }] as Record<string, string>[]) {
const ev = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, ...evalEnv });
expect(ev.status, ev.stdout + ev.stderr).toBe(0);
expect(ev.stdout).not.toMatch(/^(LOADOUT|LOADOUT_ASSUMED|SKILL_ROOT):/m);
}
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
const bad = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_BAD_MODEL: 'gpt-6.1-sol' });
expect(bad.status, bad.stdout + bad.stderr).toBe(0);
expect(bad.stdout).toContain('LOADOUT_FALLBACK: agent_model unreadable: agent: agent_model gpt-6.1-sol is not a model; serving full (docs/troubleshooting.md#loadout-fallback)\n');
expect(bad.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
expect(launchOf(bad.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
const env = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, GSTACK_MODEL: 'claude-sonnet-5-5' });
expect(env.status, env.stdout + env.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`);
expect(env.stdout).toContain('LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped\n');
write(prefs, 'agent_model: host\n');
const host = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
expect(host.status, host.stdout + host.stderr).toBe(0);
expect(host.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
expect(launchOf(host.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
});
test('the first line of gstack-models resolve --role agent keeps the provider/model shape the installer reads (GST-982)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst982-'));
try {
for (const [pref, want] of [['opus', /^agent: anthropic\/claude-opus-[0-9a-z.-]+ \(opus\) /], ['host', /^agent: host /]] as const) {
const file = path.join(dir, `${pref}.yaml`);
fs.writeFileSync(file, `agent_model: ${pref}\n`);
const out = spawnSync(path.join(REPO, 'bin/gstack-models'), ['resolve', '--role', 'agent'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_PREFERENCES_FILE: file, GSTACK_STATE_ROOT: dir, GSTACK_HOME: dir } });
expect(out.status, out.stderr).toBe(0);
expect(out.stdout.split('\n')[0]).toMatch(want);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --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 f = fixture();
const r = run(f, ['--for', 'autoplan']); const r = run(f, ['--for', 'autoplan']);