mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(lease): name the double-crash limit of breaking a dead lock Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit89bf556c11) * test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit7f6c70459a) * test: the DRIVER template names the reviewer trailer relay (pregate lane) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit2545d9a6d2) * test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commitb509096218) * feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit9335e45de2) * test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commita7a974d268) * test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for field list 2 wave A Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.115.0 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>
203 lines
13 KiB
TypeScript
203 lines
13 KiB
TypeScript
/**
|
|
* Field list 2, W9: .gstack/eval-models.json and the lint gstack-eval-plan runs
|
|
* over every arm (arms, dry-run, pilot start, price-gate). gbrain's rules
|
|
* ("Fable in smoke only", "no gpt-5.4-mini", "the newest model of each family")
|
|
* as data; literal allow/deny independent of MODEL_CATALOG; unknown ids fail
|
|
* closed; the reviewed copy comes from origin/<base>.
|
|
*/
|
|
import { afterEach, beforeEach, 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 { formatEvalCalls } from '../lib/eval-plan';
|
|
import { lintArmModels, parseEvalModels, type ArmUse, type EvalModelRules } from '../lib/eval-models';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { capyHost, type CapyHost } from './helpers/capy-host';
|
|
|
|
const BIN = path.resolve(import.meta.dir, '..', 'bin', 'gstack-eval-plan');
|
|
const GBRAIN_RULES = {
|
|
schema_version: 1,
|
|
allow: ['claude-*', 'gpt-*'],
|
|
deny: ['gpt-5.4-mini'],
|
|
scopes: { full: { deny: ['claude-fable-*'] } },
|
|
families: {
|
|
'claude-opus': ['claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8'],
|
|
'claude-fable': ['claude-fable-5-1', 'claude-fable-5'],
|
|
'gpt-sol': ['gpt-6.1-sol', 'gpt-6-sol'],
|
|
},
|
|
};
|
|
|
|
function rules(doc: unknown): EvalModelRules {
|
|
const parsed = parseEvalModels(JSON.stringify(doc));
|
|
if ('errors' in parsed) throw new Error(parsed.errors.join('; '));
|
|
return parsed.rules;
|
|
}
|
|
const verdicts = (r: EvalModelRules, uses: Array<[string, ArmUse['scope']]>) => lintArmModels(r, uses.map(([model, scope]) => ({ model, scope, where: 't' }))).map(f => `${f.model}/${f.scope}: ${f.verdict} (${f.reason})`);
|
|
|
|
describe('lintArmModels', () => {
|
|
test('gbrain\'s rules: Fable smoke-only, gpt-5.4-mini denied in any form, older generations refused, unknown ids fail closed', () => {
|
|
expect(verdicts(rules(GBRAIN_RULES), [
|
|
['claude-fable-5-1', 'smoke'], ['claude-fable-5-1', 'full'], ['gpt-5.4-mini', 'smoke'], ['openai/gpt-5.4-mini', 'full'],
|
|
['claude-opus-4-8', 'full'], ['anthropic/claude-opus-5-5-20261001', 'full'], ['gpt-6.1-sol', 'full'], ['gemini-3.8-flash', 'smoke'], ['claude-haiku-5-5', 'full'],
|
|
])).toEqual([
|
|
'claude-fable-5-1/smoke: ok (allow claude-*; newest in families.claude-fable)',
|
|
'claude-fable-5-1/full: denied (scopes.full.deny claude-fable-*)',
|
|
'gpt-5.4-mini/smoke: denied (deny gpt-5.4-mini)',
|
|
'openai/gpt-5.4-mini/full: denied (deny gpt-5.4-mini)',
|
|
'claude-opus-4-8/full: old-generation (families.claude-opus newest is claude-opus-5-5)',
|
|
'anthropic/claude-opus-5-5-20261001/full: ok (allow claude-*; newest in families.claude-opus)',
|
|
'gpt-6.1-sol/full: ok (allow gpt-*; newest in families.gpt-sol)',
|
|
'gemini-3.8-flash/smoke: denied (matches no allow entry (unknown ids fail closed))',
|
|
'claude-haiku-5-5/full: generation-unresolved (in no families entry, so the newest-generation rule cannot be checked)',
|
|
]);
|
|
});
|
|
|
|
test('literal allow/deny ignore catalog membership: uncatalogued ids are allowed or denied by the file alone; scope allow lists narrow', () => {
|
|
const r = rules({ schema_version: 1, allow: ['voyage-4-large', 'claude-sonnet-5-5', 'claude-opus-5-5'], deny: ['claude-opus-5-5'], scopes: { smoke: { allow: ['claude-sonnet-*'] } } });
|
|
expect(verdicts(r, [['voyage-4-large', 'full'], ['claude-opus-5-5', 'full'], ['claude-sonnet-5-5', 'smoke'], ['voyage-4-large', 'smoke']])).toEqual([
|
|
'voyage-4-large/full: ok (allow voyage-4-large)',
|
|
'claude-opus-5-5/full: denied (deny claude-opus-5-5)',
|
|
'claude-sonnet-5-5/smoke: ok (allow claude-sonnet-5-5)',
|
|
'voyage-4-large/smoke: denied (matches no scopes.smoke.allow entry)',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('parseEvalModels', () => {
|
|
test('the file fails closed on a missing allow, unknown keys and scopes, globs in families, duplicates and a malformed id', () => {
|
|
const errors = (doc: unknown) => { const p = parseEvalModels(JSON.stringify(doc)); return 'errors' in p ? p.errors : []; };
|
|
expect(errors({ schema_version: 1 })).toEqual(['allow must be a non-empty array of model ids or globs']);
|
|
expect(errors({ schema_version: 2, allow: ['*'], newest: true })).toEqual(['unknown key "newest" (known: schema_version, allow, deny, scopes, families)', 'schema_version must be 1']);
|
|
expect(errors({ schema_version: 1, allow: ['*'], scopes: { release: { deny: [] }, smoke: { only: [] } } })).toEqual(['scopes.release: unknown eval scope (known: smoke, full)', 'scopes.smoke: unknown key(s) only (known: allow, deny)']);
|
|
expect(errors({ schema_version: 1, allow: ['*'], families: { a: ['claude-*'], b: ['x-1', 'x-2'], c: ['x-2'] } })).toEqual([
|
|
'families.a[0] "claude-*" is not a model id (family entries are literal ids, newest first)', 'families: x-2 is listed in both b and c',
|
|
]);
|
|
expect(errors({ schema_version: 1, allow: ['claude-[a-z]+'] })[0]).toContain('allow[0] "claude-[a-z]+" is not a model id or glob');
|
|
expect(parseEvalModels('{ allow: [] }')).toMatchObject({ errors: [expect.stringContaining('not valid JSON')] });
|
|
});
|
|
|
|
test('MODEL_CATALOG checks only currency: a family listing gstack\'s current smart model after an older id is stale', () => {
|
|
const p = parseEvalModels(JSON.stringify({ schema_version: 1, allow: ['*'], families: { 'claude-opus': ['claude-opus-5', 'claude-opus-5-5'] } }));
|
|
expect('errors' in p && p.errors).toEqual([expect.stringMatching(/^families\.claude-opus lists claude-opus-5 before claude-opus-5-5, gstack's current anthropic smart model \(lib\/model-catalog\.ts, verified \d{4}-\d{2}-\d{2}\); list newest first$/)]);
|
|
expect('rules' in parseEvalModels(JSON.stringify(GBRAIN_RULES))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('eval-plan/SKILL.md', () => {
|
|
test('names only result codes the table defines and flags the bin accepts for the diagnostics run and the model rules', () => {
|
|
const skill = fs.readFileSync(path.resolve(import.meta.dir, '..', 'eval-plan', 'SKILL.md'), 'utf8');
|
|
const codes = [...new Set([...skill.matchAll(/\b(PREREG_[A-Z_]+|EVAL_MODELS?_[A-Z_]+)\b/g)].map(m => m[1]!))];
|
|
expect(codes).toEqual(expect.arrayContaining(['PREREG_DIAGNOSTIC_UNTESTED', 'EVAL_MODEL_DENIED', 'EVAL_MODEL_OLD_GENERATION', 'EVAL_MODEL_GENERATION_UNRESOLVED']));
|
|
expect(codes.filter(c => !(c in RESULT_CODES) && !['PREREG_DIAGNOSTIC', 'EVAL_MODEL'].includes(c))).toEqual([]);
|
|
expect(skill).toContain('prereg --check <path> --allow-repo-commands');
|
|
expect(skill).toContain('--scope smoke');
|
|
const help = spawnSync(BIN, ['--help'], { encoding: 'utf8', timeout: 30_000 }).stdout;
|
|
expect(help).toContain('prereg --check <file> [--allow-repo-commands]');
|
|
expect(help).toContain('--scope smoke|full');
|
|
});
|
|
});
|
|
|
|
describe('gstack-eval-plan lints every arm (bin, fixture repo with an origin)', () => {
|
|
let host: CapyHost;
|
|
let repo: string;
|
|
const git = (...args: string[]) => {
|
|
const r = spawnSync('git', args, { cwd: repo, env: host.env, encoding: 'utf8', timeout: 30_000 });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
};
|
|
const run = (args: string[]) => {
|
|
const r = spawnSync(BIN, args, { cwd: repo, env: host.env, encoding: 'utf8', timeout: 60_000 });
|
|
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
|
};
|
|
function seed(doc: unknown | null) {
|
|
const origin = path.join(host.base, 'origin.git');
|
|
spawnSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { env: host.env, timeout: 30_000 });
|
|
repo = path.join(host.base, 'repo');
|
|
fs.mkdirSync(path.join(repo, '.gstack'), { recursive: true });
|
|
git('init', '-q', '-b', 'main');
|
|
git('remote', 'add', 'origin', origin);
|
|
fs.writeFileSync(path.join(repo, 'rows.jsonl'), JSON.stringify({ prompt: 'q1' }) + '\n');
|
|
fs.writeFileSync(path.join(repo, 'retry.txt'), 'Answer again in the required JSON shape.\n');
|
|
if (doc) fs.writeFileSync(path.join(repo, '.gstack', 'eval-models.json'), JSON.stringify(doc, null, 2) + '\n');
|
|
git('add', '-A'); git('commit', '-q', '-m', 'seed'); git('push', '-q', 'origin', 'main');
|
|
}
|
|
beforeEach(() => { host = capyHost(); });
|
|
afterEach(() => host.cleanup());
|
|
|
|
test('arms: the reviewed rules from origin/main refuse a denied arm; a working-tree edit does not loosen them unless --models-from head', () => {
|
|
seed(GBRAIN_RULES);
|
|
const r = run(['arms', '--arms', 'claude-opus-5-5,gpt-5.4-mini']);
|
|
expect(r.code).toBe(1);
|
|
expect(r.out).toContain('EVAL_MODEL: claude-opus-5-5 scope=full ok (allow claude-*; newest in families.claude-opus) where=flag:--arms');
|
|
expect(r.out).toContain('EVAL_MODEL: gpt-5.4-mini scope=full denied (deny gpt-5.4-mini) where=flag:--arms');
|
|
expect(r.out).toMatch(/EVAL_MODELS: fail refused=1\/2 source=origin\/main@[0-9a-f]{12}:\.gstack\/eval-models\.json/);
|
|
expect(r.err).toContain('(EVAL_MODEL_DENIED)');
|
|
fs.writeFileSync(path.join(repo, '.gstack', 'eval-models.json'), JSON.stringify({ ...GBRAIN_RULES, deny: [] }));
|
|
const edited = run(['arms', '--arms', 'gpt-5.4-mini']);
|
|
expect(edited.code).toBe(1);
|
|
expect(edited.out).toContain('EVAL_MODELS_NOTE: the working-tree .gstack/eval-models.json differs from the reviewed copy; linting with origin/main');
|
|
const head = run(['arms', '--arms', 'gpt-5.4-mini', '--models-from', 'head']);
|
|
expect(head.out).toContain('EVAL_MODEL: gpt-5.4-mini scope=full generation-unresolved');
|
|
expect(head.out).toContain('source=head (unreviewed) .gstack/eval-models.json');
|
|
expect(head.err).toContain('(EVAL_MODEL_GENERATION_UNRESOLVED)');
|
|
});
|
|
|
|
test('dry-run and pilot start: Fable fails the full scope (no reservation), passes --scope smoke; the dry-run record carries the check', () => {
|
|
seed(GBRAIN_RULES);
|
|
const out = path.join(host.base, 'out');
|
|
const base = ['dry-run', '--input', 'rows.jsonl', '--arms', 'claude-fable-5-1', '--retry-prompt', 'retry.txt', '--out', out];
|
|
const full = run(base);
|
|
expect(full.code).toBe(1);
|
|
expect(full.out).toContain('CHECK: model-rules refused claude-fable-5-1 (scope full, dry-run --arms): scopes.full.deny claude-fable-*');
|
|
expect(full.out).toContain('DRY_RUN_RESULT: fail');
|
|
const smoke = run([...base, '--scope', 'smoke']);
|
|
expect(smoke.code).toBe(0);
|
|
expect(smoke.out).toMatch(/CHECK: model-rules ok \(source origin\/main@[0-9a-f]{12}:\.gstack\/eval-models\.json\)/);
|
|
expect(JSON.parse(fs.readFileSync(path.join(out, 'dry-run.json'), 'utf8')).checks.model_rules).toMatchObject({ ok: true, refused: [] });
|
|
const pilotOut = path.join(host.base, 'pilot');
|
|
const pilot = run(['pilot', 'start', '--out', pilotOut, '--cap', '5', '--arms', 'claude-fable-5-1', '--dry-run', path.join(out, 'dry-run.json')]);
|
|
expect(pilot.code).toBe(1);
|
|
expect(pilot.err).toContain('(EVAL_MODEL_DENIED)');
|
|
expect(fs.existsSync(path.join(pilotOut, 'spend.json'))).toBe(false);
|
|
expect(run(['pilot', 'start', '--out', pilotOut, '--cap', '5', '--arms', 'claude-fable-5-1', '--dry-run', path.join(out, 'dry-run.json'), '--scope', 'smoke']).code).toBe(0);
|
|
expect(run([...base, '--scope', 'release']).code).toBe(2);
|
|
});
|
|
|
|
test('price-gate lints each call row at its own scope and writes no receipt on a refusal', () => {
|
|
seed(GBRAIN_RULES);
|
|
const head = git('rev-parse', 'HEAD');
|
|
const gate = 'bun run eval:smoke';
|
|
const plan = path.join(host.base, 'plan.calls');
|
|
const rows = [
|
|
{ purpose: 'smoke', model: 'claude-fable-5-1', calls: 3, in_tokens: 3_000, out_tokens: 1_000, max_out_tokens: 8_192, scope: 'smoke' as const },
|
|
{ purpose: 'judge', model: 'claude-opus-4-8', calls: 3, in_tokens: 3_000, out_tokens: 1_000, max_out_tokens: 2_048, scope: 'full' as const },
|
|
];
|
|
fs.writeFileSync(plan, formatEvalCalls({ gate, head, inputs: [], preconditions: [], rows }));
|
|
const receipts = path.join(host.base, 'receipts');
|
|
const r = run(['price-gate', '--gate', gate, '--plan-file', plan, '--out', receipts]);
|
|
expect(r.code).toBe(1);
|
|
expect(r.out).toContain('EVAL_MODEL: claude-fable-5-1 scope=smoke ok');
|
|
expect(r.out).toContain('EVAL_MODEL: claude-opus-4-8 scope=full old-generation (families.claude-opus newest is claude-opus-5-5) where=purpose=judge');
|
|
expect(r.err).toContain('(EVAL_MODEL_OLD_GENERATION)');
|
|
expect(fs.existsSync(receipts)).toBe(false);
|
|
fs.writeFileSync(plan, formatEvalCalls({ gate, head, inputs: [], preconditions: [], rows: [rows[0]!] }));
|
|
const ok = run(['price-gate', '--gate', gate, '--plan-file', plan, '--out', receipts]);
|
|
expect(ok.code).toBe(0);
|
|
expect(fs.readdirSync(receipts).length).toBe(1);
|
|
});
|
|
|
|
test('no rules file changes nothing; an invalid reviewed file fails EVAL_MODELS_INVALID for every arm', () => {
|
|
seed(null);
|
|
const none = run(['arms', '--arms', 'gpt-5.4-mini']);
|
|
expect(none.code).toBe(0);
|
|
expect(none.out).toMatch(/EVAL_MODELS: none at origin\/main@[0-9a-f]{12}\n/);
|
|
fs.writeFileSync(path.join(repo, '.gstack', 'eval-models.json'), JSON.stringify({ schema_version: 1, allow: ['claude-*'], deny: 'gpt-5.4-mini' }));
|
|
git('add', '-A'); git('commit', '-q', '-m', 'rules'); git('push', '-q', 'origin', 'main');
|
|
const invalid = run(['arms', '--arms', 'claude-opus-5-5']);
|
|
expect(invalid.code).toBe(1);
|
|
expect(invalid.out).toMatch(/EVAL_MODELS: invalid source=origin\/main@/);
|
|
expect(invalid.err).toContain('deny must be an array of model ids or globs');
|
|
expect(invalid.err).toContain('(EVAL_MODELS_INVALID)');
|
|
});
|
|
});
|