v1.91.332.0 feat(capy): remember a user's model preference (agent_model); every Capy session reads factory.md (#3634)

* feat(capy): agent_model preference via gstack-models resolve --role agent; capy/skill/gstack/factory.md

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

* fix(agent-model): pre-landing review fixes

- a broken preferences.yaml no longer blocks --request or config.yaml
- CRLF, a byte-order mark and a trailing comment still read the value
- never echo raw control or bidi bytes from an unvalidated source
- cap the bytes actually read, not only the stat size
- family names and host are case-insensitive in gstack-config too
- list passes the platform through to the agent block
- a wrapper inside the checkout names no preferences file
- docs: --check never writes the skill folder

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

* fix(changelog): restore CHANGELOG.md history that 1bec403 truncated

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

* docs: agent_model and factory.md in capy-install, model-policy, troubleshooting and capy/README

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

* docs(capy): the Captain archives its own child threads by its plan for each (GST-211, R39)

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

* v1.91.332.0 stamp

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-30 12:03:08 +00:00
1 parent ae3d58c932
commit 39378ca6d1
20 files changed
+637 -18

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
/**
* The agent role (lib/agent-model.ts, gstack-models resolve --role agent): a
* user's model preference for gstack's working agents. Pins the value grammar
* shared with bin/gstack-config, the precedence (request, this machine's
* config.yaml, the preferences file, host), that a bad agent_model never stops
* the other roles, the preferences file's parse rules, and the Capy wiring:
* the session derives the file beside the Drive skill (any wrapper that exports
* GSTACK_CAPY_WRAPPER_PATH, wrapper 3 included) and --check copies nothing.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { AgentModelError, MODEL_FAMILIES, parseAgentModel, readPreferencesFile, renderAgentModel, resolveAgentModel } from '../lib/agent-model';
import { MODEL_CATALOG } from '../lib/model-catalog';
import { modelsMain } from '../lib/model-policy-cli';
const REPO = path.resolve(import.meta.dir, '..');
const bases: string[] = [];
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
function tmp(): string {
const b = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-model-')));
bases.push(b);
return b;
}
function env(base: string, extra: Record<string, string> = {}): Record<string, string> {
const root = path.join(base, 'state');
fs.mkdirSync(root, { recursive: true });
return { HOME: base, GSTACK_STATE_ROOT: root, ...extra };
}
const setConfig = (e: Record<string, string>, text: string) => fs.writeFileSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'), text);
const config = (e: Record<string, string>, args: string[]) => spawnSync(path.join(REPO, 'bin/gstack-config'), args, { encoding: 'utf8', env: { ...process.env, ...e }, timeout: 30_000 });
describe('value grammar', () => {
test('host, a family and <provider>/<model> parse; anything else is refused', () => {
expect(parseAgentModel('host')).toEqual({ kind: 'host' });
expect(parseAgentModel(' opus ')).toEqual({ kind: 'model', provider: 'anthropic', model: 'claude-opus-5-5', family: 'opus' });
expect(parseAgentModel('OPUS')).toMatchObject({ family: 'opus' });
expect(parseAgentModel('Host')).toEqual({ kind: 'host' });
expect(parseAgentModel('openai/gpt-6.1-sol')).toEqual({ kind: 'model', provider: 'openai', model: 'gpt-6.1-sol', family: null });
for (const bad of ['', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/', '/x', 'anthropic/a b', 'anthropic/x\u001b[31m']) expect(parseAgentModel(bad)).toBeNull();
});
test('every family the tier catalog also lists names the catalog model, so a catalog bump moves the family too', () => {
for (const entry of MODEL_CATALOG) {
const family = MODEL_FAMILIES.find(f => f.provider === entry.provider && entry.model.includes(f.family));
expect(family?.model, entry.model).toBe(entry.model);
}
});
test('bin/gstack-config accepts and refuses exactly what the resolver does', () => {
const e = env(tmp());
for (const value of ['host', 'Host', 'opus', 'OPUS', 'Sol', 'sonnet', 'terra', 'anthropic/claude-opus-5-5', 'openai/gpt-6.1-sol', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/a b', 'anthropic/']) {
const r = config(e, ['set', 'agent_model', value]);
expect(r.status === 0, value + r.stderr).toBe(parseAgentModel(value) !== null);
}
});
});
describe('precedence and sources', () => {
test('host when nothing is set; the preferences file, then config.yaml, then a one-job request win in turn', () => {
const base = tmp();
const prefs = path.join(base, 'skill', 'preferences.yaml');
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', id: null, source: { kind: 'default' } });
fs.mkdirSync(path.dirname(prefs), { recursive: true });
fs.writeFileSync(prefs, '# my picks\nagent_model: opus\n');
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'selected', id: 'anthropic/claude-opus-5-5', family: 'opus', source: { kind: 'preferences', path: prefs } });
setConfig(e, 'agent_model: openai/gpt-6.1-sol\n');
expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'openai/gpt-6.1-sol', source: { kind: 'config' }, sources: { config: 'openai/gpt-6.1-sol', preferences: 'opus' } });
expect(resolveAgentModel({ env: e, request: 'fable' })).toMatchObject({ id: 'anthropic/claude-fable-5-1', source: { kind: 'request', path: null } });
expect(resolveAgentModel({ env: e, request: 'host' })).toMatchObject({ status: 'host', source: { kind: 'request' } });
});
test('a one-job request is never saved, and a local unset falls back to the preferences file', () => {
const base = tmp();
const prefs = path.join(base, 'preferences.yaml');
fs.writeFileSync(prefs, 'agent_model: opus\n');
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
const r = modelsMain(['resolve', '--role', 'agent', '--request', 'sol'], e);
expect(r.code, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^agent: openai\/gpt-6\.1-sol \(sol\) - requested for this job, not saved$/m);
expect(fs.existsSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'))).toBe(false);
expect(fs.readFileSync(prefs, 'utf8')).toBe('agent_model: opus\n');
expect(config(e, ['set', 'agent_model', 'host']).status).toBe(0);
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', source: { kind: 'config' } });
expect(config(e, ['unset', 'agent_model']).status).toBe(0);
expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'anthropic/claude-opus-5-5', source: { kind: 'preferences' } });
});
test('a broken preferences file never blocks a request or this machine\'s config.yaml, and its raw value is never echoed', () => {
const base = tmp();
const prefs = path.join(base, 'preferences.yaml');
fs.writeFileSync(prefs, 'agent_model: opus\nagent_model: sol\n');
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
expect(() => resolveAgentModel({ env: e })).toThrow(/sets agent_model twice/);
const requested = resolveAgentModel({ env: e, request: 'sonnet' });
expect(requested).toMatchObject({ family: 'sonnet', source: { kind: 'request' } });
expect(requested.warnings.join('\n')).toContain('sets agent_model twice');
setConfig(e, 'agent_model: opus\n');
expect(resolveAgentModel({ env: e })).toMatchObject({ family: 'opus', source: { kind: 'config' } });
fs.writeFileSync(prefs, 'agent_model: x\u001b]0;title\u0007\u009b31m\u202e\n');
const shown = renderAgentModel(resolveAgentModel({ env: e })).join('\n');
expect(shown).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/);
});
test('an invalid value at the winning source is an error naming the source and the fix, never a silent fallback', () => {
const e = env(tmp());
setConfig(e, 'agent_model: opux\n');
expect(() => resolveAgentModel({ env: e })).toThrow(AgentModelError);
const r = modelsMain(['resolve', '--role', 'agent', '--json'], e);
expect(r.code).toBe(1);
const doc = JSON.parse(r.stdout);
expect(doc).toMatchObject({ ok: false, agent: null });
expect(doc.errors[0]).toMatchObject({ reason: 'invalid_agent_model', key: 'agent_model' });
expect(doc.errors[0].problem).toContain(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'));
});
});
describe('a bad agent_model never stops the other roles', () => {
test('plan-review, implementation and eval-arms resolve, and list reports the agent error without failing', () => {
const base = tmp();
const e = env(base);
setConfig(e, 'agent_model: opux\n');
for (const args of [['resolve', '--role', 'plan-review', '--provider', 'openai'], ['resolve', '--role', 'implementation'], ['resolve', '--role', 'eval-arms']]) {
const r = modelsMain(args, e, 'linux', base);
expect(r.code, args.join(' ') + r.stderr).toBe(0);
}
const list = modelsMain(['list'], e, 'linux', base);
expect(list.code).toBe(0);
expect(list.stdout).toMatch(/^agent: agent_model 'opux' in .* is not a model gstack can use$/m);
const json = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
expect(json.ok).toBe(true);
expect(json.agent.error.reason).toBe('invalid_agent_model');
expect(json.selections.every((s: { role: string }) => s.role !== 'agent')).toBe(true);
});
test('list --json adds a top-level agent block and leaves selections as they were', () => {
const base = tmp();
const e = env(base);
const before = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
setConfig(e, 'agent_model: opus\n');
const after = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
expect(after.selections).toEqual(before.selections);
expect(after.agent).toMatchObject({ role: 'agent', id: 'anthropic/claude-opus-5-5' });
expect(after.agent.exemptions.join(' ')).toMatch(/plan-review.*eval-arms/);
});
test('usage: --request belongs to the agent role only, and the agent role takes no --provider', () => {
const e = env(tmp());
expect(modelsMain(['resolve', '--role', 'implementation', '--request', 'opus'], e).code).toBe(2);
expect(modelsMain(['resolve', '--role', 'agent', '--provider', 'openai'], e).code).toBe(2);
expect(modelsMain(['resolve', '--role', 'agent', '--request', 'opux'], e).code).toBe(1);
});
});
describe('the preferences file', () => {
test('comments, blanks and quotes are fine; unknown keys and stray lines warn; a second agent_model is an error', () => {
const f = path.join(tmp(), 'preferences.yaml');
fs.writeFileSync(f, '# owner preferences\n\nagent_model: "opus"\ntelemetry: on\nnot a pair\n');
const read = readPreferencesFile(f);
expect(read.value).toBe('opus');
expect(read.warnings).toHaveLength(2);
expect(read.warnings[0]).toContain(":4 sets 'telemetry', which gstack does not read from preferences; ignored");
expect(read.warnings[1]).toContain(":5 is not a 'key: value' line; ignored");
fs.writeFileSync(f, 'agent_model: opus\nagent_model: sol\n');
expect(() => readPreferencesFile(f)).toThrow(/sets agent_model twice/);
});
test('CRLF line endings, a byte-order mark and a trailing comment still read the value', () => {
const f = path.join(tmp(), 'preferences.yaml');
fs.writeFileSync(f, '\ufeff# owner preferences\r\nagent_model: opus # my pick\r\n');
expect(readPreferencesFile(f)).toEqual({ value: 'opus', warnings: [] });
});
test('a missing file is silent; an oversized file or a directory is ignored with a warning', () => {
const base = tmp();
expect(readPreferencesFile(path.join(base, 'absent.yaml'))).toEqual({ value: null, warnings: [] });
const big = path.join(base, 'big.yaml');
fs.writeFileSync(big, 'agent_model: opus\n' + '#'.repeat(5000));
const read = readPreferencesFile(big);
expect(read.value).toBeNull();
expect(read.warnings[0]).toContain('over 4096 bytes');
expect(readPreferencesFile(base).warnings[0]).toContain('is not a file');
});
});
describe('Capy wiring', () => {
test('the session derives preferences.yaml beside the Drive skill from the wrapper path, in --check too, and copies nothing', () => {
const base = tmp();
const root = path.join(base, 'gstack');
for (const rel of ['bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
}
fs.writeFileSync(path.join(root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "PREFS=${GSTACK_PREFERENCES_FILE:-unset}"\necho "gstack: ok 1.0.0 for=none"\n', { mode: 0o755 });
const skill = path.join(base, 'drive', 'skills', 'gstack');
fs.mkdirSync(path.join(skill, 'scripts'), { recursive: true });
fs.writeFileSync(path.join(skill, 'preferences.yaml'), 'agent_model: opus\n');
const before = fs.readdirSync(skill).sort();
const run = (extra: Record<string, string>) => spawnSync('bash', [path.join(root, 'bin/gstack-capy-session'), '--check', '--root', root, '--for', 'review'],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '', HOME: base, ...extra } });
const r = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(skill, 'scripts', 'install.sh') });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('PREFS=' + path.join(skill, 'preferences.yaml'));
expect(fs.readdirSync(skill).sort()).toEqual(before);
expect(run({}).stdout).toContain('PREFS=unset');
const inCheckout = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(root, 'capy', 'skill', 'gstack', 'scripts', 'install.sh') });
expect(inCheckout.stdout).toContain('PREFS=unset');
});
});
+57
View File
@@ -479,3 +479,60 @@ describe('capy skill: vendored text and support file', () => {
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
});
});
describe('capy skill: factory.md, the auto-loaded "how we work" page', () => {
const FACTORY = path.join(SKILL, 'factory.md');
/** Problems with a factory page's citations: [path: Heading, Heading; path2], [/skill], and plain repo paths in prose. Bare SKILL.md is the sibling file. */
function citationProblems(text: string): string[] {
const problems: string[] = [];
const headingsOf = (file: string) => new Set(fs.readFileSync(file, 'utf8').split('\n').filter(l => /^#{1,6} /.test(l)).map(l => l.replace(/^#+ /, '').trim()));
const resolve = (p: string) => p === 'SKILL.md' ? path.join(SKILL, 'SKILL.md') : path.join(REPO, p);
for (const m of text.matchAll(/\[([^\]]+)\]/g)) {
for (const part of m[1].split(';').map(s => s.trim())) {
if (part.startsWith('/')) {
if (!fs.existsSync(path.join(REPO, part.slice(1), 'SKILL.md'))) problems.push(`unknown skill ${part}`);
continue;
}
const [file, headings] = part.split(/:\s*/, 2);
if (!fs.existsSync(resolve(file))) { problems.push(`missing file ${file}`); continue; }
const known = headingsOf(resolve(file));
const list = !headings ? [] : known.has(headings.trim()) ? [headings.trim()] : headings.split(',').map(s => s.trim()).filter(Boolean);
for (const h of list) if (!known.has(h)) problems.push(`missing heading ${file}: ${h}`);
}
}
for (const m of text.matchAll(/\b((?:capy|docs|lib|bin)\/[A-Za-z0-9_./-]+\.md)\b/g)) {
if (!fs.existsSync(path.join(REPO, m[1]))) problems.push(`missing file ${m[1]}`);
}
return [...new Set(problems)];
}
test('it fits the 3,300-byte budget, and every citation, heading and skill it names resolves', () => {
const text = fs.readFileSync(FACTORY, 'utf8');
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3300);
expect(text).toMatch(/^# How we work/);
expect(citationProblems(text)).toEqual([]);
});
test('negative controls: a missing file, a missing heading and an unknown skill are each caught', () => {
expect(citationProblems('[docs/no-such-page.md: Anything]')).toEqual(['missing file docs/no-such-page.md']);
expect(citationProblems('[docs/captain.md: No Such Heading]')).toEqual(['missing heading docs/captain.md: No Such Heading']);
expect(citationProblems('[/no-such-skill]')).toEqual(['unknown skill /no-such-skill']);
expect(citationProblems('see capy/no-such.md')).toEqual(['missing file capy/no-such.md']);
});
test('it ships to every Capy user: no crew codes or owner names, a model preference instead of a model rule', () => {
const text = fs.readFileSync(FACTORY, 'utf8');
expect(text).not.toMatch(/\b(?:GSTA|GBRA|HARN|TRUT|FOUN|ALPH|GST)-\d+/);
expect(text).not.toMatch(/\bGarry\b/);
expect(text).toContain('gstack-models resolve --role agent');
expect(text).not.toMatch(/no sonnet/i);
expect(fs.readFileSync(path.join(REPO, 'docs/model-policy.md'), 'utf8')).not.toMatch(/no sonnet/i);
});
test('SKILL.md points every session at it from Session start, and names preferences.yaml as the one file an agent may write in the skill folder', () => {
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
const sessionStart = text.slice(text.indexOf('## Session start'), text.indexOf('## Launch every gstack command'));
expect(sessionStart).toContain('read `capy/skill/gstack/factory.md` in the checkout');
expect(sessionStart).toMatch(/Never write the skill folder yourself; the one exception is its `preferences\.yaml`/);
});
});
+12
View File
@@ -514,6 +514,18 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
});
test('the session GSTACK_PREFERENCES_FILE is written into the launch file, so every gstack command finds the owner preferences; without it no line is written', () => {
const f = fixture();
const prefs = '/drive/skills/gstack/preferences.yaml';
const r = run(f, ['--for', 'autoplan'], { GSTACK_PREFERENCES_FILE: prefs });
expect(r.status, r.stdout + r.stderr).toBe(0);
const launch = verdicts(f)[0].replace(/\.json$/, '.env');
expect(fs.readFileSync(launch, 'utf8')).toContain("export GSTACK_PREFERENCES_FILE='" + prefs + "'");
const plain = run(f, ['--for', 'autoplan']);
expect(plain.status, plain.stdout + plain.stderr).toBe(0);
expect(fs.readFileSync(launch, 'utf8')).not.toContain('GSTACK_PREFERENCES_FILE');
});
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']);