screen #3700: merge d06ed8a066 onto main 5ba425af4b

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit f1797bfdf2
28 files changed
+1045 -34

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"browser-skills/hackernews-frontpage": 371,
"canary": 7738,
"captain": 6551,
"capy/skill/gstack": 5312,
"capy/skill/gstack": 5363,
"careful": 1239,
"codex": 9890,
"context-restore": 6287,
+2
View File
@@ -4,6 +4,8 @@
{ "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" },
{ "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" },
{ "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" },
{ "file": "scripts/modules-install.ts", "movedFrom": "(new: lite loadout install tree with identity-keyed reuse, GSTA-161)" },
{ "file": "lib/model-detect.ts", "movedFrom": "(new: explicit model to loadout detection, GSTA-161)" },
{ "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" },
{ "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" },
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
+44 -1
View File
@@ -50,7 +50,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) {
const log = path.join(base, 'calls.log');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stub, { recursive: true });
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) {
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
@@ -526,6 +526,49 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(fs.readFileSync(launch, 'utf8')).not.toContain('GSTACK_PREFERENCES_FILE');
});
test('--model: Opus 5.5 gets the lite SKILL_ROOT and its own launch file; another model, a failed render and no --model keep the verdict and its launch file', () => {
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" ;;',
' broken) echo "boom" >&2; exit 1 ;;',
' *) echo "LOADOUT: full model=none source=model-flag reason=unmapped"; echo "SKILL_ROOT: $full" ;;',
'esac',
]);
const launchOf = (out: string) => out.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)![1];
const opus = run(f, ['--for', 'ship', '--model', 'claude-opus-5-5']);
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=model-flag digest=0123456789ab reused=no\nSKILL_ROOT: ${f.home}/lite-0123456789ab\n`);
const [verdict] = verdicts(f);
const base = verdict.replace(/\.json$/, '.env');
const lite = launchOf(opus.stdout);
expect(lite).toBe(verdict.replace(/\.json$/, '.loadout-lite-0123456789ab.env'));
expect(fs.statSync(lite).mode & 0o777).toBe(0o600);
expect(fs.readFileSync(lite, 'utf8')).toBe(fs.readFileSync(base, 'utf8') + `export GSTACK_LOADOUT_SKILL_ROOT='${f.home}/lite-0123456789ab'\n`);
expect(fs.readFileSync(base, 'utf8')).not.toContain('GSTACK_LOADOUT_SKILL_ROOT');
expect(lastLine(opus.stdout)).toMatch(/^gstack: ok 1\.2\.3 /);
const sonnet = run(f, ['--for', 'ship', '--model', 'claude-sonnet-5-5']);
expect(sonnet.status).toBe(0);
expect(sonnet.stdout).toContain(`LOADOUT: full model=none source=model-flag reason=unmapped\nSKILL_ROOT: ${f.home}/.claude/skills\n`);
expect(launchOf(sonnet.stdout)).toBe(base);
const broken = run(f, ['--for', 'ship', '--model', 'broken']);
expect(broken.stdout).toContain(`LOADOUT_FALLBACK: boom; serving full (docs/troubleshooting.md#loadout-fallback)\nLOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${f.home}/.claude/skills\n`);
expect(launchOf(broken.stdout)).toBe(base);
const plain = run(f, ['--for', 'ship']);
expect(plain.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
expect(launchOf(plain.stdout)).toBe(base);
const fromEnv = run(f, ['--for', 'ship'], { GSTACK_MODEL: 'claude-opus-5-5' });
expect(fromEnv.status, fromEnv.stdout + fromEnv.stderr).toBe(0);
expect(launchOf(fromEnv.stdout)).toBe(lite);
expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`);
expect(verdicts(f)).toEqual([verdict]);
expect(fs.existsSync(lite)).toBe(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', () => {
const f = fixture();
const r = run(f, ['--for', 'autoplan']);
+66
View File
@@ -0,0 +1,66 @@
/**
* ./setup --loadout through the real ./setup on a default Claude install
* (GSTA-161, PR 1b). A non-full loadout is saved as claude_loadout and served
* from the untracked Claude render dir by setup's own gen:skill-docs render
* (the plan-mode-preamble path), so the checkout stays full. An explicit
* loadout is bound to no model, so its start lines carry no re-read rule. --loadout full serves the committed files again; an unknown name
* changes nothing. Fixture: test/helpers/install-fixture.ts.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { config, defaultRender, failableBun, fakeDetect, record, served, setup } from './helpers/claude-overlay-fixture';
import { ROOT, cleanupFixtures, cleanupSeed, makeFixture, makeSource } from './helpers/install-fixture';
import { readSkillStartup } from './helpers/skill-startup';
afterEach(cleanupFixtures);
afterAll(cleanupSeed);
const VOICE = '## Voice';
describe.skipIf(process.platform === 'win32')('./setup --loadout', () => {
test('lite renders the default install without the coaching pieces and keeps the checkout full; full serves the committed files; an unknown name changes nothing', () => {
const f = makeFixture();
failableBun(f);
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
fakeDetect(src);
const skills = join(f.home, '.claude/skills');
const committed = readSkillStartup(join(ROOT, 'autoplan/SKILL.md'));
expect(committed).toContain(VOICE);
const lite = setup(f, src, ['--loadout', 'lite']);
expect(lite.status, lite.out).toBe(0);
expect(lite.out).toContain('and the lite loadout');
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('lite');
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', render: 'user', loadout: 'lite' });
const startup = served(skills);
expect(startup).not.toContain(VOICE);
expect(startup).toContain('--skill "autoplan" --model "claude"\n');
expect(startup).not.toContain('--loadout');
expect(readSkillStartup(join(src, 'autoplan/SKILL.md'))).toBe(committed);
const again = setup(f, src);
expect(again.status, again.out).toBe(0);
expect(served(skills)).not.toContain(VOICE);
const unknown = setup(f, src, ['--loadout', 'nope']);
expect(unknown.status).toBe(1);
expect(unknown.out).toContain('Nothing was installed or changed');
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('lite');
const full = setup(f, src, ['--loadout', 'full']);
expect(full.status, full.out).toBe(0);
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('full');
expect(existsSync(defaultRender(f))).toBe(false);
expect(served(skills)).toBe(committed);
expect(readFileSync(join(src, 'SKILL.md'), 'utf8')).toBe(readFileSync(join(ROOT, 'SKILL.md'), 'utf8'));
}, 300_000);
test('gstack-config refuses a claude_loadout that is not a loadout name and stores full', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
const bad = config(f, src, 'set', 'claude_loadout', 'Bad Name');
expect(bad.stderr).toContain('claude_loadout must be a loadout name');
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('full');
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Loadout detection (GSTA-161, PR 1b): lib/model-detect.ts resolves a loadout
* from explicit inputs only, and gstack-skill-start prints a lite render's
* LOADOUT lines without executing or echoing a hostile model id.
*/
import { afterAll, 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 { resolveLoadout } from '../lib/model-detect';
import { ModuleError, loadLoadouts } from '../scripts/modules';
const ROOT = path.resolve(import.meta.dir, '..');
const manifest = loadLoadouts();
const resolve = (input: { loadout?: string; model?: string; env?: Record<string, string> }) => resolveLoadout({ env: {}, ...input, manifest });
describe('resolveLoadout', () => {
test('only Claude Opus 5.5 (bare, provider-prefixed, dated snapshot or -latest) maps to lite, bound to the base id', () => {
for (const model of ['claude-opus-5-5', 'anthropic/claude-opus-5-5', ' Claude-Opus-5-5[1m] ', 'claude-opus-5-5-20261101', 'claude-opus-5-5-latest']) {
expect(resolve({ model })).toEqual({ loadout: 'lite', source: 'model-flag', model: 'claude-opus-5-5' });
}
});
test('every other model, including near misses of the Opus 5.5 id, resolves to full and never errors', () => {
const near = ['claude-opus-5-6', 'claude-opus-5', 'claude-opus-55x', 'claude-opus-5-50', 'claude-opus-5-5x', 'claude-opus-5-5-1', 'claude-opus-5-5-2026110'];
const others = ['claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'gpt-6-astra', 'some-new-model', 'constructor', 'opus'];
for (const model of [...near, ...others]) expect(resolve({ model })).toEqual({ loadout: 'full', source: 'model-flag', model: null, reason: 'unmapped' });
});
test('an id that is not a plain model id resolves to full with a sanitized notice', () => {
for (const model of ['a$(touch /tmp/x)', "o'pus", 'claude\nopus', 'x'.repeat(200), 'us.anthropic.claude-opus-5-5:0', 'claude-opus-5-5@2026']) {
const r = resolve({ model });
expect(r).toMatchObject({ loadout: 'full', model: null, reason: 'invalid' });
expect(r.notice).toMatch(/^LOADOUT_INVALID_MODEL: ".{0,40}" is not a plain model id; using full/);
expect(r.notice).not.toContain('\n');
}
});
test('order: an explicit loadout (bound to no model), then --model, then GSTACK_MODEL, then full', () => {
expect(resolve({ loadout: 'lite', model: 'claude-sonnet-5-5' })).toEqual({ loadout: 'lite', source: 'loadout-flag', model: null });
expect(resolve({ loadout: 'full', model: 'claude-opus-5-5' })).toEqual({ loadout: 'full', source: 'loadout-flag', model: null });
expect(resolve({ model: 'claude-sonnet-5-5', env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toMatchObject({ loadout: 'full', source: 'model-flag' });
expect(resolve({ env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toEqual({ loadout: 'lite', source: 'GSTACK_MODEL', model: 'claude-opus-5-5' });
expect(resolve({ env: { GSTACK_LOADOUT: 'lite', GSTACK_MODEL: ' ' } })).toEqual({ loadout: 'full', source: 'default', model: null });
expect(resolve({})).toEqual({ loadout: 'full', source: 'default', model: null });
});
test('an unknown explicit loadout is refused; a manifest without a models map serves full', () => {
expect(() => resolve({ loadout: 'nope' })).toThrow(ModuleError);
expect(resolveLoadout({ model: 'claude-opus-5-5', env: {}, manifest: { ...manifest, models: undefined } })).toMatchObject({ loadout: 'full', reason: 'unmapped' });
});
});
describe('gstack-skill-start LOADOUT lines', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-start-'));
afterAll(() => fs.rmSync(home, { recursive: true, force: true }));
const start = (args: string[], env: Record<string, string> = {}) => {
const r = spawnSync('bash', [path.join(ROOT, 'bin/gstack-skill-start'), '--skill', 'qa', '--model', 'claude', ...args], {
cwd: home, encoding: 'utf-8', timeout: 60_000, env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_HOME: path.join(home, '.gstack'), ...env },
});
return r.stdout.split('\n').filter(l => l.startsWith('LOADOUT'));
};
const bound = ['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', '/opt/skills'];
const rule = 'LOADOUT_RULE: this file was rendered for claude-opus-5-5. If you are not running claude-opus-5-5, read /opt/skills/qa/SKILL.md now and follow it instead of this file.';
test('a model-bound render names its model and full root and says when to read the full file', () => {
expect(start(bound)).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
for (const running of ['claude-opus-5-5', 'Anthropic/Claude-Opus-5-5 [1m]', 'claude-opus-5-5-20261101']) expect(start(bound, { GSTACK_MODEL: running })).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
});
test('a different running model gets LOADOUT_MISMATCH; a hostile GSTACK_MODEL is neither run nor echoed', () => {
expect(start(bound, { GSTACK_MODEL: 'claude-sonnet-5-5' }).at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-sonnet-5-5: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
expect(start(bound, { GSTACK_MODEL: 'claude-opus-5-50' }).at(-1)).toContain('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-opus-5-50');
const marker = path.join(home, 'pwned');
const lines = start(bound, { GSTACK_MODEL: `$(touch ${marker})` });
expect(lines.at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs an invalid GSTACK_MODEL: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
expect(fs.existsSync(marker)).toBe(false);
});
test('a malformed bound start line points at the full file; a full or explicit render prints nothing', () => {
const malformed = 'LOADOUT: lite model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file.';
expect(start(['--loadout', 'lite'])).toEqual([malformed]);
expect(start(['--loadout', 'lite', '--loadout-model', 'x;id', '--full-root', '/opt/skills'])).toEqual([malformed]);
expect(start(['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', 'relative'])).toEqual([malformed]);
expect(start([])).toEqual([]);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* gstack-modules install (GSTA-161, PR 1b): an Opus 5.5 session gets a
* complete, immutable lite tree beside the full install; every other model
* gets the full root. Covers the round-2 review findings: the folder identity
* covers model, full root, selection and source tree; runtime assets resolve
* from the printed SKILL_ROOT; concurrent publishers of one identity agree;
* the committed checkout stays byte-identical.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { selectModules } from '../scripts/modules';
import { installLoadout, sourceTree } from '../scripts/modules-install';
import { LOCK_FILE } from '../scripts/modules-render';
const ROOT = path.resolve(import.meta.dir, '..');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-install-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const FULL_ROOT = '/opt/claude/skills';
const lite = selectModules({ loadout: 'lite' })!;
const run = (...args: string[]) => {
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
};
const digest = (file: string) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const committed = ['qa/SKILL.md', 'land-and-deploy/SKILL.md', 'SKILL.md'];
describe('gstack-modules install', () => {
test('two models on one machine: Opus 5.5 gets the published lite tree, Sonnet the full root; the checkout is untouched', () => {
const store = path.join(tmp, 'two-models');
const before = committed.map(rel => digest(path.join(ROOT, rel)));
const opus = run('install', '--model', 'anthropic/claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store);
expect(opus.code).toBe(0);
const [line, rootLine] = opus.stdout.trim().split('\n');
expect(line).toMatch(/^LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=[0-9a-f]{12} reused=no$/);
const root = rootLine.replace('SKILL_ROOT: ', '');
expect(path.dirname(root)).toBe(store);
const sonnet = run('install', '--model', 'claude-sonnet-5-5', '--full-root', FULL_ROOT, '--store', store);
expect(sonnet).toMatchObject({ code: 0, stdout: `LOADOUT: full model=none source=model-flag reason=unmapped\nSKILL_ROOT: ${FULL_ROOT}\n` });
expect(run('install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store).stdout).toContain(`reused=yes\nSKILL_ROOT: ${root}\n`);
expect(fs.readdirSync(store)).toEqual([path.basename(root)]);
expect(committed.map(rel => digest(path.join(ROOT, rel)))).toEqual(before);
expect(fs.readFileSync(path.join(ROOT, 'qa/SKILL.md'), 'utf-8')).not.toContain('--loadout');
}, 240_000);
test('the lite tree is a complete skill tree: lite sections, baked start lines, runtime assets and the router', async () => {
const { root, lock } = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store: path.join(tmp, 'tree') });
const qa = fs.readFileSync(path.join(root, 'qa/SKILL.md'), 'utf-8');
expect(qa).toContain(`gstack-skill-start --skill "qa" --model "claude" --loadout 'lite' --loadout-model 'claude-opus-5-5' --full-root '${FULL_ROOT}'`);
expect(qa).toContain(`${root}/qa/sections/`);
for (const rel of ['qa/templates/qa-report-template.md', 'qa/templates/functional-report-template.md', 'qa/references/issue-taxonomy.md', 'qa/sections/manifest.json', 'gstack/SKILL.md']) {
expect(fs.existsSync(path.join(root, rel))).toBe(true);
}
expect(fs.readFileSync(path.join(root, 'qa/templates/qa-report-template.md'))).toEqual(fs.readFileSync(path.join(ROOT, 'qa/templates/qa-report-template.md')));
expect(fs.existsSync(path.join(root, 'qa/SKILL.md.tmpl'))).toBe(false);
expect(fs.readFileSync(path.join(root, 'gstack/SKILL.md'))).toEqual(fs.readFileSync(path.join(root, 'SKILL.md')));
const landing = fs.readFileSync(path.join(root, 'land-and-deploy/SKILL.md'), 'utf-8');
const full = fs.readFileSync(path.join(ROOT, 'land-and-deploy/SKILL.md'), 'utf-8');
expect(landing.length).toBeLessThan(full.length);
expect(landing).toContain(`${FULL_ROOT}/gstack/bin/`);
expect(landing).not.toContain('~/.claude/skills/gstack/bin/');
expect(lock).toMatchObject({ kind: 'install', loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: FULL_ROOT });
expect(lock.excluded).toHaveLength(11);
expect(lock.excluded).not.toContain('preamble:upgrade-check');
expect(lock.excluded).not.toContain('preamble:brain-sync');
expect(JSON.parse(fs.readFileSync(path.join(root, LOCK_FILE), 'utf-8')).identity.source_tree).toBe(sourceTree(ROOT));
}, 240_000);
test('the identity covers the bound model and full root; an explicit lite is unbound', async () => {
const store = path.join(tmp, 'identity');
const a = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store });
const b = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: '/other/skills', store });
const c = await installLoadout({ selection: lite, boundModel: null, fullRoot: FULL_ROOT, store });
expect(new Set([a.root, b.root, c.root]).size).toBe(3);
const explicit = fs.readFileSync(path.join(c.root, 'qa/SKILL.md'), 'utf-8');
expect(explicit).toContain('--skill "qa" --model "claude"\n');
expect(explicit).not.toContain('--loadout');
}, 240_000);
test('concurrent publishers of one identity agree on one folder; a tampered folder is refused, not reused', async () => {
const store = path.join(tmp, 'race');
const [x, y] = await Promise.all([1, 2].map(() => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })));
expect(x.root).toBe(y.root);
expect(fs.readdirSync(store)).toEqual([path.basename(x.root)]);
fs.appendFileSync(path.join(x.root, 'qa/SKILL.md'), 'tampered\n');
await expect(installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })).rejects.toThrow(/RENDER_DEST_NOT_OWNED/);
}, 240_000);
test('pruning removes only its own folders past the three most recently used, and none used in the last day', async () => {
const store = path.join(tmp, 'prune');
const install = (fullRoot: string) => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot, store });
const age = (dir: string, days: number) => { const t = new Date(Date.now() - days * 86_400_000); fs.utimesSync(dir, t, t); };
const published = () => fs.readdirSync(store).filter(n => /^lite-[0-9a-f]{12}$/.test(n));
const inUse = await install('/r/0');
const oldest = await install('/r/1');
const old = await install('/r/2');
age(inUse.root, 3);
age(oldest.root, 3);
age(old.root, 2);
const foreign = path.join(store, 'lite-project');
fs.mkdirSync(foreign);
fs.writeFileSync(path.join(foreign, 'notes.txt'), 'not gstack\n');
age(foreign, 9);
expect((await install('/r/0')).reused).toBe(true);
const newest = await install('/r/3');
expect([inUse.root, old.root, newest.root].every(dir => fs.existsSync(dir))).toBe(true);
expect(fs.existsSync(oldest.root)).toBe(false);
expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf8')).toBe('not gstack\n');
await install('/r/4');
await install('/r/5');
expect(fs.existsSync(old.root)).toBe(false);
expect(fs.existsSync(inUse.root)).toBe(true);
expect(published()).toHaveLength(4);
}, 300_000);
test('the source tree identity changes with a commit and with uncommitted edits', () => {
const repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const git = (...args: string[]) => spawnSync('git', args, { cwd: repo, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } });
git('init', '-q');
fs.writeFileSync(path.join(repo, 'a.md'), 'one\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'one');
const clean = sourceTree(repo);
expect(clean).toMatch(/^[0-9a-f]{40}$/);
expect(sourceTree(repo)).toBe(clean);
fs.writeFileSync(path.join(repo, 'a.md'), 'two\n');
const dirty = sourceTree(repo);
expect(dirty.startsWith(`${clean}+`)).toBe(true);
fs.writeFileSync(path.join(repo, 'b.md'), 'new\n');
expect(sourceTree(repo)).not.toBe(dirty);
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'two');
expect(sourceTree(repo)).toMatch(/^[0-9a-f]{40}$/);
expect(sourceTree(repo)).not.toBe(clean);
expect(sourceTree(path.join(tmp, 'not-a-repo-at-all'))).toMatch(/^unpinned-/);
});
test('a lite render that fails serves full with LOADOUT_FALLBACK, exit 0', () => {
const store = path.join(tmp, 'blocked');
fs.writeFileSync(store, 'a file, not a directory\n');
const r = run('install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store);
expect(r).toMatchObject({ code: 0, stdout: `LOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${FULL_ROOT}\n` });
expect(r.stderr).toContain('LOADOUT_FALLBACK: ');
expect(run('install', '--model', 'claude-opus-5-5', '--full-root', 'relative/path')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
});
});
+42 -4
View File
@@ -1,8 +1,10 @@
/**
* Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived
* catalog, --modules selection, the render filter, and gstack-modules render.
* Every rung loads `full` today, so the synthetic ad-hoc arms below are the
* only exclusion coverage; committed bytes are covered by Skill Docs Freshness.
* Module loadouts (GSTA-140, PR 1a; GSTA-161, PR 1b): modules/loadouts.json,
* the derived catalog, --modules selection, the render filter, and
* gstack-modules render. Every rung loads `full`; the one named exclusion set
* is `lite`, mapped only from Claude Opus 5.5 (install and detection:
* test/module-loadouts-install.test.ts, test/model-detect.test.ts). Committed
* bytes are covered by Skill Docs Freshness.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
@@ -46,6 +48,24 @@ describe('modules/loadouts.json', () => {
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
});
test('lite drops the eleven coaching pieces, keeps upgrade-check and brain-sync, and only Opus 5.5 maps to it', () => {
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'evidence', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
expect(manifest.loadouts.lite).toMatchObject({ base: 'full', exclude: coaching.map(id => `preamble:${id}`) });
expect(manifest.models).toEqual({ 'claude-opus-5-5': 'lite' });
const s = selectModules({ loadout: 'lite' })!;
expect([...s.excluded].sort()).toEqual(coaching.map(id => `preamble:${id}`).sort());
for (const kept of ['preamble:upgrade-check', 'preamble:brain-sync', ...manifest.pinned]) expect(s.excluded.has(kept)).toBe(false);
});
test('a broken models map names each problem', () => {
const bad = structuredClone(manifest);
bad.models = { 'Claude-Opus*': 'lite', 'claude-opus-5-5': 'ghost', 'claude-opus-5-5-latest': 'lite' };
const problems = validateLoadouts(bad, catalog, CORPUS).join('\n');
expect(problems).toContain('models key "Claude-Opus*" must be an exact model id');
expect(problems).toContain('models.claude-opus-5-5 names no loadout');
expect(problems).toContain('models key claude-opus-5-5-latest is also matched by claude-opus-5-5');
});
test('the catalog derives every module kind from its source files', () => {
const ids = new Set(catalog.map(e => e.id));
expect(ids.has('skill:gstack')).toBe(true);
@@ -251,6 +271,11 @@ describe('modules/loadouts.schema.json', () => {
expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort());
const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern);
for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern);
const modelKey = new RegExp(schema.properties.models.propertyNames.pattern);
for (const [key, loadout] of Object.entries(raw.models as Record<string, string>)) {
expect(key).toMatch(modelKey);
expect(Object.keys(raw.loadouts)).toContain(loadout);
}
});
});
@@ -291,6 +316,19 @@ describe('bin/gstack-modules', () => {
expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before);
});
test('render --bind-model bakes the start lines an install for that model serves, and only for an exclusion loadout', () => {
const out = path.join(tmp, 'cli-bind');
const result = run('render', '--host', 'claude', '--loadout', 'lite', '--bind-model', 'anthropic/claude-opus-5-5', '--full-root', '/opt/full', '--out-dir', out);
expect(result.code).toBe(0);
expect(fs.readFileSync(path.join(out, 'qa/SKILL.md'), 'utf-8')).toContain(`--skill "qa" --model "claude" --loadout 'lite' --loadout-model 'claude-opus-5-5' --full-root '/opt/full'`);
expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8'))).toMatchObject({ loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: '/opt/full' });
const refused = path.join(tmp, 'cli-bind-refused');
for (const args of [['--loadout', 'full', '--bind-model', 'claude-opus-5-5', '--full-root', '/opt/full'], ['--loadout', 'lite', '--bind-model', 'claude-opus-5-5'], ['--loadout', 'lite', '--bind-model', 'a$(id)', '--full-root', '/opt/full'], ['--loadout', 'lite', '--bind-model', 'claude-opus-5-5', '--full-root', 'rel']]) {
expect(run('render', '--host', 'claude', ...args, '--out-dir', refused)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
}
expect(fs.existsSync(refused)).toBe(false);
});
test('render prints one RENDERED line and writes the lock', () => {
const out = path.join(tmp, 'cli-render');
const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);