v1.93.1.0 stamp: merge main 9d9d4c31bb into #3865 (land)

This commit is contained in:
garrytan committed 2026-11-02 14:50:07 +00:00
commit da34529dae
155 files changed
+7490 -703

No files matched your search

+4 -1
View File
@@ -138,6 +138,9 @@ describe('Aside driver contract ({{ASIDE_SETUP}})', () => {
test('cookbook uses only the verified Aside APIs', () => {
expect(section).toContain('Page.addScriptToEvaluateOnNewDocument');
expect(section).toContain('Emulation.setDeviceMetricsOverride');
// Touch and phone recipe: verified on Garry's Mac in Aside 1.26 (GSTA-227 F3, #3058).
expect(section).toContain('Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 }');
expect(section).toContain('Input.dispatchTouchEvent');
expect(section).toContain('annotatedScreenshot(pg)');
expect(section).toContain('snapshot(pg, { interactive: true })');
// Verified NOT to exist or NOT to persist across CLI calls — must never be recommended.
@@ -470,7 +473,7 @@ describe('web research ({{ASIDE_RESEARCH}})', () => {
// CSO's private startup keeps advisory queries inside its stricter public-ID
// policy and intentionally does not import the generic Aside research block.
expect(carriers).not.toContain('cso');
expect(carriers).toEqual(expect.arrayContaining(['design-consultation', 'investigate', 'office-hours', 'plan-ceo-review', 'plan-devex-review', 'plan-eng-review', 'review']));
expect(carriers).toEqual(expect.arrayContaining(['design-consultation', 'office-hours', 'plan-ceo-review', 'plan-devex-review', 'plan-eng-review', 'review']));
for (const skill of carriers) {
const md = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
expect({ skill, count: md.split('## Web research runs in Aside').length - 1 }).toEqual({ skill, count: 1 });
+2 -1
View File
@@ -111,7 +111,8 @@ test.each([
expect(facts.receipts.filter((receipt: any) => receipt.outcome === 'timeout')).toHaveLength(3 - scenario.passed);
expect(facts.receipts.every((receipt: any) => receipt.timeoutMs === 240_000 && !receipt.answered)).toBe(true);
expect(facts.active).toBe(0); expect(facts.answers).toBe(0); expect(facts.pendingTimers).toBe(0);
expect(facts.leftovers).toEqual([]); expect(facts.ownedStateLeftovers).toEqual([]);
expect(facts.leftovers).toEqual([]);
expect(facts.ownedStateLeftovers, JSON.stringify({ fixtures: facts.fixtures, inputs: facts.inputs.map((input: any) => [input.id, input.config, input.state]), events: facts.events.filter((event: any) => ['query-close', 'capture-settle', 'cleanup'].includes(event.kind)) })).toEqual([]);
expect(facts.events.filter((event: any) => event.kind === 'query-close')).toHaveLength(scenario.queries);
expect(facts.events.filter((event: any) => event.kind === 'cleanup')).toHaveLength(3);
}, 20_000);
+3 -1
View File
@@ -1326,7 +1326,9 @@ describe('Autoplan phase entry through the gbrain :user render (#2569)', () => {
edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot)));
return root;
}
function withEnv<T>(env: Record<string, string | undefined>, work: () => T): T {
/** The state-root chain the case does not name is cleared, so an ambient GSTACK_STATE_ROOT (every gstack launch file exports one) cannot pick the root. */
function withEnv<T>(given: Record<string, string | undefined>, work: () => T): T {
const env = { GSTACK_STATE_ROOT: undefined, GSTACK_STATE_DIR: undefined, CLAUDE_PLUGIN_DATA: undefined, ...given };
const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
try { return work(); }
+426
View File
@@ -0,0 +1,426 @@
/**
* GST-537 PR 3, shadow mode through the driver (bin/gstack-autoplan,
* lib/autoplan-shadow.ts): every gate writer records the shadow before it
* prints GATE, the lock is not held during the call, a live lease makes a
* second writer pay nothing and answer wait, a crashed call settles unknown
* (never released) and answer records the revision retrospectively, the
* checkpoint taken at the gate holds the record, owner provenance comes from
* the owner's own words, and the reader exports answered revisions only.
* Model calls go to test/fixtures/shadow-fake-model.ts: never a real network call.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { releaseUndispatched } from '../lib/autoplan-budget';
import { readState } from '../lib/autoplan-state';
import { readLedger } from '../lib/spend-ledger';
import { AUTOPLAN, ROOT, SMALL_PLAN, env as baseEnv, journal, readRun, review } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-shadow-drv-'));
const FAKE = path.join(ROOT, 'test', 'fixtures', 'shadow-fake-model.ts');
const servers: Array<{ kill: (sig?: number) => void }> = [];
afterAll(() => { for (const s of servers) s.kill(); fs.rmSync(TMP, { recursive: true, force: true }); });
const rows = (file: string) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
const record = (out: string, rev = 1) => path.join(out, 'shadow', `ceo-only.r${rev}.jsonl`);
const events = (out: string) => journal(out).map(r => r.event);
interface Fake { env: Record<string, string>; requests: () => any[]; receipts: () => any[] }
async function fake(name: string, script: Array<Record<string, unknown>>, extra: Record<string, string> = {}): Promise<Fake> {
const dir = path.join(TMP, `fake-${name}`);
fs.mkdirSync(path.join(dir, 'state'), { recursive: true });
fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify(script));
servers.push(Bun.spawn(['bun', FAKE, dir], { stdout: 'ignore', stderr: 'inherit' }));
for (let i = 0; i < 200 && !fs.existsSync(path.join(dir, 'port')); i++) await Bun.sleep(25);
const port = fs.readFileSync(path.join(dir, 'port'), 'utf8');
const env = baseEnv({ GSTACK_STATE_ROOT: path.join(dir, 'state'), GSTACK_CAPY: '', GSTACK_AUTOPLAN_SHADOW: '1', ANTHROPIC_API_KEY: 'sk-fake-anthropic', OPENAI_API_KEY: 'sk-fake-openai',
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, OPENAI_BASE_URL: `http://127.0.0.1:${port}/v1`, ...extra });
return { env, requests: () => rows(path.join(dir, 'requests.jsonl')), receipts: () => rows(path.join(dir, 'state', 'security', 'egress.jsonl')) };
}
const ap = (args: string[], e: Record<string, string>) => spawnSync('bun', [AUTOPLAN, ...args], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 120_000 });
const apAsync = (args: string[], e: Record<string, string>) => Bun.spawn(['bun', AUTOPLAN, ...args], { cwd: ROOT, env: e, stdout: 'pipe', stderr: 'pipe' });
function ok(r: { status: number | null; stdout: string; stderr: string }, what: string) { if (r.status !== 0) throw new Error(`${what} failed (${r.status}): ${r.stderr}\n${r.stdout}`); return r; }
function start(name: string, e: Record<string, string>, extra: string[] = []) {
const dir = path.join(TMP, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
const out = path.join(dir, 'run');
const first = ok(ap(['next', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', ...extra], e), 'first next');
return { out, first };
}
const attempt = (out: string, phase: string, voice: string) => readRun(out).phases[phase][voice];
const resultFile = (out: string, phase: string, voice: string) => attempt(out, phase, voice).attempts.at(-1).result;
/** next, both fixture reviews, the native submit; returns the outside submit's argv so a test can run it its own way. */
function toOutsideSubmit(out: string, phase: string, e: Record<string, string>): string[] {
ok(ap(['next', '--out', out], e), `next ${phase}`);
for (const voice of ['native', 'outside']) ok(review(path.join(out, `${phase}-${voice}-prompt.md`)), `review ${voice}`);
ok(ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultFile(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', attempt(out, phase, 'native').attempt, '--runner', 'host-subagent'], e), 'submit native');
return ['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultFile(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', attempt(out, phase, 'outside').attempt, '--runner', 'host-subagent'];
}
const phase = (out: string, name: string, e: Record<string, string>) => ok(ap(toOutsideSubmit(out, name, e), e), `submit outside ${name}`);
const lineIndex = (stdout: string, prefix: string) => stdout.split('\n').findIndex(l => l.startsWith(prefix));
const exported = (args: string[], e: Record<string, string>) => ap(['shadow', ...args, '--json'], e);
describe('submit records the shadow before GATE; the record stays private and checkpointed', async () => {
const f = await fake('main', [{ mode: 'valid' }]);
const ckpt = path.join(TMP, 'ckpt-main');
const e = { ...f.env, GSTACK_AUTOPLAN_CHECKPOINT_DIR: ckpt };
const { out, first } = start('main', e);
phase(out, 'ceo', e);
const gate = phase(out, 'eng', e);
const shadowRows = rows(record(out));
test('init prints SHADOW: on and stores the switch in run.json and the init row', () => {
expect(first.stdout).toContain('SHADOW: on model=claude-opus-5-5 (GSTACK_AUTOPLAN_SHADOW=1)');
expect(readRun(out).shadow).toEqual({ enabled: true, model: 'claude-opus-5-5', source: 'env' });
expect(journal(out).find(r => r.event === 'init').shadow.enabled).toBe(true);
});
test('SHADOW: recorded prints before GATE, and GATE_SPEND shows the shadow spend', () => {
expect(lineIndex(gate.stdout, 'SHADOW: recorded model=claude-opus-5-5 usd=0.0140')).toBeGreaterThan(-1);
expect(lineIndex(gate.stdout, 'SHADOW: ')).toBeLessThan(lineIndex(gate.stdout, 'GATE: gate_rev=1'));
expect(gate.stdout).toMatch(/GATE_SPEND: .* attempts=4 shadow=0\.0140/);
const ev = events(out);
expect(ev.indexOf('gate_written')).toBeLessThan(ev.indexOf('shadow_pending'));
expect(ev.indexOf('shadow_pending')).toBeLessThan(ev.indexOf('shadow_dispatched'));
expect(ev.indexOf('shadow_dispatched')).toBeLessThan(ev.indexOf('shadow_recorded'));
expect(f.requests().length).toBe(1);
});
test('one row per item with the frozen item, the rule pick, eligibility, class and hashes', () => {
expect(shadowRows.map(r => [r.item, r.item_class, r.ceo_eligible, r.rule_pick, r.status, r.retrospective])).toEqual([
['uc1', 'challenge2', true, 'a', 'recorded', false], ['d1', 'finding', false, null, 'recorded', false], ['d2', 'finding', false, null, 'recorded', false],
['uc2', 'challenge2', false, 'a', 'recorded', false], ['d3', 'finding', false, null, 'recorded', false], ['d4', 'finding', false, null, 'recorded', false], ['p1', 'p1', true, 'a', 'recorded', false]]);
expect(shadowRows[0].decision).toBe(`${readRun(out).run}-uc1`);
expect(shadowRows[0].item_frozen.options.map((o: { key: string }) => o.key)).toEqual(['a', 'b']);
expect(typeof shadowRows[0].audit).toBe('boolean');
});
test('a finding item is never ceo_eligible and has no rule pick: a CEO-only run leaves an open Critical or High finding to the owner', () => {
const findings = shadowRows.filter(r => r.item_class === 'finding');
expect(findings.map(r => [r.item, r.phase])).toEqual([['d1', 'ceo'], ['d2', 'ceo'], ['d3', 'eng'], ['d4', 'eng']]);
expect(findings.every(r => r.ceo_eligible === false && r.rule_pick === null)).toBe(true);
});
test('exactly one egress receipt per send, with the shadow sink and consent', () => {
expect(f.receipts().map(r => [r.sink, r.consent])).toEqual([['autoplan-shadow', 'autoplan_shadow on']]);
});
test('the checkpoint taken at the gate holds the shadow record; a restore keeps it byte for byte', () => {
const collection = path.join(ckpt, readRun(out).run);
const current = JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8'));
const gen = path.join(collection, 'generations', current.generation, 'run', 'shadow', 'ceo-only.r1.jsonl');
expect(fs.readFileSync(gen).equals(fs.readFileSync(record(out)))).toBe(true);
const restored = path.join(TMP, 'main-restored');
ok(ap(['resume', '--out', restored, '--from', collection], e), 'resume --from');
expect(fs.readFileSync(record(restored)).equals(fs.readFileSync(record(out)))).toBe(true);
expect(f.requests().length).toBe(1);
});
test('the gate render, owner brief, decisions and public export carry no pick, reason or salt', () => {
const salt = fs.readFileSync(path.join(out, 'shadow', 'salt'), 'utf8').trim();
const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(out, 'gate.json'), '--all'], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
const brief = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
expect(render.status).toBe(0);
for (const text of [render.stdout, brief.stdout, fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'), fs.readFileSync(path.join(out, 'gate.json'), 'utf8')]) {
expect(text).not.toContain('fake prediction');
expect(text).not.toContain('confidence');
expect(text).not.toContain(salt);
}
const pub = path.join(TMP, 'main-public');
ok(ap(['export', '--out', out, '--public', pub], e), 'export --public');
const all = fs.readdirSync(pub, { recursive: true }).map(String);
expect(all.some(p => p.includes('shadow'))).toBe(false);
for (const p of all) if (fs.statSync(path.join(pub, p)).isFile()) expect(fs.readFileSync(path.join(pub, p), 'utf8')).not.toContain(salt);
expect(readRun(out).salt).toBeUndefined();
});
test('gstack-artifact validate checks the record against its journaled sha256', () => {
const validate = () => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
expect(validate().status).toBe(0);
const good = fs.readFileSync(record(out), 'utf8');
fs.writeFileSync(record(out), good.replace('"pick":"a"', '"pick":"b"'));
const bad = validate();
expect(bad.status).toBe(1);
expect(bad.stderr).toContain('(ARTIFACT_STALE)');
expect(bad.stderr).toContain('shadow/ceo-only.r1.jsonl');
fs.rmSync(record(out));
const missing = validate();
expect(missing.status).toBe(1);
expect(missing.stderr).toContain('(ARTIFACT_STALE)');
expect(missing.stderr).toContain('shadow/ceo-only.r1.jsonl');
fs.writeFileSync(record(out), good);
});
test('printing the gate again pays nothing', () => {
const again = ok(ap(['next', '--out', out], e), 'next again');
expect(again.stdout).toContain('GATE: gate_rev=1');
expect(again.stdout).not.toContain('SHADOW:');
expect(f.requests().length).toBe(1);
});
test('the reader refuses an unanswered revision', () => {
const r = exported(['--out', out], e);
expect(r.status).toBe(3);
expect(r.stderr).toContain('(SHADOW_BEFORE_ANSWER)');
});
test('"Do it" relayed as tokens records owner_mode rec, owner_named false, the rec_hash and the agent role', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a d1a d2a uc2a d3a d4a p1b', '--by', 'agent:capy', '--owner-reply', 'Do it'], e), 'answer');
const d = rows(path.join(out, 'decisions.jsonl'));
const gateRec = shadowRows[0].rec_hash;
expect(d.map(r => [r.label, r.owner_mode, r.owner_named, r.answered_role, r.rec_hash === gateRec, r.answered_by])).toEqual([
['UC1', 'rec', false, 'agent', true, 'agent:capy'], ['D1', 'rec', false, 'agent', true, 'agent:capy'], ['D2', 'rec', false, 'agent', true, 'agent:capy'],
['UC2', 'rec', false, 'agent', true, 'agent:capy'], ['D3', 'rec', false, 'agent', true, 'agent:capy'], ['D4', 'rec', false, 'agent', true, 'agent:capy'], ['P1', 'rec', false, 'agent', true, 'agent:capy']]);
});
test('the reader exports the answered revision with the owner provenance', () => {
const r = exported(['--out', out], e);
expect(r.status).toBe(0);
const x = JSON.parse(r.stdout);
expect(x.rows.map((row: any) => [row.item, row.status, row.shadow.pick, row.owner.chosen, row.owner.owner_mode, row.owner.rec_hash_at_answer === row.owner.rec_hash_at_record])).toEqual([
['uc1', 'recorded', 'a', 'a', 'rec', true], ['d1', 'recorded', 'a', 'a', 'rec', true], ['d2', 'recorded', 'a', 'a', 'rec', true],
['uc2', 'recorded', 'a', 'a', 'rec', true], ['d3', 'recorded', 'a', 'a', 'rec', true], ['d4', 'recorded', 'a', 'a', 'rec', true], ['p1', 'recorded', 'a', 'b', 'rec', true]]);
expect(x.counts.status).toEqual({ recorded: 7 });
expect(x.counts.ceo_eligible).toEqual({ true: 2, false: 5 });
});
});
describe('a failed shadow never blocks: the gate prints and answers; owner words name items', async () => {
const f = await fake('fail', [{ mode: 'http500' }]);
const e = f.env;
const { out } = start('fail', e);
phase(out, 'ceo', e);
const gate = phase(out, 'eng', e);
test('a 500 journals shadow_failed http, prints SHADOW: failed http, and still prints the gate', () => {
expect(gate.stdout).toContain('SHADOW: failed http');
expect(gate.stdout).toContain('GATE: gate_rev=1');
expect(journal(out).find(r => r.event === 'shadow_failed')).toMatchObject({ gate_rev: 1, code: 'http' });
expect(fs.existsSync(record(out))).toBe(false);
});
test('owner words "uc1a" record tokens and owner_named true for uc1 only', () => {
const r = ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a', '--by', 'agent:capy', '--owner-reply', 'uc1a'], e), 'answer uc1a');
expect(r.stdout).toContain('GATE_PENDING:');
const uc1 = rows(path.join(out, 'decisions.jsonl')).find(r => r.label === 'UC1');
expect(uc1).toMatchObject({ owner_mode: 'tokens', owner_named: true, answered_role: 'agent' });
expect(rows(path.join(out, 'decisions.jsonl')).find(r => r.label === 'UC2').owner_mode).toBeUndefined();
});
test('an interrogated plan approval is refused by the reader', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'p1c', '--by', 'human'], e), 'answer p1c');
const r = exported(['--out', out], e);
expect(r.status).toBe(3);
expect(r.stderr).toContain('answered c (interrogate)');
});
test('an unparsed owner reply records owner_mode unparsed; the reader shows failed with its code', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc2a p1a', '--by', 'agent:capy', '--owner-reply', 'sounds good to me'], e), 'answer');
const d = rows(path.join(out, 'decisions.jsonl'));
expect(d.find(r => r.label === 'UC2')).toMatchObject({ owner_mode: 'unparsed', owner_named: false, owner_reply_parsed: false });
const x = JSON.parse(exported(['--out', out], e).stdout);
expect(x.rows.map((row: any) => [row.item, row.status, row.code])).toEqual([['uc1', 'failed', 'http'], ['d1', 'failed', 'http'], ['d2', 'failed', 'http'], ['uc2', 'failed', 'http'], ['d3', 'failed', 'http'], ['d4', 'failed', 'http'], ['p1', 'failed', 'http']]);
});
});
describe('the lease: the lock is free during the call, a second writer pays nothing, answer waits', async () => {
const f = await fake('lease', [{ mode: 'valid', delay_ms: 2500 }]);
const e = f.env;
const { out } = start('lease', e);
phase(out, 'ceo', e);
const writer = apAsync(toOutsideSubmit(out, 'eng', e), e);
for (let i = 0; i < 400 && !fs.existsSync(path.join(out, 'shadow', 'r1.pending')); i++) await Bun.sleep(25);
const lease = JSON.parse(fs.readFileSync(path.join(out, 'shadow', 'r1.pending'), 'utf8'));
const second = ap(['next', '--out', out], e);
const answer = apAsync(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e);
const [writerCode, answerCode] = await Promise.all([writer.exited, answer.exited]);
const writerOut = await new Response(writer.stdout).text();
test('the lease names the writer, and the run lock is free while it calls', () => {
expect(lease).toMatchObject({ gate_rev: 1, pid: writer.pid, host: os.hostname(), retrospective: false });
expect(second.status).toBe(0);
});
test('a second writer during the call prints SHADOW: pending and pays nothing; two writers pay once', () => {
expect(second.stdout).toContain('SHADOW: pending (gate_rev 1 is being recorded by pid');
expect(f.requests().length).toBe(1);
expect(writerCode).toBe(0);
expect(writerOut).toContain('SHADOW: recorded');
});
test('answer waits for the record, then answers; the record is prospective', () => {
expect(answerCode).toBe(0);
const ev = events(out);
expect(ev.indexOf('shadow_recorded')).toBeLessThan(ev.indexOf('answered'));
expect(rows(record(out)).every(r => r.retrospective === false)).toBe(true);
expect(readRun(out).status).toBe('complete');
});
});
describe('a crash mid-call: the dispatched call settles unknown and answer records the gate retrospectively', async () => {
const f = await fake('crash', [{ mode: 'valid', delay_ms: 30000 }, { mode: 'valid' }]);
const e = f.env;
const { out } = start('crash', e);
phase(out, 'ceo', e);
const writer = apAsync(toOutsideSubmit(out, 'eng', e), e);
for (let i = 0; i < 400 && !(events(out).includes('shadow_dispatched') && f.requests().length > 0); i++) await Bun.sleep(25);
writer.kill(9);
await writer.exited;
const ledgerBefore = readLedger(path.join(out, 'spend.json'));
const released = releaseUndispatched(readState(out));
const answer = ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e);
const ledger = readLedger(path.join(out, 'spend.json'));
test('a dispatched shadow call is never released as undispatched', () => {
expect(ledgerBefore.reserved.map(r => [r.attempt, r.label])).toEqual([['shadow-r1-1', 'shadow']]);
expect(released).toEqual([]);
});
test('answer settles the dead call unknown, records every row retrospective, then answers', () => {
expect(answer.status).toBe(0);
expect(answer.stdout).toMatch(/SHADOW: recorded model=claude-opus-5-5 usd=0\.0140 retrospective/);
expect(ledger.settled.find(s => s.attempt === 'shadow-r1-1')).toMatchObject({ usd: 'unknown', label: 'shadow' });
expect(ledger.released.some(r => r.attempt === 'shadow-r1-1')).toBe(false);
expect(journal(out).find(r => r.event === 'shadow_expired').settled_unknown).toEqual(['shadow-r1-1']);
expect(rows(record(out)).every(r => r.retrospective === true)).toBe(true);
expect(f.requests().length).toBe(2);
});
test('the reader labels those rows retrospective', () => {
const x = JSON.parse(exported(['--out', out], e).stdout);
expect(x.counts.status).toEqual({ retrospective: 7 });
});
});
describe('every gate writer records the shadow before it prints GATE', async () => {
const f = await fake('writers', [{ mode: 'valid' }]);
const off = baseEnv({ GSTACK_CAPY: '' });
/** A gate written with shadow off, then switched on: the next command that prints it records the shadow first. */
const unshadowed = (name: string) => {
const { out } = start(name, off);
phase(out, 'ceo', off); phase(out, 'eng', off);
const run = readRun(out);
run.shadow = { enabled: true, model: 'claude-opus-5-5', source: 'env' };
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
return out;
};
for (const verb of ['next', 'resume'] as const) test(`${verb}`, () => {
const out = unshadowed(`writer-${verb}`);
const r = ok(ap([verb, '--out', out], f.env), verb);
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeGreaterThan(-1);
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeLessThan(lineIndex(r.stdout, 'GATE: gate_rev=1'));
expect(fs.existsSync(record(out))).toBe(true);
});
test('close --deferred (and a malformed item records malformed)', async () => {
const g = await fake('close', [{ mode: 'bad_option' }, { mode: 'bad_option' }]);
const { out } = start('writer-close', g.env);
phase(out, 'ceo', g.env);
ok(ap(['next', '--out', out], g.env), 'next eng');
const r = ok(ap(['close', '--out', out, '--phase', 'eng', '--deferred', 'out of time', '--by', 'human'], g.env), 'close');
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeLessThan(lineIndex(r.stdout, 'GATE: gate_rev=1'));
expect(rows(record(out)).map(x => [x.item, x.item_class, x.status])).toEqual([['uc1', 'challenge2', 'malformed'], ['d1', 'finding', 'recorded'], ['d2', 'finding', 'recorded'], ['d3', 'cap_deferred', 'recorded'], ['p1', 'p1', 'recorded']]);
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', '--rec-hash', rows(record(out))[0].rec_hash], g.env), 'answer');
closeOut = out;
});
test('submit, revise, check and run share the same publish step (source ratchet; submit runs above)', () => {
const bin = fs.readFileSync(AUTOPLAN, 'utf8');
for (const verb of ['next', 'submit', 'revise', 'close', 'check']) expect(bin).toMatch(new RegExp(`if \\(cmd === '${verb}'\\) return await publish\\(out, json, mutating\\(`));
expect(bin).toContain('return await publish(out, json, withRunLock(out, () => {\n const r = resumeRun(out);');
const gateLineCalls = bin.split('\n').filter(l => /gateLines\((state|at), out\)/.test(l) && !l.includes('function gateLines'));
expect(gateLineCalls.length).toBe(2);
expect(gateLineCalls.every(l => l.includes('line ? [line] : []'))).toBe(true);
expect(bin).toContain("const step = withRunLock(out, () => { const r = doNext(out, rest, price); return r.gate ? claimed(out, r) : r; });");
});
});
let closeOut = '';
describe('the record: once per revision, a reopen writes r2, temp files and symlinks', async () => {
const f = await fake('reopen', [{ mode: 'valid' }]);
const e = f.env;
const { out } = start('reopen', e);
phase(out, 'ceo', e); phase(out, 'eng', e);
const r1 = fs.readFileSync(record(out));
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1d', '--by', 'human'], e), 'answer p1d');
const orphan = path.join(out, 'shadow', 'ceo-only.r2.jsonl.99999.dead.tmp');
fs.writeFileSync(orphan, 'partial');
phase(out, 'ceo', e);
const gate2 = phase(out, 'eng', e);
test('a reopen writes r2 and leaves r1 byte-identical; the orphan temp file is removed', () => {
expect(gate2.stdout).toContain('GATE: gate_rev=2');
expect(fs.readFileSync(record(out)).equals(r1)).toBe(true);
expect(rows(record(out, 2)).map(r => r.decision)).toEqual(rows(record(out, 2)).map(r => `${readRun(out).run}-r2-${r.item}`));
expect(fs.existsSync(orphan)).toBe(false);
expect(f.requests().length).toBe(2);
});
test('re-recording keeps the audit marks: the same salt and decision ids mark the same rows', () => {
const salt = fs.readFileSync(path.join(out, 'shadow', 'salt'), 'utf8').trim();
for (const r of [...rows(record(out)), ...rows(record(out, 2))]) {
const mark = BigInt('0x' + new Bun.CryptoHasher('sha256').update(salt + r.decision).digest('hex')) % 1000n < 100n;
expect(r.audit).toBe(r.action === 'decide' && mark);
}
});
test('a symlinked shadow folder is refused, nothing is written through it, and the gate still prints', async () => {
const g = await fake('symlink', [{ mode: 'valid' }]);
const { out: o2 } = start('symlink', g.env);
const target = path.join(TMP, 'symlink-target');
fs.mkdirSync(target);
fs.symlinkSync(target, path.join(o2, 'shadow'));
phase(o2, 'ceo', g.env);
const r = phase(o2, 'eng', g.env);
expect(r.stdout).toContain('SHADOW: failed unsafe_dir');
expect(r.stdout).toContain('GATE: gate_rev=1');
expect(fs.readdirSync(target)).toEqual([]);
expect(g.requests().length).toBe(0);
});
});
describe('the switch through the driver, and the spend cap', async () => {
const f = await fake('switch', [{ mode: 'valid' }]);
test('off by default: no SHADOW line, run.json says off, and no shadow folder at the gate', () => {
const e = { ...f.env, GSTACK_AUTOPLAN_SHADOW: '' };
const { out, first } = start('switch-default', e);
expect(first.stdout).not.toContain('SHADOW:');
expect(readRun(out).shadow).toEqual({ enabled: false, reason: 'off by default', source: 'default' });
phase(out, 'ceo', e);
expect(phase(out, 'eng', e).stdout).not.toContain('SHADOW:');
expect(fs.existsSync(path.join(out, 'shadow'))).toBe(false);
});
test('the env var wins over config; --no-outside gives off with its reason; resume cannot flip it', () => {
fs.writeFileSync(path.join(f.env.GSTACK_STATE_ROOT!, 'config.yaml'), 'autoplan_shadow: on\n');
const { out: offRun } = start('switch-env', { ...f.env, GSTACK_AUTOPLAN_SHADOW: '0' });
expect(readRun(offRun).shadow).toMatchObject({ enabled: false, source: 'env' });
const { out: cfgRun, first } = start('switch-config', { ...f.env, GSTACK_AUTOPLAN_SHADOW: '' });
expect(first.stdout).toContain('SHADOW: on model=claude-opus-5-5 (autoplan_shadow on)');
ok(ap(['resume', '--out', cfgRun], { ...f.env, GSTACK_AUTOPLAN_SHADOW: '0' }), 'resume');
expect(readRun(cfgRun).shadow).toMatchObject({ enabled: true, source: 'config' });
fs.rmSync(path.join(f.env.GSTACK_STATE_ROOT!, 'config.yaml'));
const { first: noOutside } = start('switch-no-outside', f.env, ['--no-outside']);
expect(noOutside.stdout).toContain('SHADOW: off (--no-outside)');
});
test('a cap that cannot cover the call journals shadow_failed budget and the gate prints', () => {
const { out } = start('switch-cap', f.env, ['--spend-cap', '0.05', '--estimate-usd', '0.001']);
phase(out, 'ceo', f.env);
const r = phase(out, 'eng', f.env);
expect(r.stdout).toContain('SHADOW: failed budget');
expect(r.stdout).toContain('GATE: gate_rev=1');
expect(journal(out).find(x => x.event === 'shadow_failed').code).toBe('budget');
expect(f.requests().length).toBe(0);
});
});
describe('the reader over many runs', () => {
test('two runs that share a run id are both counted; the same folder listed twice is counted once', () => {
const copy = path.join(TMP, 'fail-copy', 'run');
fs.cpSync(path.join(TMP, 'fail', 'run'), copy, { recursive: true });
expect(readRun(copy).run).toBe(readRun(path.join(TMP, 'fail', 'run')).run);
const list = path.join(TMP, 'runs-same-id.txt');
fs.writeFileSync(list, [path.join(TMP, 'fail', 'run'), copy, copy].join('\n') + '\n');
const r = exported(['--runs', list], baseEnv({ GSTACK_CAPY: '' }));
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).counts.status).toEqual({ failed: 14 });
});
test('missing, failed, malformed, retrospective and recorded rows; dedupe across a repeated entry; CEO_ONLY_NO_GATE', () => {
const e = baseEnv({ GSTACK_CAPY: '' });
const { out: missing } = start('reader-missing', e);
phase(missing, 'ceo', e); phase(missing, 'eng', e);
const run = readRun(missing);
run.shadow = { enabled: true, model: 'claude-opus-5-5', source: 'env' };
fs.writeFileSync(path.join(missing, 'run.json'), JSON.stringify(run, null, 2) + '\n');
ok(ap(['answer', '--out', missing, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e), 'answer missing');
const { out: ceoOnly } = start('reader-ceo-only', e, ['--ceo-only']);
const list = path.join(TMP, 'runs.txt');
fs.writeFileSync(list, [missing, path.join(TMP, 'fail', 'run'), path.join(TMP, 'crash', 'run'), closeOut, path.join(TMP, 'main', 'run'), path.join(TMP, 'main', 'run'), ceoOnly].join('\n') + '\n');
const r = exported(['--runs', list], e);
expect(r.status).toBe(0);
expect(r.stderr).toContain('(CEO_ONLY_NO_GATE)');
const x = JSON.parse(r.stdout);
expect(x.counts.status).toEqual({ missing: 7, failed: 7, retrospective: 7, malformed: 1, recorded: 11 });
expect(x.rows.length).toBe(33);
expect(x.counts.item_class.cap_deferred).toBe(1);
expect(x.rows.filter((row: any) => row.item_class === 'finding').every((row: any) => row.ceo_eligible === false)).toBe(true);
const text = ap(['shadow', '--runs', list], e);
expect(text.stdout).toContain('SHADOW_EXPORT: rows=33 refused=1');
});
});
+333
View File
@@ -0,0 +1,333 @@
/**
* GST-537 PR 3, shadow mode for the CEO-only decider (lib/autoplan-shadow.ts):
* the frozen decider prompt, the switch, the reply contract, the 10% audit
* mark, the call (truncation, the OpenAI output bound, the 90 s deadline, the
* shadow's own egress receipt), and the gate-cost exclusion of shadow spend.
* Model calls go to test/fixtures/shadow-fake-model.ts through
* ANTHROPIC_BASE_URL and OPENAI_BASE_URL: never a real network call.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { SHADOW_PROMPT_SHA256, SHADOW_PROMPT_VERSION, auditMark, buildPage, claimShadow, parsePage, recordFile, runShadow, shadowPromptFile, shadowSwitch } from '../lib/autoplan-shadow';
import { callModel } from '../lib/outside-voice-api';
import { gateCost, renderGateCost } from '../lib/autoplan-gate-cost';
import { readJournal, readState } from '../lib/autoplan-state';
import { reserve, settle, readLedger } from '../lib/spend-ledger';
import type { GateList } from '../lib/gate-list';
import { ROOT, readRun, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-shadow-'));
const FAKE = path.join(ROOT, 'test', 'fixtures', 'shadow-fake-model.ts');
const servers: Array<{ kill: () => void }> = [];
afterAll(() => { for (const s of servers) s.kill(); fs.rmSync(TMP, { recursive: true, force: true }); });
const rows = (file: string) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
/** A fake model server in its own process, answering step n of its script for the n-th request. */
async function fake(name: string, script: Array<Record<string, unknown>>) {
const dir = path.join(TMP, `fake-${name}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify(script));
const proc = Bun.spawn(['bun', FAKE, dir], { stdout: 'ignore', stderr: 'inherit' });
servers.push(proc);
for (let i = 0; i < 200 && !fs.existsSync(path.join(dir, 'port')); i++) await Bun.sleep(25);
const port = Number(fs.readFileSync(path.join(dir, 'port'), 'utf8'));
const state = path.join(dir, 'state');
fs.mkdirSync(state, { recursive: true });
const env = { HOME: state, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, ANTHROPIC_API_KEY: 'sk-fake-anthropic', OPENAI_API_KEY: 'sk-fake-openai', ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, OPENAI_BASE_URL: `http://127.0.0.1:${port}/v1` };
return { dir, env, requests: () => rows(path.join(dir, 'requests.jsonl')), receipts: () => rows(path.join(state, 'security', 'egress.jsonl')) };
}
// One run at its gate (uc1 from CEO, uc2 from Eng, p1), shadow off; each case copies it and turns shadow on in run.json.
const BASE = startSmallRun(path.join(TMP, 'base'));
runPhase(BASE, 'ceo');
runPhase(BASE, 'eng');
let copies = 0;
function gateRun(model = 'claude-opus-5-5'): string {
const out = path.join(TMP, `run-${++copies}`);
fs.cpSync(BASE, out, { recursive: true });
const run = readRun(out);
run.shadow = { enabled: true, model, source: 'env' };
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
return out;
}
const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
const decider = () => fs.readFileSync(shadowPromptFile(), 'utf8');
describe('the decider prompt is frozen and predicts; it imports no mandate', () => {
test('its sha256 is pinned beside its version, and every row stores it', () => {
expect(SHADOW_PROMPT_VERSION).toBe(1);
expect(crypto.createHash('sha256').update(fs.readFileSync(shadowPromptFile())).digest('hex')).toBe(SHADOW_PROMPT_SHA256);
});
test('no take-the-recommendation or keep-the-direction rule; the recommendation is a labeled input', () => {
const text = decider();
expect(text).not.toMatch(/\(recommended\)/i);
expect(text).not.toMatch(/keep (the|your|its) (plan's )?direction/i);
expect(text).not.toMatch(/sections\/ceo-only\.md/);
expect(text).toContain("The reviewers' recommendation is one input among several");
for (const slot of ['{{PLAN}}', '{{QUESTIONS}}', '{{IDS}}']) expect(text.split(slot).length).toBe(2);
expect(text.trimEnd().endsWith('```')).toBe(true);
});
test('the CEO-only driver module does not name the CEO-only skill section', () => {
expect(fs.readFileSync(path.join(ROOT, 'lib', 'autoplan-ceo-only.ts'), 'utf8')).not.toContain('sections/ceo-only.md');
});
});
describe('the switch is off by default and fixed at init', () => {
const state = fs.mkdtempSync(path.join(TMP, 'cfg-'));
const keys = { GSTACK_STATE_ROOT: state, ANTHROPIC_API_KEY: 'sk-fake', OPENAI_API_KEY: '' };
const on = { outsideEnabled: true, ceoOnly: false };
test('off by default, with no line', () => {
expect(shadowSwitch(on, keys)).toEqual({ shadow: { enabled: false, reason: 'off by default', source: 'default' } });
});
test('config on turns it on with the default model; the env var wins over config', () => {
fs.writeFileSync(path.join(state, 'config.yaml'), 'autoplan_shadow: on\n');
const r = shadowSwitch(on, keys);
expect(r.shadow).toEqual({ enabled: true, model: 'claude-opus-5-5', source: 'config' });
expect(r.line).toStartWith('SHADOW: on model=claude-opus-5-5');
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: '0' }).shadow).toMatchObject({ enabled: false, source: 'env' });
fs.rmSync(path.join(state, 'config.yaml'));
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: '1' }).shadow).toMatchObject({ enabled: true, source: 'env' });
});
test('no key, --no-outside, a CEO-only run, an unpriced model or a bad value give off with the reason', () => {
const env = { ...keys, GSTACK_AUTOPLAN_SHADOW: 'on' };
expect(shadowSwitch(on, { ...env, ANTHROPIC_API_KEY: '' }).line).toBe('SHADOW: off (no API key for claude-opus-5-5 (ANTHROPIC_API_KEY))');
expect(shadowSwitch({ ...on, outsideEnabled: false }, env).line).toBe('SHADOW: off (--no-outside)');
expect(shadowSwitch({ ...on, ceoOnly: true }, env).line).toBe('SHADOW: off (a CEO-only run writes no gate)');
fs.writeFileSync(path.join(state, 'config.yaml'), 'autoplan_shadow_model: claude-unpriced-9\n');
expect(shadowSwitch(on, env).line).toBe('SHADOW: off (claude-unpriced-9 has no price in lib/pricing.ts, so --spend-cap cannot bound it)');
fs.rmSync(path.join(state, 'config.yaml'));
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: 'yes' }).line).toBe('SHADOW: off (GSTACK_AUTOPLAN_SHADOW=yes is not on or off)');
});
test('a run.json without the field reads off: nothing is claimed', () => {
const out = path.join(TMP, 'no-field');
fs.cpSync(BASE, out, { recursive: true });
const run = readRun(out);
expect(run.shadow).toEqual({ enabled: false, reason: 'off by default', source: 'default' });
delete run.shadow;
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
expect(claimShadow(out)).toEqual({});
expect(fs.existsSync(path.join(out, 'shadow'))).toBe(false);
});
});
describe('the reply contract: exactly one valid object per requested id', () => {
const out = gateRun();
const page = buildPage(readState(out), gateOf(out), decider());
const valid = page.items.map(i => ({ id: i.item.id, action: 'decide', pick: 'a', confidence: 0.6, cites: i.findings.slice(0, 1).map(f => f.id), reason: 'r' }));
const P1 = valid.findIndex(v => v.id === 'p1');
const reply = (objs: unknown[]) => '```json\n' + JSON.stringify(objs) + '\n```';
test('the page carries each item as rendered, with its findings, and p1 lists every finding the others name', () => {
expect(page.items.map(i => i.item.id)).toEqual(['uc1', 'd1', 'd2', 'uc2', 'd3', 'd4', 'p1']);
expect(page.items[0]!.frozen).toMatchObject({ item_class: 'challenge2', kind: 'approval', recommended: 'a' });
expect(page.items[0]!.findings.map(f => f.severity).every(Boolean)).toBe(true);
expect(page.items[P1]!.findings.map(f => f.id).sort()).toEqual(page.items.filter((_, i) => i !== P1).flatMap(i => i.findings).map(f => f.id).sort());
expect(page.prompt).toContain('"id": "uc1"');
expect(page.prompt).not.toContain('{{');
});
test('slot names inside the plan text stay plan text; the template slots are still filled', () => {
const planFile = path.join(TMP, 'slot-plan.md');
fs.writeFileSync(planFile, '## Implementation plan\n\nfill {{QUESTIONS}} and {{IDS}} in {{PLAN}}\n');
const p = buildPage({ ...readState(out), active_plan: planFile }, gateOf(out), decider());
expect(p.prompt).toContain('fill {{QUESTIONS}} and {{IDS}} in {{PLAN}}');
expect(p.prompt).toContain('"id": "uc1"');
expect(p.prompt).toContain('uc1, d1, d2, uc2, d3, d4, p1');
expect(p.prompt.split('{{').length).toBe(4);
});
test('a good reply is clean', () => {
expect(parsePage(reply(valid), page.items).clean).toBe(true);
});
const cases: Array<[string, unknown[] | string, string]> = [
['missing', valid.slice(0, 2), 'missing'],
['duplicate', [...valid, valid[P1]], 'duplicate'],
['unknown option', valid.map((v, i) => (i === P1 ? { ...v, pick: 'z' } : v)), 'pick "z" is not an option of p1'],
['foreign cite', valid.map((v, i) => (i === P1 ? { ...v, cites: ['nope'] } : v)), 'cites ["nope"] name a finding outside p1\'s origin'],
['confidence above 1', valid.map((v, i) => (i === P1 ? { ...v, confidence: 1.2 } : v)), 'confidence 1.2 is not a number from 0 to 1'],
['bad action', valid.map((v, i) => (i === P1 ? { ...v, action: 'approve' } : v)), 'action "approve" is not decide, send_back or hold'],
['long reason', valid.map((v, i) => (i === P1 ? { ...v, reason: 'x'.repeat(401) } : v)), 'reason is not a string of at most 400 characters'],
['no JSON', 'I would approve.', 'no JSON array in the reply'],
];
for (const [name, objs, why] of cases) test(`${name} marks that item malformed`, () => {
const p = parsePage(typeof objs === 'string' ? objs : reply(objs), page.items);
expect(p.clean).toBe(false);
expect(p.byId.get('p1')).toEqual({ ok: false, why });
});
test('a foreign id leaves the items valid but the page unclean, so it is asked again', () => {
const p = parsePage(reply([...valid, { ...valid[0], id: 'zz9' }]), page.items);
expect(p.foreign).toEqual(['zz9']);
expect([...p.byId.values()].every(v => v.ok)).toBe(true);
expect(p.clean).toBe(false);
});
});
describe('the 10% audit mark', () => {
test('on a fixed 2,000-row corpus the marked share is what the hash gives', () => {
const marked = Array.from({ length: 2000 }, (_, i) => auditMark('fixed-salt-0001', `run-fixture-r${1 + (i % 3)}-d${i}`)).filter(Boolean).length;
expect(marked).toBe(Array.from({ length: 2000 }, (_, i) => BigInt('0x' + crypto.createHash('sha256').update(`fixed-salt-0001run-fixture-r${1 + (i % 3)}-d${i}`).digest('hex')) % 1000n < 100n).filter(Boolean).length);
expect(marked / 2000).toBeGreaterThanOrEqual(0.08);
expect(marked / 2000).toBeLessThanOrEqual(0.12);
});
});
describe('callModel: the shadow receipt, stop_reason and the OpenAI output bound (existing callers unchanged)', async () => {
const f = await fake('transport', [{ mode: 'valid' }, { mode: 'truncated' }, { mode: 'valid' }, { mode: 'truncated' }]);
test('a receipt override writes exactly one receipt with its sink and consent; the default stays the outside-voice receipt', async () => {
await callModel({ model: 'claude-opus-5-5', prompt: 'p', timeoutMs: 5000, env: f.env, maxTokens: 10, receipt: { sink: 'autoplan-shadow', consent: 'autoplan_shadow on', payloadClass: 'plan-and-gate' } });
expect(f.receipts().map(r => [r.sink, r.consent, r.payload_class ?? r.payloadClass])).toEqual([['autoplan-shadow', 'autoplan_shadow on', 'plan-and-gate']]);
const cut = await callModel({ model: 'claude-opus-5-5', prompt: 'p', timeoutMs: 5000, env: f.env });
expect(cut.stop_reason).toBe('max_tokens');
expect(f.receipts().at(-1)!.sink).toBe('outside-voice-api');
expect(f.receipts().length).toBe(2);
});
test('OpenAI gets max_completion_tokens only when the caller set an output limit, and its finish_reason comes back', async () => {
const plain = await callModel({ model: 'gpt-6.1-sol', prompt: 'p', timeoutMs: 5000, env: f.env });
const bounded = await callModel({ model: 'gpt-6.1-sol', prompt: 'p', timeoutMs: 5000, env: f.env, maxTokens: 4000 });
const [a, b] = f.requests().slice(2).map(r => r.body);
expect(Object.keys(a)).toEqual(['model', 'messages']);
expect(b.max_completion_tokens).toBe(4000);
expect(plain.stop_reason).toBe('stop');
expect(bounded.stop_reason).toBe('length');
});
});
describe('runShadow: retry, truncation, failures and the deadline', async () => {
const ids = (out: string) => rows(recordFile(out, 1));
const shadowed = async (name: string, script: Array<Record<string, unknown>>, model?: string, deadlineMs?: number) => {
const f = await fake(name, script);
const out = gateRun(model);
const { claim } = claimShadow(out);
expect(claim).toEqual({ gate_rev: 1, retrospective: false });
const started = Date.now();
const outcome = await runShadow(out, claim!, { env: { ...process.env, ...f.env }, deadlineMs });
return { f, out, outcome, ms: Date.now() - started };
};
test('a good reply records every item, keeps the prompt and the raw reply, and settles one shadow-labeled call', async () => {
const { f, out, outcome } = await shadowed('good', [{ mode: 'valid' }]);
expect(outcome.status).toBe('recorded');
expect(outcome.line).toMatch(/^SHADOW: recorded model=claude-opus-5-5 usd=0\.0140$/);
expect(ids(out).map(r => [r.item, r.status, r.pick, r.rule_pick, r.prompt_sha256 === SHADOW_PROMPT_SHA256])).toEqual([['uc1', 'recorded', 'a', 'a', true], ['d1', 'recorded', 'a', null, true], ['d2', 'recorded', 'a', null, true],
['uc2', 'recorded', 'a', 'a', true], ['d3', 'recorded', 'a', null, true], ['d4', 'recorded', 'a', null, true], ['p1', 'recorded', 'a', 'a', true]]);
expect(f.requests().map(r => r.body.max_tokens)).toEqual([4000]);
expect(fs.existsSync(path.join(out, 'shadow', 'r1.prompt.md'))).toBe(true);
expect(fs.readFileSync(path.join(out, 'shadow', 'shadow-r1-1.reply.txt'), 'utf8')).toContain('fake prediction');
expect(readLedger(path.join(out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => [s.attempt, s.usd])).toEqual([['shadow-r1-1', 0.014]]);
expect(fs.existsSync(path.join(out, 'shadow', 'r1.pending'))).toBe(false);
});
test('a link at a reply path is replaced, never written through', async () => {
const f = await fake('linked-reply', [{ mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
const sentinel = path.join(TMP, 'sentinel.txt');
fs.writeFileSync(sentinel, 'keep');
const reply = path.join(out, 'shadow', 'shadow-r1-1.reply.txt');
fs.symlinkSync(sentinel, reply);
expect((await runShadow(out, claim!, { env: { ...process.env, ...f.env } })).status).toBe('recorded');
expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep');
expect(fs.lstatSync(reply).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(reply, 'utf8')).toContain('fake prediction');
});
test('a cap changed during the call admits the retry against the new cap and is not written back over', async () => {
const f = await fake('cap-moved', [{ mode: 'truncated', delay_ms: 400 }, { mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
const pending = runShadow(out, claim!, { env: { ...process.env, ...f.env } });
await Bun.sleep(150);
const run = readRun(out);
run.spend_cap_usd = 2;
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
expect((await pending).status).toBe('recorded');
expect(f.requests().length).toBe(2);
expect(readLedger(path.join(out, 'spend.json')).cap_usd).toBe(2);
});
test('a malformed page is asked once more; items valid in either reply record, the rest record malformed', async () => {
const fixed = await shadowed('missing-then-valid', [{ mode: 'missing' }, { mode: 'valid' }]);
expect(fixed.f.requests().length).toBe(2);
expect(ids(fixed.out).every(r => r.status === 'recorded')).toBe(true);
const still = await shadowed('bad-twice', [{ mode: 'bad_option' }, { mode: 'bad_option' }]);
expect(still.f.requests().length).toBe(2);
expect(ids(still.out).map(r => r.status)).toEqual(['malformed', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded']);
expect(ids(still.out)[0]).toMatchObject({ pick: null, audit: false, malformed: 'pick "z" is not an option of uc1' });
expect(still.outcome.line).toContain('malformed=1/7');
});
test('a truncated reply raises max_tokens once to 8,000 (no page retry); truncated twice fails truncated', async () => {
const raised = await shadowed('truncated-once', [{ mode: 'truncated' }, { mode: 'valid' }]);
expect(raised.f.requests().map(r => r.body.max_tokens)).toEqual([4000, 8000]);
expect(raised.outcome.status).toBe('recorded');
const twice = await shadowed('truncated-twice', [{ mode: 'truncated' }]);
expect(twice.f.requests().length).toBe(2);
expect(twice.outcome.line).toBe('SHADOW: failed truncated');
expect(fs.existsSync(recordFile(twice.out, 1))).toBe(false);
});
test('an OpenAI shadow model sends max_completion_tokens and treats finish_reason length as truncated', async () => {
const r = await shadowed('openai', [{ mode: 'truncated' }, { mode: 'valid' }], 'gpt-6.1-sol');
expect(r.f.requests().map(x => [x.path, x.body.max_completion_tokens])).toEqual([['/v1/chat/completions', 4000], ['/v1/chat/completions', 8000]]);
expect(r.outcome.status).toBe('recorded');
});
test('a 500 fails http and settles the call at 0; the journal names the code', async () => {
const r = await shadowed('http500', [{ mode: 'http500' }]);
expect(r.outcome.line).toBe('SHADOW: failed http');
expect(readLedger(path.join(r.out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => s.usd)).toEqual([0]);
});
test('one deadline covers the call and its retry: a slow malformed reply leaves the retry no time', async () => {
const r = await shadowed('deadline-retry', [{ mode: 'missing', delay_ms: 1200 }, { mode: 'valid', delay_ms: 1200 }], undefined, 1500);
expect(r.ms).toBeLessThan(2400);
expect(r.outcome.status).toBe('recorded');
expect(ids(r.out).map(x => x.status)).toEqual(['recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'malformed']);
const slow = await shadowed('deadline-call', [{ mode: 'valid', delay_ms: 3000 }], undefined, 800);
expect(slow.outcome.line).toBe('SHADOW: failed timeout');
expect(slow.ms).toBeLessThan(2500);
expect(readLedger(path.join(slow.out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => s.usd)).toEqual(['unknown']);
});
test('a publish that throws journals shadow_failed and drops the lease, so a later claim pays no second call', async () => {
const f = await fake('publish-throws', [{ mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
fs.mkdirSync(path.join(out, 'shadow', 'ceo-only.r1.jsonl.stuck.tmp'), { recursive: true });
const outcome = await runShadow(out, claim!, { env: { ...process.env, ...f.env } });
expect(outcome).toMatchObject({ status: 'failed', code: 'internal' });
expect(readJournal(out).filter(r => r.event === 'shadow_failed' && r.gate_rev === 1).map(r => r.code)).toEqual(['internal']);
expect(fs.existsSync(path.join(out, 'shadow', 'r1.pending'))).toBe(false);
expect(claimShadow(out)).toEqual({});
expect(f.requests().length).toBe(1);
});
test('runShadow never throws when the journal cannot be appended', async () => {
const f = await fake('journal-readonly', [{ mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
fs.chmodSync(path.join(out, 'attempts.jsonl'), 0o444);
try {
expect(await runShadow(out, claim!, { env: { ...process.env, ...f.env } })).toMatchObject({ status: 'failed' });
} finally { fs.chmodSync(path.join(out, 'attempts.jsonl'), 0o644); }
});
test('a record already published makes a later claim a no-op: no second call is paid', async () => {
const { f, out } = await shadowed('noop', [{ mode: 'valid' }]);
const before = fs.readFileSync(recordFile(out, 1));
expect(claimShadow(out)).toEqual({});
expect(f.requests().length).toBe(1);
expect(fs.readFileSync(recordFile(out, 1)).equals(before)).toBe(true);
});
});
describe('gate cost: shadow spend counts against the cap and shows in GATE_SPEND, never in the per-voice price', () => {
test('a $5 voice settle followed by a shadow settle keeps GATE_REC_COST at x 5.00', () => {
const out = path.join(TMP, 'cost');
fs.mkdirSync(out, { recursive: true });
const file = path.join(out, 'spend.json');
reserve(file, 'r-a1', 5, { cap: 5.2, label: 'ceo/native' }); settle(file, 'r-a1', 'unknown');
reserve(file, 'shadow-r1-1', 0.15, { label: 'shadow' }); settle(file, 'shadow-r1-1', 0.014);
const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [
{ id: 'd1', title: 't', kind: 'auto', recommended: 'b', phase: 'ceo', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }] },
{ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'b', options: [{ key: 'a', text: 'x' }, { key: 'b', text: 'y' }] }] };
const c = gateCost(out, list, { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: 5.2 });
expect(c.per_voice_usd).toBe(5);
expect(c.attempts).toBe(1);
expect(c.shadow_usd).toBe(0.014);
expect(c.summary.worst_case_usd).toBe(5.014);
const [spend, rec] = renderGateCost(c);
expect(spend).toContain('attempts=1 shadow=0.0140');
expect(rec).toContain('× 5.00');
expect(rec).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
});
});
+1 -1
View File
@@ -62,7 +62,7 @@ describe.skipIf(process.platform !== 'linux')('bootstrap paid-shard cleanup inte
fs.writeFileSync(path.join(root, 'controller.stdout.log'), controller.stdout ?? '', { mode: 0o600 });
fs.writeFileSync(path.join(root, 'controller.stderr.log'), controller.stderr ?? '', { mode: 0o600 });
expect(controller.error).toBeUndefined();
expect(controller.status).toBe(0);
expect(controller.status, `controller exited ${controller.status} (signal ${controller.signal}); stderr tail: ${(controller.stderr ?? '').slice(-600).replace(/\s*\n\s*/g, ' | ')}`).toBe(0);
const outcome = JSON.parse(fs.readFileSync(outcomePath, 'utf8'));
if (scenario === 'ack-failure') {
expect(controller.stdout).toContain('(fail) qa-bootstrap');
+19
View File
@@ -256,3 +256,22 @@ describe('CI Node.js installs match the image and are verified (#3354)', () => {
expect(unpack).toBeGreaterThan(verify);
});
});
// Value: protects=the eval image ships an ffmpeg that runs /design-motion's checks and exports;
// fails_when=Dockerfile.ci stops installing ffmpeg or stops failing the build on a missing filter or encoder;
// why_new=the marathon design-motion-showcase case ran 0/3 on an image without ffmpeg (#3687); seam=none;
// no_claim=reads the Dockerfile only; the image build runs the check itself
describe('ci image ffmpeg for the design-motion evals (#3687)', () => {
const lines = logicalLines(fs.readFileSync(DOCKERFILE, 'utf-8')).filter((line) => !/^\s*#/.test(line));
test('apt installs ffmpeg, which the marathon design-motion-showcase case needs', () => {
expect(lines.some((line) => /apt-get install\b[^&|;]*\sffmpeg\b/.test(line))).toBe(true);
});
test('the build fails when that ffmpeg cannot run the motion checks and exports', () => {
const verify = lines.find((line) => /\bffmpeg\b.*-filters/.test(line)) ?? '';
for (const need of ['ffprobe', 'psnr', 'gblur', 'palettegen', 'paletteuse', 'signalstats', 'tile', 'libx264', 'gif']) {
expect(verify, `Dockerfile.ci must check ffmpeg for ${need}`).toContain(need);
}
});
});
+25
View File
@@ -247,6 +247,31 @@ describe('probe lock recovery and failure', () => {
expect(calls(f)).toBe(1);
}));
test('a lock released while a waiter reads its age is not abandoned; the next owner\'s fresh lock survives', withFixture((f) => {
const sig = sigOf(f);
const lock = path.join(f.locks, sig);
const once = path.join(f.home, 'vanish-once');
const real = JSON.stringify(Bun.which('stat'));
fs.mkdirSync(lock, { recursive: true });
fs.writeFileSync(path.join(f.home, 'bin/stat'), `#!/usr/bin/env bash
if [ "$3" = "$RACE_LOCK" ] && [ -f "$RACE_ONCE" ]; then
case "$1" in
-c) rmdir "$RACE_LOCK"; [ "$RACE_PHASE" = early ] && mkdir "$RACE_LOCK"; exit 1 ;;
-f) ${real} "$@"; rc=$?; rm -f "$RACE_ONCE"; [ -d "$RACE_LOCK" ] || mkdir "$RACE_LOCK"; exit "$rc" ;;
esac
fi
exec ${real} "$@"
`, { mode: 0o755 });
for (const phase of ['early', 'late']) {
fs.writeFileSync(once, 'armed');
const r = probe(f, { _GSTACK_CODEX_LOCK_WAIT: '1', RACE_LOCK: lock, RACE_ONCE: once, RACE_PHASE: phase });
expect(fs.existsSync(once)).toBe(false);
expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE (another probe of this model still holds its lock)');
expect(fs.existsSync(lock)).toBe(true);
expect(calls(f)).toBe(0);
}
}));
test('an unreadable cache or uncreatable lock is unverified and launches no paid probe', withFixture((f) => {
fs.mkdirSync(f.cache);
const dir = probe(f);
+4
View File
@@ -107,6 +107,10 @@ const MODULE_SINKS = [
// vendor CLI. hosts/ has no curl/fetch for the scanner to see, so the
// receipt wiring is pinned here explicitly.
'hosts/claude/hooks/memorable-user-prompt-hook.ts',
// GSTA-156 P1: the sealed guard lists and fetches the destination's own
// refs/pull/<n>/head for squashes on its base, to skip commits it already
// holds; PR numbers leave the machine, sink 'sealed-guard' (fail-open).
'lib/sealed-guard.ts',
];
/** Shell sinks: must source the shared lib; every network op receipted. */
+8
View File
@@ -74,6 +74,14 @@ describe('gstack-evidence run', () => {
expect(fs.readFileSync(rec.log_path, 'utf-8')).toContain('ok');
});
test('the lane command sees its own log path as GSTACK_EVIDENCE_LOG (the free runner\'s home tripwire excludes exactly that file)', () => {
const r = run(['run', '--label', 'focused', '--', 'printf "%s" "$GSTACK_EVIDENCE_LOG"']);
expect(r.status).toBe(0);
const rec = records().pop();
expect(path.isAbsolute(rec.log_path)).toBe(true);
expect(r.stdout).toBe(rec.log_path);
});
test('propagates a failing exit code and records it', () => {
const r = run(['run', '--label', 'tests', '--', 'exit 3']);
expect(r.status).toBe(3);
+12 -1
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/harness-start-hook.ts", "movedFrom": "(new: gstack-harness start hook for OpenClaw and Hermes, GST-718)" },
@@ -70,6 +72,8 @@
{ "file": "browse/src/routes/pty.ts", "movedFrom": "browse/src/server.ts" },
{ "file": "browse/src/routes/table.ts", "movedFrom": "browse/src/server.ts" },
{ "file": "browse/src/routes/tokens.ts", "movedFrom": "browse/src/server.ts" },
{ "file": "browse/src/session-tabs.ts", "movedFrom": "browse/src/server.ts (the BROWSE_TAB pin and restore, replaced by per-request tab scopes and sessions; GSTA-227)" },
{ "file": "browse/src/session-name.ts", "movedFrom": "(new: the CLI side of per-session tabs, the agent harness walk; GSTA-227)" },
{ "file": "browse/src/routes/tunnel.ts", "movedFrom": "browse/src/server.ts" },
{ "file": "scripts/resolvers/review-dashboard.ts", "movedFrom": "scripts/resolvers/review.ts" },
{ "file": "scripts/resolvers/plan-gates.ts", "movedFrom": "scripts/resolvers/review.ts" },
@@ -104,6 +108,9 @@
{ "file": "lib/result-codes-release.ts", "movedFrom": "lib/result-codes.ts (the release-path rows: ship queue, pregate timing, approval classes, state matrix; field list 2 wave B)" },
{ "file": "lib/result-codes-capy.ts", "movedFrom": "lib/result-codes.ts (the Capy rows: approval record, Captain brief, PR screenshots; GSTA-61)" },
{ "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" },
{ "file": "lib/result-codes-browse.ts", "movedFrom": "lib/result-codes.ts (the browse per-session tab rows, #1961, and device and touch rows, #3058; GSTA-227)" },
{ "file": "browse/src/device-emulation.ts", "movedFrom": "(new: per-tab phone emulation for $B device, #3058, GSTA-227 F3)" },
{ "file": "browse/src/touch-commands.ts", "movedFrom": "(new: $B tap, swipe, pinch and touch through Input.dispatchTouchEvent, #3058, GSTA-227 F3)" },
{ "file": "lib/result-codes-eval.ts", "movedFrom": "lib/result-codes.ts (the /eval-plan preregistration rows and the no-gstack arm; GSTA-168)" },
{ "file": "lib/result-codes-plan.ts", "movedFrom": "lib/result-codes.ts (the plan-review fact-check and task-list rows: REALITY receipts, the office-hours probe, gstack-tasks; GSTA-228)" },
{ "file": "lib/result-codes-autoplan.ts", "movedFrom": "lib/result-codes.ts (the CEO-only driver run rows; GST-537)" },
@@ -130,6 +137,8 @@
{ "file": "lib/autoplan-run.ts", "movedFrom": "(new: next/submit/resume over lib/autoplan-state.ts, B2)" },
{ "file": "lib/autoplan-gate.ts", "movedFrom": "(new: phase close, gate build, answer and reopen, B2/B9)" },
{ "file": "lib/autoplan-ceo-only.ts", "movedFrom": "(new: the CEO-only driver run's finish, report and refusals, GST-537)" },
{ "file": "lib/autoplan-shadow.ts", "movedFrom": "(new: shadow mode for the CEO-only decider: switch, lease, call and record, GST-537 PR 3)" },
{ "file": "lib/autoplan-shadow-reader.ts", "movedFrom": "(new: the shadow-mode export for taste-replay R2 and its private validate check, GST-537 PR 3)" },
{ "file": "lib/autoplan-export.ts", "movedFrom": "(new: the unattended export file set, B2/B12)" },
{ "file": "lib/spec-review-cap.ts", "movedFrom": "(new: the spec-review loop at the cap: numbered gaps and deterministic fix verification, B8)" },
{ "file": "bin/gstack-autoplan", "movedFrom": "(new: thin entrypoint over lib/autoplan-run.ts, B2)" },
@@ -183,7 +192,9 @@
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
{ "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" },
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" },
{ "file": "lib/repo-state-clean.ts", "movedFrom": "(new: per-repo .gstack cleanup behind bin/gstack-clean, #2627)" },
{ "file": "lib/result-codes-clean.ts", "movedFrom": "lib/result-codes.ts (the gstack-clean rows, #2627)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+14 -14
View File
@@ -1,64 +1,64 @@
{
"refused": {
"code": 1,
"stdout": "SEALED: refused findings=2 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stdout": "SEALED: refused findings=2 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of <root>/origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n sealed.answer runs/c1/summary.md:1 commit <sha>\nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n",
"jsonCode": 1,
"json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
"json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
"hookCode": 1,
"hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of <root>/origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n sealed.answer runs/c1/summary.md:1 commit <sha>\nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n"
},
"clear": {
"code": 0,
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stderr": "",
"jsonCode": 0,
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"hookCode": 0,
"hookStderr": ""
},
"exempt": {
"code": 0,
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit <sha>\n",
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit <sha>\n",
"stderr": "gstack-pregate sealed: exempt sealed.path sealed/s1/MANIFEST.json commit <sha> (sealed.allow_paths **/sealed/*/MANIFEST.json)\n",
"jsonCode": 0,
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"<sha>\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"<sha>\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"hookCode": 0,
"hookStderr": "gstack sealed-output guard: exempt sealed.path sealed/s1/MANIFEST.json commit <sha> (sealed.allow_paths **/sealed/*/MANIFEST.json)\n"
},
"waived": {
"code": 0,
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit <sha>\n",
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit <sha>\n",
"stderr": "gstack-pregate sealed: waived sealed.transcript runs/c1/transcript.jsonl commit <sha> (.gstack/sealed-allow.tsv: synthetic fixture)\n",
"jsonCode": 0,
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"hookCode": 0,
"hookStderr": "gstack sealed-output guard: waived sealed.transcript runs/c1/transcript.jsonl commit <sha> (.gstack/sealed-allow.tsv: synthetic fixture)\n"
},
"warned": {
"code": 0,
"stdout": "SEALED: warned findings=1 commits=1 visibility=private waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stdout": "SEALED: warned findings=1 commits=1 skipped=0 visibility=private waived=0 exempt=0 policy=origin/main + commit <sha>\n",
"stderr": "gstack-pregate sealed: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n",
"jsonCode": 0,
"json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"hookCode": 0,
"hookStderr": "gstack sealed-output guard: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n"
},
"error": {
"code": 1,
"stdout": "SEALED: error findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
"stdout": "SEALED: error findings=0 commits=0 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
"stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of <root>/origin.git — the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n",
"jsonCode": 1,
"json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
"json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
"hookCode": 1,
"hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of <root>/origin.git — the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n"
},
"no-policy": {
"code": 0,
"stdout": "SEALED: no-policy findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
"stdout": "SEALED: no-policy findings=0 commits=0 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
"stderr": "",
"jsonCode": 0,
"json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
"hookCode": 0,
"hookStderr": ""
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* A local stand-in for the Anthropic Messages and OpenAI Chat Completions APIs,
* so the shadow-mode tests (test/autoplan-shadow*.test.ts) never make a real
* network call: the tests point ANTHROPIC_BASE_URL and OPENAI_BASE_URL at it.
* Usage: bun test/fixtures/shadow-fake-model.ts <dir>. It writes <dir>/port,
* appends every request to <dir>/requests.jsonl, and answers the n-th request
* with step n of <dir>/script.json (the last step repeats; default valid):
* { "mode": valid | missing | duplicate | foreign | bad_option | bad_cite | bad_conf | garbage | truncated | send_back | http500,
* "delay_ms"?: n }. A valid reply picks each question's first option with the
* question's first finding as its cite; usage is 1,000 input and 500 output tokens.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
const dir = process.argv[2]!;
interface Step { mode: string; delay_ms?: number }
interface Question { id: string; options: Array<{ key: string }>; findings: Array<{ id: string }> }
let served = 0;
function questions(prompt: string): Question[] {
const at = prompt.indexOf('## The questions');
const block = /```json\n([\s\S]*?)\n```/.exec(prompt.slice(at))?.[1];
return block ? JSON.parse(block) : [];
}
function answer(mode: string, qs: Question[]): string {
const rows: Array<Record<string, unknown>> = qs.map(q => ({ id: q.id, action: mode === 'send_back' ? 'send_back' : 'decide', pick: q.options[0]!.key, confidence: 0.75, cites: q.findings.slice(0, 1).map(f => f.id), reason: 'fake prediction' }));
if (mode === 'missing') rows.pop();
if (mode === 'duplicate') rows.push({ ...rows[0]! });
if (mode === 'foreign') rows.push({ ...rows[0]!, id: 'zz9' });
if (mode === 'bad_option') rows[0]!.pick = 'z';
if (mode === 'bad_cite') rows[0]!.cites = ['not-a-finding'];
if (mode === 'bad_conf') rows[0]!.confidence = 1.5;
if (mode === 'garbage') return 'I would approve it.';
if (mode === 'truncated') return '```json\n[{"id": "' + (qs[0]?.id ?? 'p1') + '", "act';
return 'Here is my prediction.\n```json\n' + JSON.stringify(rows, null, 2) + '\n```\n';
}
const server = Bun.serve({
port: 0, hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
const body = await req.json() as Record<string, any>;
let steps: Step[] = [{ mode: 'valid' }];
try { steps = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); } catch { /* default */ }
const step = steps[Math.min(served, steps.length - 1)]!;
served++;
fs.appendFileSync(path.join(dir, 'requests.jsonl'), JSON.stringify({ n: served, path: url.pathname, mode: step.mode, body, at: Date.now() }) + '\n');
if (step.delay_ms) await Bun.sleep(step.delay_ms);
if (step.mode === 'http500') return Response.json({ error: { message: 'fake overloaded' } }, { status: 500 });
const text = answer(step.mode, questions(String(body.messages?.[0]?.content ?? '')));
const cut = step.mode === 'truncated';
if (url.pathname.endsWith('/v1/messages')) return Response.json({ model: body.model, content: [{ type: 'text', text }], stop_reason: cut ? 'max_tokens' : 'end_turn', usage: { input_tokens: 1000, output_tokens: 500 } });
return Response.json({ model: body.model, choices: [{ message: { content: text }, finish_reason: cut ? 'length' : 'stop' }], usage: { prompt_tokens: 1000, completion_tokens: 500 } });
},
});
fs.writeFileSync(path.join(dir, 'port.tmp'), String(server.port));
fs.renameSync(path.join(dir, 'port.tmp'), path.join(dir, 'port'));
+28
View File
@@ -78,6 +78,34 @@ describe('free-suite home-write tripwire (#2895)', () => {
expect(failure).toContain('only');
});
test.skipIf(process.platform === 'win32')('the wrapping gstack-evidence lane log is excluded by its exact path; a foreign write beside it still trips', async () => {
const home = fakeHome();
const logs = path.join(home, '.gstack/projects/p/logs');
fs.mkdirSync(logs, { recursive: true });
const laneLog = path.join(logs, 'focused.log');
fs.writeFileSync(laneLog, 'lane start\n');
const unexcluded = guardFreeHome(['test/a.test.ts'], { HOME: home });
const options = (script: string, name: string) => ({ env: { ...process.env, HOME: home, GSTACK_EVIDENCE_LOG: laneLog }, quiet: true, log: () => {},
logFilePath: path.join(home, `${name}.log`),
commandFor: () => ({ command: process.execPath, args: ['-e', `${script}console.log('Ran 1 tests across 1 files. [1.00ms]')`] }) });
const grow = `require('fs').appendFileSync(${JSON.stringify(laneLog)}, 'lane output\\n');`;
const errors: string[] = [];
const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); });
try {
expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(grow, 'lane'))).status).toBe('passed');
expect(unexcluded.verify()).toContain('~/.gstack/projects/p/logs/focused.log');
const foreign = `${grow}require('fs').writeFileSync(${JSON.stringify(path.join(logs, 'other.log'))}, 'x');`;
expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(foreign, 'foreign'))).status).toBe('failed');
expect(errors.join('\n')).toContain('~/.gstack/projects/p/logs/other.log');
expect(errors.join('\n')).not.toContain('focused.log');
} finally {
spy.mockRestore();
for (const retained of errors.flatMap(line => /retained (\S+)$/.exec(line)?.[1] ?? [])) {
if (/^gstack-free-shard-/.test(path.basename(retained))) fs.rmSync(retained, { recursive: true, force: true });
}
}
});
test('a concurrent phase guard names no shard and points at attribution', () => {
const home = fakeHome();
const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }, { kind: 'concurrent', shards: 3 });
+8 -6
View File
@@ -174,7 +174,8 @@ describe('free-tests workflow wiring', () => {
expect(workflow.env.FREE_SUITE_SHARDS).toMatch(/^[1-9][0-9]?$/);
expect(planner.outputs.runner).toBe('${{ steps.plan.outputs.runner }}');
expect(workflow.jobs['free-suite']['runs-on']).toBe('${{ needs.free-plan.outputs.runner }}');
expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}' });
expect(workflow.env.FREE_SUITE_JOBS).toMatch(/^[1-8]$/);
expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}', JOBS_OVERRIDE: '${{ inputs.free_jobs }}' });
expect(plan.run).not.toContain('${{');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'free plan '));
const bin = path.join(directory, 'bin');
@@ -185,7 +186,7 @@ describe('free-tests workflow wiring', () => {
const output = path.join(directory, 'output');
fs.writeFileSync(output, '');
const result = spawnSync('bash', ['-c', plan.run], {
env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', ...overrides,
env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', JOBS_OVERRIDE: '', ...overrides,
PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory),
GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) },
encoding: 'utf8', timeout: 5000,
@@ -199,12 +200,13 @@ describe('free-tests workflow wiring', () => {
const unset = resolve({});
expect(unset.status).toBe(0);
expect(unset.outputs.runner).toBe(workflow.env.FREE_SUITE_RUNNER);
expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS}`);
const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16' });
expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS} --shard-jobs ${workflow.env.FREE_SUITE_JOBS}`);
const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16', JOBS_OVERRIDE: '3' });
expect(override.status).toBe(0);
expect(override.outputs.runner).toBe('ubicloud-standard-4');
expect(override.args).toContain('--shards 16');
const refusals: Record<string, string>[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' }];
expect(override.args).toContain('--shards 16 --shard-jobs 3');
const refusals: Record<string, string>[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' },
{ JOBS_OVERRIDE: '0' }, { JOBS_OVERRIDE: '9' }, { JOBS_OVERRIDE: '2; true' }, { JOBS_OVERRIDE: '-1' }];
for (const bad of refusals) {
const refused = resolve(bad);
expect(refused.status, JSON.stringify(bad)).not.toBe(0);
+17
View File
@@ -71,6 +71,23 @@ describe('git fixture template', () => {
expect(() => leaky.copy()).toThrow(/still references its template [^\n]*\n.*config/);
});
test("Capy's git shim log in a fixture home (home/.capy/git-shim.log) is not a leak; any other file naming the template still is", () => {
const plant = (rel: string) => gitFixtureTemplate({ prefix: 'gstack-fixture-template-shim-', build: (root) => {
const meta = build(root);
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), '# capy git shim v2: ts cwd ppid parent verb\n2026-11-02T02:15:30Z\t' + path.join(root, 'work') + '\t1\tbun\tcat-file\n');
return meta;
}, rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) });
const shim = plant(path.join('home', '.capy', 'git-shim.log'));
const other = plant(path.join('home', '.capy', 'state.json'));
try {
const c = shim.copy();
roots.push(c.root);
expect(git(path.join(c.root, 'work'), 'remote', 'get-url', 'origin')).toBe(path.join(c.root, 'origin.git'));
expect(() => other.copy()).toThrow(/still references its template [^\n]*\n.*state\.json/);
} finally { shim.dispose(); other.dispose(); }
});
test('native, forward-slash, backslash-escaped and Git Bash spellings are each rewritten, and each one left behind is found', () => {
const to = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fixture-spellings-'));
roots.push(to);
+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);
@@ -550,6 +550,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']);
+115
View File
@@ -0,0 +1,115 @@
/**
* gstack-clean removes only top-level tmp/codex-* files in a repository's
* .gstack (lib/repo-state-clean.ts, #2627 D8). This test greps the tracked
* templates, lib, bin and scripts for every producer of a .gstack/tmp/ entry
* and fails when one is not classified below, as scratch gstack-clean may
* remove or as kept, or when a codex-* name is not on the scratch list (a kept
* file named codex-* would be deleted after 7 days). Classify a new producer
* here before shipping it.
*/
import { 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 { SCRATCH_PREFIX } from '../lib/repo-state-clean';
const ROOT = path.resolve(import.meta.dir, '..');
/** Throwaway names gstack-clean removes after 7 days; every one starts with SCRATCH_PREFIX. Exact names, so a new codex-* producer must be looked at. */
const SCRATCH: Record<string, string> = {
'codex-err-XXXXXX': 'codex stderr capture under TMP_ROOT, which falls back to .gstack/tmp (codex/sections/*.md.tmpl)',
'codex-resp-XXXXXX': 'codex response capture under TMP_ROOT (codex/sections/*.md.tmpl)',
'codex-out-XXXXXX': 'codex review output under TMP_ROOT (codex/sections/review-mode.md.tmpl)',
'codex-prompt-XXXXXX': 'codex review prompt under TMP_ROOT (codex/sections/review-mode.md.tmpl)',
'codex-prompt.XXXXXX': 'codex challenge and consult prompt, a free-text file in .gstack/tmp (codex/sections/*.md.tmpl)',
'codex-focus.XXXXXX': 'codex review focus, a free-text file in .gstack/tmp (codex/sections/review-mode.md.tmpl)',
};
/** Names or name patterns (prefix* or *suffix) gstack-clean never touches. */
const KEPT: Record<string, string> = {
'restamp-journal.json': 'gstack-restamp rollback journal (lib/restamp.ts)',
'restamp-staging.json': 'gstack-restamp files still to stage, read by its noop rerun (bin/gstack-restamp)',
'gate-ahead*': 'gate-ahead record and worktree (lib/restamp.ts)',
'pregate.json': 'pregate result (lib/pregate/index.ts)',
'ship-queue': 'ship-queue state (lib/ship-queue-gate.ts)',
'ship-receipt.md': '/ship handoff receipt',
'ship-gate.json': '/ship handoff gate record',
'issues.json': '/ship issue links',
'docs-audit.json': '/ship documentation audit record',
'tree-receipt.json': 'gstack-tree-receipt output',
'base-red.json': '/ship base-red record',
'review-*': '/ship review loop records',
'fix-check-*': '/ship fix-check inputs',
'tasks-*': 'gstack-tasks rows files (bin/gstack-tasks)',
'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)',
'eval-bisect': '/investigate eval bisect cache',
'price-gate': 'gstack-eval-plan price receipts',
'qt.txt': 'question-tuning summary file',
'tune-words.txt': '/plan-tune free text',
'before.txt': '/ship PR screenshot input',
'after.txt': '/ship PR screenshot input',
'*.XXXXXX': 'free-text and page mktemp files (scripts/resolvers/free-text-file.ts, scripts/resolvers/gbrain.ts)',
'<*': 'a free-text file named by the basename its mktemp printed',
'name*': 'bin/gstack-paths comment example for TMP_ROOT',
};
const matches = (name: string, pattern: string) => pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern.startsWith('*') ? name.endsWith(pattern.slice(1)) : name === pattern;
function trackedSources(): string[] {
const r = spawnSync('git', ['ls-files', '-z', '--', '*.tmpl', 'lib', 'bin', 'scripts', 'autoplan/bin', 'browse/src'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error('git ls-files failed: ' + r.stderr);
return r.stdout.split('\0').filter(f => f && !/(^|\/)test\//.test(f) && !f.endsWith('.test.ts') && fs.existsSync(path.join(ROOT, f)));
}
/** The .gstack/tmp entry names one source text can create, each with its offset. */
function namesIn(text: string): Array<[string, number]> {
const out: Array<[string, number]> = [];
for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;:,|&*\\]*)/g)) out.push([m[1]!.split('/')[0]!.replace(/\.+$/, ''), m.index!]);
for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) out.push([m[1] ?? '', m.index!]);
for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) out.push([m[1]!, m.index!]);
for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) {
for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) out.push([(spec.split('=')[1] || 'text') + '.XXXXXX', m.index!]);
}
return out;
}
/** Every .gstack/tmp entry name a source can create, with where it was seen. */
function producers(): Map<string, string> {
const found = new Map<string, string>();
for (const file of trackedSources()) {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
for (const [name, index] of namesIn(text)) if (name && !found.has(name)) found.set(name, file + ':' + (text.slice(0, index).split('\n').length));
}
return found;
}
describe('gstack-clean producer classification', () => {
const found = producers();
test('a path followed by prose punctuation names the file, not the punctuation', () => {
expect(namesIn('r.stop(`.gstack/tmp/x.json: ${message}`)').map(([name]) => name)).toEqual(['x.json']);
});
test('the scan sees the known producers', () => {
for (const name of ['restamp-journal.json', 'pregate.json', 'ship-queue', 'autoplan', 'codex-err-XXXXXX']) expect(found.has(name)).toBe(true);
});
test('every .gstack/tmp producer is classified', () => {
const problems: string[] = [];
for (const [name, where] of found) {
if (name.startsWith(SCRATCH_PREFIX)) {
if (!(name in SCRATCH)) problems.push(where + ': ' + name + ' starts with ' + SCRATCH_PREFIX + ', so gstack-clean removes it after 7 days; add it to SCRATCH if it is throwaway, or rename it');
} else if (!Object.keys(KEPT).some(pattern => matches(name, pattern))) {
problems.push(where + ': ' + name + ' is not classified; add it to KEPT (or name it ' + SCRATCH_PREFIX + '* and add it to SCRATCH)');
}
}
expect(problems).toEqual([]);
});
test('every classified entry still has a producer, and scratch is what gstack-clean removes', () => {
expect(Object.keys(SCRATCH).filter(name => !name.startsWith(SCRATCH_PREFIX))).toEqual([]);
const names = [...found.keys()];
expect(Object.keys(SCRATCH).filter(name => !found.has(name))).toEqual([]);
expect(Object.keys(KEPT).filter(pattern => !names.some(name => !name.startsWith(SCRATCH_PREFIX) && matches(name, pattern)))).toEqual([]);
});
});
+540
View File
@@ -0,0 +1,540 @@
/**
* gstack-clean (#2627, GSTA-226 D8/D9): dry run changes nothing, apply removes
* exactly the allowlist, every refusal touches nothing, links never lead it
* outside the folder, liveness and unknown liveness skip a group, the in-place
* cut keeps the inode and a line boundary, audit rotation keeps newer and
* unreadable lines byte for byte, exit codes 0/1/2/3, and the --json shape.
* Fixtures live under os.tmpdir(), outside any checkout, with GSTACK_HOME at
* a temp state root; every fixture path is resolved and checked to stay there.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawn, spawnSync } from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-clean');
const DAY = 24 * 60 * 60 * 1000;
const roots: string[] = [];
afterAll(() => {
for (const root of roots) {
spawnSync('chmod', ['-R', 'u+rwx', root], { timeout: 10_000 });
fs.rmSync(root, { recursive: true, force: true });
}
});
interface Fixture { root: string; home: string; state: string; repo: string; gs: string }
/** Bun's install cache and the machine's git shim write under HOME; neither is gstack-clean's doing. */
const HOME_NOISE = new Set(['home/.bun', 'home/.capy']);
function git(cwd: string, ...args: string[]): string {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
if (r.status !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr);
return r.stdout.trim();
}
function inRoot(root: string, p: string): string {
const real = fs.realpathSync(p);
if (real !== root && !real.startsWith(root + path.sep)) throw new Error(p + ' resolves outside the fixture root ' + root);
return real;
}
function fixture(): Fixture {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-clean-')));
roots.push(root);
if (root.startsWith(ROOT + path.sep)) throw new Error('fixture root is inside the checkout');
const home = path.join(root, 'home');
const state = path.join(root, 'state');
const repo = path.join(root, 'repo');
fs.mkdirSync(home);
fs.mkdirSync(state);
fs.mkdirSync(repo);
git(repo, 'init', '-q');
expect(git(repo, 'rev-parse', '--show-toplevel')).toBe(repo);
const gs = path.join(repo, '.gstack');
fs.mkdirSync(path.join(gs, 'tmp'), { recursive: true });
inRoot(root, gs);
fs.mkdirSync(path.join(root, 'quiet-ps'));
fs.writeFileSync(path.join(root, 'quiet-ps', 'ps'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
return { root, home, state, repo, gs };
}
function put(file: string, content: string | Buffer, ageMs = 30 * DAY): string {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
const when = new Date(Date.now() - ageMs);
fs.utimesSync(file, when, when);
return file;
}
/** Log text of whole numbered lines, about the given size. */
function logText(bytes: number): string {
const lines: string[] = [];
let size = 0;
for (let i = 0; size < bytes; i++) {
const line = 'line ' + String(i).padStart(7, '0') + ' ' + 'x'.repeat(i % 90) + '\n';
lines.push(line);
size += line.length;
}
return lines.join('');
}
function deadPid(): number {
const r = spawnSync('true', [], { timeout: 10_000 });
return r.pid!;
}
/** A ps that lists no process, so an unrelated codex elsewhere on the host cannot skip tmp/; realPs keeps the host's. */
function quietPsPath(f: Fixture): string {
return path.join(f.root, 'quiet-ps') + path.delimiter + (process.env.PATH ?? '');
}
function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record<string, string | undefined>; realPs?: boolean } = {}) {
const env: Record<string, string | undefined> = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...(opts.realPs ? {} : { PATH: quietPsPath(f) }), ...opts.env };
for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'CLAUDE_PLUGIN_DATA', 'BROWSE_STATE_FILE']) if (!(opts.env && name in opts.env)) delete env[name];
for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k];
const r = spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { cwd: opts.cwd ?? f.repo, env: env as NodeJS.ProcessEnv, encoding: 'utf8', timeout: 30_000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
/** Every entry under the fixture root (links not followed): type, inode, mtime and content hash. */
function snapshot(root: string): Record<string, string> {
const out: Record<string, string> = {};
const walk = (dir: string) => {
for (const name of fs.readdirSync(dir).sort()) {
const p = path.join(dir, name);
const st = fs.lstatSync(p);
const rel = path.relative(root, p);
if (HOME_NOISE.has(rel)) continue;
if (st.isSymbolicLink()) out[rel] = 'link ' + fs.readlinkSync(p);
else if (st.isDirectory()) { out[rel] = 'dir'; walk(p); }
else out[rel] = 'file ' + st.ino + ' ' + st.mtimeMs + ' ' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
}
};
walk(root);
return out;
}
function changedKeys(before: Record<string, string>, after: Record<string, string>): string[] {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => before[k] !== after[k]).sort();
}
/** A .gstack with one of everything: targets, near misses and protected files. */
function populate(f: Fixture): void {
const t = path.join(f.gs, 'tmp');
put(path.join(t, 'codex-err-old1'), 'old stderr', 8 * DAY);
put(path.join(t, 'codex-resp-old2'), 'old response', 400 * DAY);
put(path.join(t, 'codex-resp-new'), 'two days old', 2 * DAY);
for (const name of ['restamp-journal.json', 'gate-ahead.json', 'gate-ahead-2.json', 'pregate.json', 'ship-receipt.md', 'brief.AbC123', 'page.Xy12'])
put(path.join(t, name), '{"keep":true}', 400 * DAY);
for (const dir of ['autoplan', 'ship-queue', 'eval-bisect', 'price-gate']) put(path.join(t, dir, 'codex-nested-old'), 'nested', 400 * DAY);
put(path.join(t, 'codex-dir', 'inside'), 'a directory named like scratch', 400 * DAY);
put(path.join(f.gs, 'browse-console.log'), logText(1.5 * 1024 * 1024), 2 * 60 * 60 * 1000);
put(path.join(f.gs, 'browse-network.log'), logText(500 * 1024), 400 * DAY);
put(path.join(f.gs, 'browse-daemon.log.1'), logText(2 * 1024 * 1024), 400 * DAY);
put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00.000Z","cmd":"goto"}\n', 400 * DAY);
put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: deadPid(), port: 1 }), 400 * DAY);
put(path.join(f.gs, 'session-state.json'), '{"cookies":[]}', 400 * DAY);
put(path.join(f.gs, 'chromium-profile', 'Default', 'Cookies'), 'cookies', 400 * DAY);
put(path.join(f.gs, 'browser-skills', 'mine', 'script.ts'), 'export {}', 400 * DAY);
put(path.join(f.gs, 'browse-states', 'saved.json'), '{}', 400 * DAY);
}
describe('gstack-clean', () => {
test('dry run changes no byte or mtime and names the target first', () => {
const f = fixture();
populate(f);
const before = snapshot(f.root);
const r = run(f, []);
expect(r.code).toBe(0);
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
const lines = r.out.trimEnd().split('\n');
expect(lines[0]).toBe('gstack-clean: target ' + f.gs + ' (the .gstack folder at the git toplevel ' + f.repo + ')');
expect(r.out).toMatch(/would remove +tmp\/codex-err-old1 \(10 bytes\)/);
expect(r.out).toMatch(/would cut +browse-console\.log \(freeing \d+ bytes\)/);
expect(r.out).toMatch(/skip +tmp\/codex-dir: is not a regular file/);
expect(r.out).toMatch(/^total: 3 actions would free \d+ bytes; 1 skipped$/m);
expect(lines.at(-1)).toBe('nothing changed; to do this: gstack-clean --apply');
});
test('apply removes exactly the allowlist; protected files survive at any age', () => {
const f = fixture();
populate(f);
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.err).toBe('');
const after = snapshot(f.root);
expect(changedKeys(before, after)).toEqual([
'repo/.gstack/browse-console.log',
'repo/.gstack/tmp/codex-err-old1',
'repo/.gstack/tmp/codex-resp-old2',
]);
expect(after['repo/.gstack/tmp/codex-err-old1']).toBeUndefined();
expect(after['repo/.gstack/browse-console.log']).toBeDefined();
expect(r.out).toMatch(/^removed +tmp\/codex-resp-old2 \(12 bytes\)/m);
expect(r.out).toMatch(/^total: 3 actions freed \d+ bytes; 1 skipped$/m);
expect(r.out).not.toContain('nothing changed');
});
test('a missing .gstack is nothing to clean', () => {
const f = fixture();
fs.rmSync(f.gs, { recursive: true });
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out.trim()).toBe('nothing to clean: ' + f.gs + ' does not exist');
});
describe('refusals touch nothing (exit 3)', () => {
const refused = (f: Fixture, r: ReturnType<typeof run>, before: Record<string, string>, why: RegExp) => {
expect(r.code).toBe(3);
expect(r.out).toBe('');
expect(r.err.trim()).toMatch(why);
expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_TARGET_REFUSED)');
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
};
test('outside git', () => {
const f = fixture();
const plain = path.join(f.root, 'plain');
put(path.join(plain, '.gstack', 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const before = snapshot(f.root);
refused(f, run(f, ['--apply'], { cwd: plain }), before, /is not inside a git work tree/);
});
test('a symlinked .gstack', () => {
const f = fixture();
fs.rmSync(f.gs, { recursive: true });
const elsewhere = path.join(f.root, 'elsewhere');
put(path.join(elsewhere, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
fs.symlinkSync(elsewhere, f.gs);
const before = snapshot(f.root);
refused(f, run(f, ['--apply']), before, /\.gstack is a symlink/);
});
test('BROWSE_STATE_FILE naming another folder; its own browse.json is accepted', () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const before = snapshot(f.root);
const foreign = path.join(f.root, 'other', '.gstack', 'browse.json');
refused(f, run(f, ['--apply'], { env: { BROWSE_STATE_FILE: foreign } }), before, /BROWSE_STATE_FILE is .*other\/\.gstack\/browse\.json/);
const own = run(f, [], { env: { BROWSE_STATE_FILE: path.join(f.gs, 'browse.json') } });
expect(own.code).toBe(0);
expect(own.out).toContain('would remove');
});
test('the target is the state root (HOME/.gstack with GSTACK_HOME=HOME/.gstack)', () => {
const f = fixture();
git(f.home, 'init', '-q');
const home = path.join(f.home, '.gstack');
put(path.join(home, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
put(path.join(home, 'projects', 'p', 'learnings.jsonl'), '{}\n', 30 * DAY);
const before = snapshot(f.root);
refused(f, run(f, ['--apply'], { cwd: f.home, env: { HOME: f.home, GSTACK_HOME: home } }), before, /sits inside the gstack state root/);
});
test('the target holds the state root', () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const inner = path.join(f.gs, 'state');
fs.mkdirSync(inner);
const before = snapshot(f.root);
refused(f, run(f, ['--apply'], { env: { GSTACK_HOME: inner } }), before, /holds or sits inside the gstack state root/);
});
});
test('a link in tmp/ pointing outside leaves the outside file intact', () => {
const f = fixture();
const outside = put(path.join(f.root, 'outside', 'codex-precious'), 'precious', 30 * DAY);
fs.symlinkSync(outside, path.join(f.gs, 'tmp', 'codex-link'));
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +tmp\/codex-link: is a symlink/);
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
expect(fs.readFileSync(outside, 'utf8')).toBe('precious');
});
test('a symlinked tmp/ refuses the tmp group', () => {
const f = fixture();
fs.rmSync(path.join(f.gs, 'tmp'), { recursive: true });
const outsideTmp = path.join(f.root, 'outside-tmp');
put(path.join(outsideTmp, 'codex-err-old'), 'x', 30 * DAY);
fs.symlinkSync(outsideTmp, path.join(f.gs, 'tmp'));
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +tmp: tmp\/ is a symlink; the tmp group is refused/);
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
describe('liveness', () => {
const bigLog = (f: Fixture, ageMs = 2 * 60 * 60 * 1000) => put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), ageMs);
test('a live browse daemon (browse.json names a running pid) skips browse files', () => {
const f = fixture();
bigLog(f);
put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: process.pid, port: 1 }));
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toContain('skip browse-console.log: the browse daemon (pid ' + process.pid + ', browse.json) is running');
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
test('a starting daemon (browse.json.lock with a live pid) skips browse files', () => {
const f = fixture();
bigLog(f);
put(path.join(f.gs, 'browse.json.lock'), process.pid + '\n');
const r = run(f, ['--apply']);
expect(r.out).toContain('browse.json.lock) is running');
expect(fs.statSync(path.join(f.gs, 'browse-console.log')).size).toBeGreaterThan(1024 * 1024);
});
test('an unreadable browse.json is unknown liveness and skips browse files', () => {
const f = fixture();
bigLog(f);
put(path.join(f.gs, 'browse.json'), 'not json');
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toContain('browse.json names no pid, so the browse daemon may be running');
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
test('no process listing is unknown liveness and skips tmp/', () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const failingPs = path.join(f.root, 'failing-ps');
fs.mkdirSync(failingPs);
fs.writeFileSync(path.join(failingPs, 'ps'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
const before = snapshot(path.join(f.repo));
const r = run(f, ['--apply'], { env: { PATH: failingPs + path.delimiter + (process.env.PATH ?? '') } });
expect(r.code).toBe(0);
expect(r.out).toContain('skip tmp/codex-err-old: cannot list processes to tell whether codex is running');
expect(changedKeys(before, snapshot(f.repo))).toEqual([]);
});
test('a running codex skips tmp/', async () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const fake = path.join(f.root, 'fakebin', 'codex');
put(fake, '#!/bin/sh\nsleep 30\n', 0);
fs.chmodSync(fake, 0o755);
const child = spawn(fake, [], { stdio: 'ignore' });
try {
await Bun.sleep(300);
const r = run(f, ['--apply'], { realPs: true });
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +tmp\/codex-err-old: codex is running/);
expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true);
} finally {
child.kill('SIGKILL');
}
});
test('a file modified within the last hour is skipped', () => {
const f = fixture();
bigLog(f, 0);
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.out).toContain('skip browse-console.log: modified within the last hour');
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
});
test('the in-place cut keeps the inode and starts on a line boundary', () => {
const f = fixture();
const text = logText(1.5 * 1024 * 1024);
const log = put(path.join(f.gs, 'browse-daemon.log'), text, 2 * 60 * 60 * 1000);
const ino = fs.statSync(log).ino;
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
const kept = fs.readFileSync(log, 'utf8');
expect(fs.statSync(log).ino).toBe(ino);
expect(kept.length).toBeLessThanOrEqual(256 * 1024);
expect(kept.length).toBeGreaterThan(255 * 1024 - 200);
expect(text.endsWith(kept)).toBe(true);
expect(text[text.length - kept.length - 1]).toBe('\n');
expect(kept.startsWith('line ')).toBe(true);
expect(r.out).toMatch(new RegExp('^cut +browse-daemon\\.log \\(freed ' + (text.length - kept.length) + ' bytes\\)', 'm'));
});
test('--audit-days keeps newer and unparseable lines byte for byte; a malformed file is left alone', () => {
const f = fixture();
const iso = (ageMs: number) => new Date(Date.now() - ageMs).toISOString();
const oldLine = '{"ts":"' + iso(40 * DAY) + '","cmd":"goto"}\n';
const keepLines = [
'{"ts":"' + iso(DAY) + '","cmd":"text","args":"caf\u00e9"}\n',
'{"cmd":"no-ts"}\n',
'{"ts":"not a date","cmd":"bad-ts"}\n',
'\n',
'{"ts":"' + iso(2 * DAY) + '", "cmd":"spaced"}',
];
const audit = put(path.join(f.gs, 'browse-audit.jsonl'), oldLine + keepLines[0] + oldLine + keepLines.slice(1).join(''), 2 * 60 * 60 * 1000);
const ino = fs.statSync(audit).ino;
const untouched = run(f, ['--apply']);
expect(untouched.out).not.toContain('browse-audit.jsonl');
const r = run(f, ['--apply', '--audit-days', '30']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/^trimmed +browse-audit\.jsonl \(freed \d+ bytes\): audit entries older than 30 days/m);
expect(fs.readFileSync(audit, 'utf8')).toBe(keepLines.join(''));
expect(fs.statSync(audit).ino).toBe(ino);
const g = fixture();
put(path.join(g.gs, 'browse-audit.jsonl'), oldLine + '{"ts": truncated\n', 2 * 60 * 60 * 1000);
const before = snapshot(g.root);
const bad = run(g, ['--apply', '--audit-days=30']);
expect(bad.code).toBe(0);
expect(bad.out).toContain('skip browse-audit.jsonl: line 2 is not JSON, so the file was left as it is');
expect(changedKeys(before, snapshot(g.root))).toEqual([]);
});
test('a hard-linked log is left alone, and the other inode keeps every byte', () => {
const f = fixture();
const outside = put(path.join(f.root, 'outside.txt'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
fs.linkSync(outside, path.join(f.gs, 'browse-console.log'));
const before = fs.readFileSync(outside);
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +browse-console\.log: .*hard link/);
expect(fs.readFileSync(outside).equals(before)).toBe(true);
});
test.skipIf(process.getuid?.() === 0)('an unlistable tmp/ skips the tmp group and the browse logs are still cut', () => {
const f = fixture();
const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
fs.chmodSync(path.join(f.gs, 'tmp'), 0o000);
try {
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +tmp: cannot list/);
expect(fs.statSync(log).size).toBeLessThanOrEqual(256 * 1024);
} finally {
fs.chmodSync(path.join(f.gs, 'tmp'), 0o755);
}
});
test('a log with no line break in its last 256 KB is left alone', () => {
const f = fixture();
put(path.join(f.gs, 'browse-network.log'), 'x'.repeat(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
const before = snapshot(f.root);
const r = run(f, ['--apply']);
expect(r.out).toMatch(/skip +browse-network\.log: no line break/);
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
test('an audit line that is not a JSON object leaves the audit file alone', () => {
const f = fixture();
put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00Z"}\n[1]\n', 2 * 60 * 60 * 1000);
const before = snapshot(f.root);
const r = run(f, ['--apply', '--audit-days', '7']);
expect(r.out).toMatch(/skip +browse-audit\.jsonl: line 2 is not a JSON object/);
expect(changedKeys(before, snapshot(f.root))).toEqual([]);
});
test.skipIf(process.getuid?.() === 0)('an unreadable log is skipped and the rest still runs', () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000);
fs.chmodSync(log, 0o000);
try {
const r = run(f, ['--apply']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/skip +browse-console\.log: cannot read/);
expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(false);
} finally {
fs.chmodSync(log, 0o644);
}
});
test('a reader that closes early does not turn a finished run into an internal error', () => {
const f = fixture();
for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY);
const env: Record<string, string> = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state };
const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--apply', '--json'].map(a => "'" + a.replace(/'/g, "'\\''") + "'").join(' ') + " 2>'" + path.join(f.root, 'err.txt') + "' | head -c 1 >/dev/null";
const r = spawnSync('bash', ['-c', 'set -o pipefail; ' + cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(f.root, 'err.txt'), 'utf8')).not.toContain('GSTACK_CLEAN_INTERNAL');
expect(fs.readdirSync(path.join(f.gs, 'tmp')).filter(n => n.startsWith('codex-'))).toEqual([]);
});
test('--json reaches a pipe whole with hundreds of actions', () => {
const f = fixture();
for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY);
const env: Record<string, string> = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state };
const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--json'].map(a => "'" + a.replace(/'/g, "'\''") + "'").join(' ') + ' | (sleep 1; cat)';
const r = spawnSync('sh', ['-c', cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 });
expect(JSON.parse(r.stdout).actions.length).toBe(300);
});
describe('exit codes', () => {
test('2: an unknown flag or a bad --audit-days quotes the value; the last line is usage', () => {
const f = fixture();
for (const [args, quoted] of [[['--force'], '"--force"'], [['--audit-days', 'abc'], '"abc"'], [['--audit-days=0'], '"0"'], [['--audit-days'], '""']] as const) {
const r = run(f, [...args]);
expect(r.code).toBe(2);
expect(r.out).toBe('');
const lines = r.err.trimEnd().split('\n');
expect(lines[0]).toContain(quoted);
expect(lines[0]).toEndWith('(GSTACK_CLEAN_USAGE)');
expect(lines.at(-1)).toBe('usage: gstack-clean [--apply] [--audit-days N] [--json]');
}
});
test.skipIf(process.getuid?.() === 0)('1: a read-only folder and a read-only file give FAILED lines; rerunning is safe', () => {
const f = fixture();
put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY);
const log = put(path.join(f.gs, 'browse-dialog.log'), logText(1.1 * 1024 * 1024), 2 * 60 * 60 * 1000);
fs.chmodSync(log, 0o444);
fs.chmodSync(path.join(f.gs, 'tmp'), 0o555);
try {
const r = run(f, ['--apply']);
expect(r.code).toBe(1);
expect(r.out).toContain('FAILED ' + path.join(f.gs, 'tmp', 'codex-err-old') + ' EACCES');
expect(r.out).toContain('FAILED ' + log + ' EACCES');
expect(r.err).toContain('rerunning is safe');
expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_PARTIAL)');
} finally {
fs.chmodSync(path.join(f.gs, 'tmp'), 0o755);
}
expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true);
});
test('--help lists the fixed thresholds and exits 0', () => {
const f = fixture();
const r = run(f, ['--help']);
expect(r.code).toBe(0);
for (const needle of ['older than 7 days', 'over 1 MB', 'last 256 KB', '--audit-days N', 'in the last hour']) expect(r.out).toContain(needle);
});
});
test('--json prints one versioned object with the same content', () => {
const f = fixture();
populate(f);
const dry = run(f, ['--json']);
expect(dry.code).toBe(0);
const plan = JSON.parse(dry.out);
expect(Object.keys(plan)).toEqual(['schema_version', 'tool', 'mode', 'status', 'target', 'chosen_by', 'audit_days', 'actions', 'skips', 'total', 'error', 'exit_code']);
expect(plan).toMatchObject({ schema_version: 1, tool: 'gstack-clean', mode: 'dry-run', status: 'planned', target: f.gs, audit_days: null, error: null, exit_code: 0 });
expect(plan.actions.map((a: any) => [a.rel, a.group, a.action, a.status])).toEqual([
['tmp/codex-err-old1', 'tmp', 'remove', 'planned'],
['tmp/codex-resp-old2', 'tmp', 'remove', 'planned'],
['browse-console.log', 'browse', 'cut', 'planned'],
]);
expect(plan.skips).toEqual([{ path: path.join(f.gs, 'tmp', 'codex-dir'), rel: 'tmp/codex-dir', group: 'tmp', reason: 'is not a regular file; left alone' }]);
expect(plan.total).toEqual({ actions: 3, bytes: plan.actions.reduce((n: number, a: any) => n + a.bytes, 0), failed: 0, skipped: 1 });
const applied = JSON.parse(run(f, ['--json', '--apply']).out);
expect(applied).toMatchObject({ mode: 'apply', status: 'done', exit_code: 0 });
expect(applied.actions.map((a: any) => a.status)).toEqual(['done', 'done', 'done']);
expect(JSON.parse(run(f, ['--json']).out)).toMatchObject({ status: 'nothing_to_do', actions: [] });
const refused = run(f, ['--json'], { cwd: f.root });
expect(refused.code).toBe(3);
expect(JSON.parse(refused.out)).toMatchObject({ schema_version: 1, status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED' }, exit_code: 3 });
});
});
+6 -1
View File
@@ -1460,8 +1460,13 @@ describe('scan: option-like bases and page-controlled inline ignores', () => {
fs.mkdirSync(many, { recursive: true });
for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}');
try {
const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } });
const log = path.join(SANDBOX, 'argv-many.log');
fs.rmSync(log, { force: true });
const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files
const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]);
expect(calls).toHaveLength(1);
expect(calls[0].some(x => x.endsWith(path.join('src', 'many')))).toBe(true);
const files = fs.readdirSync(many).map(f => path.join('src', 'many', f));
const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } });
expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 7500ms exceeded, \d+ of 11 batches not run/);
+92
View File
@@ -622,6 +622,98 @@ describe('gstack-developer-profile --check-mismatch', () => {
expect(r.stdout).toContain('scope_appetite');
expect(r.stdout).toContain('autonomy');
});
const deriveWithOneSignal = () => {
logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'fix-now', session_id: 's0' });
for (let i = 1; i < 13; i++) {
logQuestion({ skill: 'plan-eng-review', question_id: `adhoc-unregistered-${i}`, question_summary: 'x', user_choice: 'anything', session_id: `s${i}` });
}
expect(runDev('--derive').status).toBe(0);
const file = path.join(tmpHome, 'developer-profile.json');
const p = readProfile() as any;
expect(p.inferred.contributing_events.scope_appetite).toBe(0);
p.declared = { scope_appetite: 0.9 };
fs.writeFileSync(file, JSON.stringify(p));
};
test('a declared dimension with no observed evidence is not flagged (#3436)', () => {
deriveWithOneSignal();
const r = runDev('--check-mismatch');
expect(r.status).toBe(0);
expect(r.stdout).not.toContain('scope_appetite: declared');
expect(r.stdout).toContain('scope_appetite: no observed evidence');
});
test('--gap leaves out a dimension with no observed evidence (#3436)', () => {
deriveWithOneSignal();
const out = JSON.parse(runDev('--gap').stdout);
expect(out.gap.scope_appetite).toBeUndefined();
expect(out.low_evidence).toEqual({ scope_appetite: 0 });
});
test('a dimension with 1 or 2 contributing events reports too little evidence', () => {
runDev('--read');
const file = path.join(tmpHome, 'developer-profile.json');
const p = readProfile() as any;
p.declared = { scope_appetite: 0.9 };
p.inferred.values.scope_appetite = 0.45;
p.inferred.sample_size = 10;
p.inferred.contributing_events = { scope_appetite: 2 };
fs.writeFileSync(file, JSON.stringify(p));
const r = runDev('--check-mismatch');
expect(r.stdout).toContain('MISMATCH: none');
expect(r.stdout).toContain('scope_appetite: too little evidence (2 of 3 events)');
expect(JSON.parse(runDev('--gap').stdout).low_evidence).toEqual({ scope_appetite: 2 });
});
test('a dimension with 3 contributing events is compared', () => {
runDev('--read');
const file = path.join(tmpHome, 'developer-profile.json');
const p = readProfile() as any;
p.declared = { scope_appetite: 0.9 };
p.inferred.values.scope_appetite = 0.45;
p.inferred.sample_size = 10;
p.inferred.contributing_events = { scope_appetite: 3 };
fs.writeFileSync(file, JSON.stringify(p));
expect(runDev('--check-mismatch').stdout).toContain('scope_appetite: declared 0.9 vs inferred 0.45 (gap 0.45)');
expect(JSON.parse(runDev('--gap').stdout).gap.scope_appetite).toBeCloseTo(0.45, 2);
});
test('three same-day answers that agree with the declaration are not a mismatch', () => {
for (let i = 0; i < 3; i++) {
logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-mode', question_summary: 'mode?', user_choice: 'expand', session_id: `s${i}`, ts: '2026-10-20T12:00:00Z' });
}
expect(runDev('--derive').status).toBe(0);
const file = path.join(tmpHome, 'developer-profile.json');
const p = readProfile() as any;
expect(p.inferred.contributing_events.scope_appetite).toBe(3);
p.declared = { scope_appetite: 0.9 };
fs.writeFileSync(file, JSON.stringify(p));
const r = runDev('--check-mismatch');
expect(r.stdout).toContain('not enough data');
expect(r.stdout).not.toContain('scope_appetite: declared');
});
test('derive stores the gap for a dimension with 3 contributing events', () => {
for (let i = 0; i < 3; i++) {
logQuestion({ skill: 'plan-eng-review', question_id: 'plan-eng-review-arch-finding', question_summary: 'arch?', user_choice: 'fix-now', session_id: `s${i}`, ts: `2026-10-2${i}T12:00:00Z` });
}
expect(runDev('--derive').status).toBe(0);
const file = path.join(tmpHome, 'developer-profile.json');
const p = readProfile() as any;
expect(p.inferred.contributing_events.architecture_care).toBe(3);
p.declared = { architecture_care: 0.1 };
fs.writeFileSync(file, JSON.stringify(p));
expect(runDev('--derive').status).toBe(0);
expect((readProfile() as any).gap.architecture_care).toBeGreaterThan(0.3);
});
test('derive stores no gap for a dimension without evidence', () => {
deriveWithOneSignal();
expect(runDev('--derive').status).toBe(0);
const p = readProfile() as any;
expect(p.gap.scope_appetite).toBeUndefined();
});
});
// -----------------------------------------------------------------------
+6 -5
View File
@@ -860,8 +860,9 @@ do not launch the downstream skill or open a browser.`,
// ── Multi-agent wave PR D3 (2026-10-10): investigate recipes carve ──
// Five lazy recipes (flake, polluter, race, eval-bisect, backlog) read when
// the Phase 2 pattern or the --backlog argument matches; Phases 1–5, the
// Iron Law and the Scope Lock stay always-loaded.
// the Recipes list or the --backlog argument matches; How to work, the
// Iron Law, the Scope Lock and the Report stay always-loaded (GST-938 dropped
// the Phases 1-5 prose: followed in 0 of 46 Opus 5.5 sessions).
investigate: {
skill: 'investigate',
expectedSections: ['flake.md', 'polluter.md', 'race.md', 'eval-bisect.md', 'backlog.md', 'blast-radius.md'],
@@ -872,10 +873,10 @@ do not launch the downstream skill or open a browser.`,
mustStayInSkeleton: [
'## Iron Law',
'## Arguments',
'## Phase 1: Root Cause Investigation',
'## How to work',
'## Scope Lock',
'## Phase 3: Hypothesis Testing',
'## Phase 5: Verification & Report',
'## Recipes',
'## Report',
'git update-index --refresh',
],
mustPrecedeStop: ['## Arguments'],
+1 -1
View File
@@ -23,7 +23,7 @@ const COPIED = [
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
'bin/gstack-launch-probe.sh',
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', // the windows bash row
'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', // the windows bash row
];
const bases: string[] = [];
/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */
+1
View File
@@ -212,6 +212,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'],
'test/fixtures/sealed-guard-off-golden.json': ['test/sealed-guard.test.ts'],
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
'test/fixtures/shadow-fake-model.ts': ['test/autoplan-shadow-driver.test.ts', 'test/autoplan-shadow.test.ts'],
'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'],
'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'],
'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],
+6 -2
View File
@@ -52,7 +52,11 @@ export function rebindGitDir(gitDir: string, from: string, to: string): void {
}
}
/** Files and symlinks under dir (and the template's meta value) that still name templateRoot; zlib-compressed object storage is skipped. */
/**
* Files and symlinks under dir (and the template's meta value) that still name templateRoot. Skipped: zlib-compressed object
* storage, and the log Capy's /usr/bin/git shim appends to in whatever HOME it runs under (.capy/git-shim.log), which names
* the template whenever a fixture home ran git during build; nothing reads it back.
*/
export function templateReferences(dir: string, templateRoot: string, meta?: unknown): string[] {
const needles = pathSpellings(templateRoot).map(s => Buffer.from(s));
const names = (buf: Buffer) => needles.some(n => buf.includes(n));
@@ -62,7 +66,7 @@ export function templateReferences(dir: string, templateRoot: string, meta?: unk
const p = path.join(d, entry.name);
if (entry.isSymbolicLink()) { if (names(Buffer.from(fs.readlinkSync(p)))) hits.push(p); }
else if (entry.isDirectory()) { if (!(path.basename(d) === 'objects' && /^(pack|[0-9a-f]{2})$/.test(entry.name))) walk(p); }
else if (entry.isFile() && names(fs.readFileSync(p))) hits.push(p);
else if (entry.isFile() && !(entry.name === 'git-shim.log' && path.basename(d) === '.capy') && names(fs.readFileSync(p))) hits.push(p);
}
};
walk(dir);
+2
View File
@@ -56,6 +56,8 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
// /eval-plan (plan E1): the pilot's ledger inputs.
'<cap-usd>': { grammar: NUMBER, what: 'spend cap in USD' },
'<total-items>': { grammar: NUM, what: 'items in the full eval run' },
'<tap-x>': { grammar: NUM, what: 'viewport x in CSS pixels for the Aside touch recipe' },
'<tap-y>': { grammar: NUM, what: 'viewport y in CSS pixels for the Aside touch recipe' },
'<workers>': { grammar: NUM, what: 'concurrent eval workers' },
'<attempt-id>': { grammar: ID, what: 'PILOT_ATTEMPT id printed by pilot start' },
'<usd|unknown>': { grammar: /^(?:[0-9]+(?:\.[0-9]+)?|unknown)$/, what: 'actual cost in USD from the provider usage field, or unknown' },
+15 -15
View File
@@ -2,7 +2,7 @@
* Plan D3: investigate recipes and backlog mode. Pins the carve shape (manifest
* ids, STOP pointers, the Arguments table routing `--backlog`), the tokens each
* recipe must carry (commands, the `## Flake evidence` section, the four backlog
* classes, the never-retry rule), the Phase 1 additions, the benchmark
* classes, the never-retry rule), the How to work lessons, the benchmark
* `git update-index --refresh` line, and that an external host gets the recipes
* inlined. Prose is not pinned.
*/
@@ -24,7 +24,7 @@ describe('investigate carve', () => {
expect(CARVE_GUARDS.investigate!.expectedSections).toEqual(manifest.sections.map(s => s.file));
for (const s of manifest.sections) expect(fs.existsSync(path.join(ROOT, 'investigate', 'sections', `${s.file}.tmpl`))).toBe(true);
});
test('the skeleton routes: Arguments table with --backlog before the first STOP, one STOP per recipe, recipes after the Phase 2 pattern table', () => {
test('the skeleton routes: Arguments table with --backlog before the first STOP, one STOP per recipe, recipes between the Scope Lock and the Report', () => {
const args = skeleton.indexOf('## Arguments');
const firstStop = skeleton.indexOf('> **STOP.**');
expect(args).toBeGreaterThan(0);
@@ -32,15 +32,15 @@ describe('investigate carve', () => {
expect(skeleton).toMatch(/\| `\/investigate --backlog \[<issue numbers or gh query>\]` \| Read-only triage/);
for (const id of RECIPES) expect(skeleton).toContain(`sections/${id}.md`);
expect(skeleton.match(/> \*\*STOP\.\*\*/g)!.length).toBe(RECIPES.length);
const recipes = skeleton.indexOf('### Recipes');
expect(recipes).toBeGreaterThan(skeleton.indexOf('## Phase 2: Pattern Analysis'));
expect(recipes).toBeLessThan(skeleton.indexOf('## Phase 3: Hypothesis Testing'));
const recipes = skeleton.indexOf('## Recipes');
expect(recipes).toBeGreaterThan(skeleton.indexOf('## Scope Lock'));
expect(recipes).toBeLessThan(skeleton.indexOf('## Report'));
expect(skeleton).not.toContain('## Flake recipe');
});
test('Phase 1 additions: call sites before calling two queries duplicates; update-index before timing git in a worktree (investigate and benchmark)', () => {
const phase1 = skeleton.slice(skeleton.indexOf('## Phase 1: Root Cause Investigation'), skeleton.indexOf('## Scope Lock'));
expect(phase1).toMatch(/Before calling two queries, helpers or code paths duplicates, read their call sites/);
expect(phase1).toContain('`git update-index --refresh`');
test('How to work lessons: call sites before calling two queries duplicates; update-index before timing git in a worktree (investigate and benchmark)', () => {
const how = skeleton.slice(skeleton.indexOf('## How to work'), skeleton.indexOf('## Scope Lock'));
expect(how).toMatch(/Before calling two queries, helpers or code paths duplicates, read their call sites/);
expect(how).toContain('`git update-index --refresh`');
const benchmark = read('benchmark/SKILL.md');
expect(benchmark).toContain('git update-index --refresh >/dev/null 2>&1 || true');
expect(benchmark).toMatch(/before timing anything that spawns git/);
@@ -95,7 +95,7 @@ describe('recipe tokens', () => {
expect(backlog).toMatch(/no `gh issue close`/);
expect(backlog).not.toMatch(/gh issue close <n>/);
});
test('blast-radius: the one safety fact, the five-rung ladder, look where grep stops, confirmed and cleared risks apart, the Phase 4 pointer and the shared-premise red flag', () => {
test('blast-radius: the one safety fact, the five-rung ladder, look where grep stops, confirmed and cleared risks apart, the Recipes pointer and the shared-premise red flag', () => {
const blast = read('investigate/sections/blast-radius.md');
expect(blast).toContain('## Blast radius');
expect(blast).toContain('**Name the one fact it is safe because of.**');
@@ -104,11 +104,11 @@ describe('recipe tokens', () => {
expect(blast).toContain('**Keep confirmed risks and cleared risks apart.**');
expect(blast).toContain('the word "unproven"');
const skeleton = read('investigate/SKILL.md');
const phase4 = skeleton.slice(skeleton.indexOf('## Phase 4: Implementation'), skeleton.indexOf('## Phase 5: Verification & Report'));
expect(phase4).toContain('sections/blast-radius.md');
expect(phase4.indexOf('sections/blast-radius.md')).toBeLessThan(phase4.indexOf('**Fix the root cause, not the symptom.**'));
const phase3 = skeleton.slice(skeleton.indexOf('## Phase 3: Hypothesis Testing'), skeleton.indexOf('## Phase 4: Implementation'));
expect(phase3).toMatch(/Two fixes that shared one premise failed the same gate — write the premise down in one sentence and test it before a third fix/);
const recipes = skeleton.slice(skeleton.indexOf('## Recipes'), skeleton.indexOf('## Report'));
expect(recipes).toContain('sections/blast-radius.md');
const how = skeleton.slice(skeleton.indexOf('## How to work'), skeleton.indexOf('## Scope Lock'));
expect(how).toMatch(/search the generic error class, library and version, never the raw error text, hostnames, IPs, file paths, SQL, customer data or secrets/);
expect(how).toMatch(/Two fixes that shared one premise failed the same gate — write the premise down in one sentence and test it before a third fix/);
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ const BUN_MISSING = 'gstack-learnings-search: bun not found on PATH, so learning
const CALLERS: Array<{ name: string; file: (host: string) => string; hosts: string[] }> = [
{ name: 'Prior Learnings (review)', file: h => (h === 'claude' ? 'review/SKILL.md' : '.agents/skills/gstack-review/SKILL.md'), hosts: ['claude', 'codex'] },
{ name: 'investigate hypothesis refresh', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'] },
{ name: 'investigate prior learnings', file: h => (h === 'claude' ? 'investigate/SKILL.md' : '.agents/skills/gstack-investigate/SKILL.md'), hosts: ['claude', 'codex'] },
{ name: 'qa fix-loop search', file: h => (h === 'claude' ? 'qa/SKILL.md' : '.agents/skills/gstack-qa/SKILL.md'), hosts: ['claude', 'codex'] },
{ name: 'ship adversarial search', file: h => (h === 'claude' ? 'ship/sections/adversarial.md' : '.agents/skills/gstack-ship/sections/adversarial.md'), hosts: ['claude', 'codex'] },
{ name: 'review army specialist search', file: () => 'review/sections/review-army.md', hosts: ['claude'] },
+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);
+2 -1
View File
@@ -312,7 +312,8 @@ describe('Office Hours real session runner with fake processes', () => {
expect(captured.output).toBe('captured output');
const diagnostic = JSON.parse(fs.readFileSync(path.join(dir, '.gstack/test-transcripts/drain-failure.json'), 'utf8'));
expect(diagnostic.stderr).toContain('fixture auth failure');
expect(running(Number(fs.readFileSync(path.join(dir, 'child.pid'), 'utf8')))).toBe(false);
const child = fs.readFileSync(path.join(dir, 'child.pid'), 'utf8').trim();
expect(running(Number(child)), `held-pipe child after the runner returned (group ${fs.readFileSync(path.join(dir, 'parent.pid'), 'utf8').trim()}): ${spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,stat=,etime=', '-p', child], { encoding: 'utf8', timeout: 5_000 }).stdout.trim() || 'gone now'}`).toBe(false);
});
}, 10_000);
}
+1 -1
View File
@@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
// These imports are resolved relative to the copied install, not the source checkout.
const libDir = path.join(workDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts']) {
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts', 'result-codes-clean.ts']) {
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
}
+44 -7
View File
@@ -6,11 +6,12 @@
* the base branch and every other open PR, and a lookup that cannot finish is
* `unknown`, which withholds `REGATE: not-needed`.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
import { gitFixtureTemplate, rebindFile, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template';
import { BASE_POLICY, cleanup, fixtureRepoAt, makePr, recordAndBundle, restamp, runBin, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
@@ -39,10 +40,14 @@ console.error("gh shim: unsupported "+a.join(" ")); process.exit(1)' "${file}" "
}
const ghCalls = (repo: FixtureRepo) => fs.readFileSync(path.join(repo.root, 'gh-regate', 'calls.log'), 'utf8');
type Lanes = Array<{ label: string; command: string }>;
const TESTS_LANE: Lanes = [{ label: 'tests', command: 'true' }];
const DIGEST_LANES: Lanes = [...TESTS_LANE, { label: 'digest', command: 'cat generated/digest.md' }];
/** pr-a adds migration v240, is stamped 1.2.4.0 and gated (lanes recorded and bundled); then restamped to 1.2.5.0. */
function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [{ label: 'tests', command: 'true' }]) {
const repo = makeFixtureRepo({ prefix: 'pregate-regate-', policy: POLICY });
repos.push(repo);
function buildGated(root: string, lanes: Lanes) {
seedFixtureRepo(root, { policy: POLICY });
const repo = fixtureRepoAt(root);
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
fs.mkdirSync(path.join(repo.work, 'migrations'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'migrations', 'v240-add-index.sql'), 'create index a;\n');
@@ -54,7 +59,39 @@ function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [
const head = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.5.0');
return { repo, head, bundle, current: repo.git('rev-parse', 'HEAD') };
return { head, current: repo.git('rev-parse', 'HEAD') };
}
// Each fixture costs four gstack bin runs (two restamps, an evidence run and a bundle); build each lane set once
// and give every test its own copy instead of rebuilding it per test.
const templates = new Map<Lanes, GitFixtureTemplate<ReturnType<typeof buildGated>>>();
beforeAll(() => {
for (const lanes of [TESTS_LANE, DIGEST_LANES]) {
const template = gitFixtureTemplate({
prefix: 'pregate-regate-',
build: root => buildGated(root, lanes),
rebind: (copy, tpl) => {
rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy);
rebindFile(path.join(copy, 'bundle.json'), tpl, copy);
const projects = path.join(copy, 'home', 'projects');
for (const slug of fs.existsSync(projects) ? fs.readdirSync(projects) : []) {
const dir = path.join(projects, slug.split(path.basename(tpl)).join(path.basename(copy)));
if (dir !== path.join(projects, slug)) fs.renameSync(path.join(projects, slug), dir);
for (const file of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) if (file.isFile()) rebindFile(path.join(file.parentPath, file.name), tpl, copy);
}
fs.rmSync(path.join(copy, 'home', '.capy'), { recursive: true, force: true });
},
});
template.copy().root && templates.set(lanes, template);
}
}, 60_000);
afterAll(() => { for (const t of templates.values()) t.dispose(); });
function gatedThenRestamped(lanes: Lanes = TESTS_LANE) {
const { root, meta: { head, current } } = templates.get(lanes)!.copy();
const repo = fixtureRepoAt(root);
repos.push(repo);
return { repo, head, bundle: path.join(repo.root, 'bundle.json'), current };
}
const regate = (repo: FixtureRepo, args: string[], env: Record<string, string>) => runBin(repo, 'gstack-pregate', ['regate', '--base', 'main', ...args], env);
@@ -97,7 +134,7 @@ describe('gstack-pregate regate: evidence, stamps and lane verdicts', () => {
});
test('a stamp-sensitive lane reruns, so a regate is needed; a code edit is not stamp-only and names the file and line', () => {
const { repo, head, bundle } = gatedThenRestamped([{ label: 'tests', command: 'true' }, { label: 'digest', command: 'cat generated/digest.md' }]);
const { repo, head, bundle } = gatedThenRestamped(DIGEST_LANES);
const env = ghShim(repo, { list: [] });
const r = regate(repo, ['--against', head, '--bundle', bundle], env);
expect(r.status).toBe(EXIT.fail);
@@ -120,3 +120,13 @@ printf 200
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
});
describe('CI runs these cases on the BSD userland itself', () => {
// Value: protects=the cases above run where /usr/bin/tar and /usr/bin/awk are the BSD tools; fails_when=macos-named-regressions stops naming this file, so Linux CI (no bsdtar or one-true-awk installed) skips every case and a #3680 or #3679 regression ships green; why_new=the file was only in the Linux free suite; seam=.github/workflows/free-tests.yml; no_claim=the job's runner image or its tool versions
test('free-tests.yml macos-named-regressions names this file', () => {
const workflow = Bun.YAML.parse(readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
const mac = workflow.jobs['macos-named-regressions'];
expect(mac['runs-on']).toMatch(/^macos-/);
expect((mac.steps as any[]).map(step => String(step.run ?? '')).join('\n')).toContain('test/regression-issue3680-ubicloud-bsd-userland.test.ts');
});
});
+127
View File
@@ -683,6 +683,133 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
const v = run(['verify', '--ledger', ledger]);
expect([v.status, v.stdout.trim()]).toEqual([3, "VERIFY: no brief records (this run's ledger kept no prompts; dispatched-vs-rendered cannot be checked)"]);
});
// Value: protects=verify re-derives every merged row from the reader's unchanged output by its index, so a merged.jsonl edited
// after collect cannot lower a finding or swap a blocker's text for a summary and still verify clean; fails_when=a forged label
// or a copied raw line on a real blocker's id verifies clean (GST-541: both did at main c830c9b), or a clean run collected through a
// symlinked run directory and judged there with relative paths verifies dirty (GST-541 ship2-p11); why_new=verify compared only
// output hashes and line counts; seam=verify; no_claim=that an output collected without a recorded file can be re-derived
test('verify re-derives each merged row by index: a forged label or a summary copied onto a blocker id is dirty', () => {
const blocker = '{"severity":"CRITICAL","path":"log.ts","line":3,"category":"security","summary":"bearer token written to a public log","specialist":"testing"}';
const summary = '{"severity":"INFORMATIONAL","path":"a.ts","line":9,"category":"style","summary":"I found nothing CRITICAL.","specialist":"testing"}';
const setup = () => {
const p = pass();
run(['sent', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--sent', p.file]);
const out = p.w('spec.out', blocker + '\n' + summary + '\n');
expect(run(['collect', '--ledger', p.ledger, '--role', 'specialist', '--name', 'testing', '--output', out, '--merged', p.merged]).status).toBe(0);
return p;
};
const judgeAll = (p: ReturnType<typeof pass>, severity: string) => run(['judge', '--ledger', p.ledger, '--merged', p.merged, '--judged', p.w('judged.jsonl',
readJsonl<MergedLine>(p.merged).map(m => JSON.stringify({ id: m.id, severity: m.id.endsWith('#1') ? severity : 'INFORMATIONAL' })).join('\n') + '\n')]);
const clean = setup();
expect(judgeAll(clean, 'CRITICAL').status).toBe(0);
expect(run(['verify', '--ledger', clean.ledger]).stdout).toContain('VERIFY: clean');
const forge = (p: ReturnType<typeof pass>, edit: (rows: MergedLine[]) => void) => {
const rows = readJsonl<MergedLine>(p.merged);
edit(rows);
fs.writeFileSync(p.merged, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
expect(judgeAll(p, 'INFORMATIONAL').stdout.trim()).toBe('JUDGMENT: ok added=0 promoted=0 folded=0');
return run(['verify', '--ledger', p.ledger]);
};
const label = forge(setup(), rows => { rows[0]!.label = 'INFORMATIONAL'; });
expect([label.status, label.stdout]).toEqual([1, expect.stringContaining('merged_line_differs=["specialist/testing#1"]')]);
const copied = forge(setup(), rows => { rows[0]!.label = rows[1]!.label; rows[0]!.raw = rows[1]!.raw; });
expect([copied.status, copied.stdout]).toEqual([1, expect.stringContaining('merged_line_differs=["specialist/testing#1"]')]);
expect(copied.stdout).toMatch(/^JUDGE - MISMATCH .*merged_line_differs/m);
const gone = setup();
fs.rmSync(path.join(gone.out, 'spec.out'));
const missing = forge(gone, rows => { rows[0]!.label = 'INFORMATIONAL'; });
expect([missing.status, missing.stdout.includes('output_file_missing'), missing.stdout.includes('merged_line_differs=["specialist/testing#1","specialist/testing#2"]')]).toEqual([1, true, true]);
const copy = setup();
const forgedCopy = copy.w('merged2.jsonl', readJsonl<MergedLine>(copy.merged).map((r, k) => JSON.stringify(k ? r : { ...r, label: 'INFORMATIONAL' })).join('\n') + '\n');
expect(run(['judge', '--ledger', copy.ledger, '--merged', forgedCopy, '--judged', copy.w('judged.jsonl', readJsonl<MergedLine>(copy.merged).map(m => JSON.stringify({ id: m.id, severity: 'INFORMATIONAL' })).join('\n') + '\n')]).status).toBe(0);
const viaCopy = run(['verify', '--ledger', copy.ledger]);
expect([viaCopy.status, viaCopy.stdout]).toEqual([1, expect.stringMatching(/^JUDGE - MISMATCH .*judge_merged_not_collected/m)]);
const relinked = pass();
const linkDir = path.join(tmp(), 'run');
fs.symlinkSync(relinked.out, linkDir);
const ledgerViaLink = path.join(linkDir, 'review-ledger.jsonl');
run(['sent', '--ledger', ledgerViaLink, '--role', 'specialist', '--name', 'testing', '--sent', relinked.file]);
const linkOut = relinked.w('spec.out', blocker + '\n' + summary + '\n');
expect(run(['collect', '--ledger', ledgerViaLink, '--role', 'specialist', '--name', 'testing', '--output', path.join(linkDir, path.basename(linkOut)), '--merged', path.join(linkDir, 'merged.jsonl')]).status).toBe(0);
relinked.w('judged.jsonl', readJsonl<MergedLine>(relinked.merged).map(m => JSON.stringify({ id: m.id, severity: m.label })).join('\n') + '\n');
expect(run(['judge', '--ledger', 'review-ledger.jsonl', '--merged', 'merged.jsonl', '--judged', 'judged.jsonl'], linkDir).status).toBe(0);
const linked = run(['verify', '--ledger', ledgerViaLink]);
expect([linked.status, linked.stdout.includes('judge_merged_not_collected'), linked.stdout.includes('VERIFY: clean')]).toEqual([0, false, true]);
const quiet = pass();
run(['sent', '--ledger', quiet.ledger, '--role', 'specialist', '--name', 'testing', '--sent', quiet.file]);
const none = quiet.w('none.out', 'NO FINDINGS\n');
expect(run(['collect', '--ledger', quiet.ledger, '--role', 'specialist', '--name', 'testing', '--output', none, '--merged', quiet.merged]).status).toBe(0);
fs.writeFileSync(quiet.merged, '');
expect(run(['judge', '--ledger', quiet.ledger, '--merged', quiet.merged, '--judged', quiet.w('judged.jsonl', '')]).status).toBe(0);
fs.rmSync(none);
const noneGone = run(['verify', '--ledger', quiet.ledger]);
expect([noneGone.status, noneGone.stdout.includes('output_file_missing')]).toEqual([1, true]);
const extra = forge(setup(), rows => { rows[0]!.label = 'INFORMATIONAL'; rows.push({ ...rows[1]!, id: 'specialist/testing#9' }); });
expect([extra.status, extra.stdout.includes('merged_rows_not_collected=["specialist/testing#9"]')]).toEqual([1, true]);
});
// Value: protects=an output event edited to read like a refused reply (no ids, review false, tree_ok false) or to drop its file
// cannot drop its findings and still verify clean, while a reply collect really refused for its tree stays NOT_REVIEWED until a re-sent brief is answered;
// fails_when=verify skips the count check on the editable review or tree_ok field, so a clean marker keeps the output MATCH or a
// later review voids it, or an output with no recorded file passes on its review field (fix-checks at 721539c, 56ed6e3 and 50dc73c: VERIFY: clean); why_new=the GST-482 count exemption trusted collect's
// fields; seam=verify; no_claim=that a deleted output event is caught (#3719)
test('an output event edited to look refused, or a refused one edited to look accepted, is dirty; a real wrong-tree reply is NOT_REVIEWED, then voided by a review', () => {
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
const findings = '1. **CRITICAL**: auth.ts:3 leaks a token\nBlocks: yes\n\nRecommendation: request changes\n';
const voice = (reply: string) => {
const out = tmp();
const ledger = path.join(out, 'review-ledger.jsonl');
const merged = path.join(out, 'merged.jsonl');
const brief = field(run(['render', '--role', 'outside-voice', '--out', out, '--diff-base', head]).stdout, 'file');
const send = () => run(['sent', '--ledger', ledger, '--role', 'outside-voice', '--sent', brief]);
let n = 0;
const take = (text: string) => { const f = path.join(out, `ov${++n}.out`); fs.writeFileSync(f, text); return run(['collect', '--ledger', ledger, '--role', 'outside-voice', '--output', f, '--merged', merged]); };
send();
const c = take(reply);
const forge = (edit: Record<string, unknown>) => {
fs.writeFileSync(ledger, readJsonl<Record<string, unknown>>(ledger).map(e => JSON.stringify(e.event === 'output' ? { ...e, ...edit } : e)).join('\n') + '\n');
fs.writeFileSync(merged, '');
};
const verify = () => {
fs.writeFileSync(path.join(out, 'judged.jsonl'), '');
expect(run(['judge', '--ledger', ledger, '--merged', merged, '--judged', path.join(out, 'judged.jsonl')]).status).toBe(0);
return run(['verify', '--ledger', ledger]);
};
return { c, send, take, forge, verify };
};
const noIds = voice(findings);
expect(noIds.c.status).toBe(0);
noIds.forge({ ids: [], review: false });
const v = noIds.verify();
expect([v.status, v.stdout.includes('merged_count_differs=1/0'), v.stdout.includes('VERIFY: dirty')]).toEqual([1, true, true]);
const unfiled = voice(findings);
unfiled.forge({ file: null, ids: [] });
const u = unfiled.verify();
expect([u.status, u.stdout.includes('output_file_unrecorded'), u.stdout.includes('VERIFY: dirty')]).toEqual([1, true, true]);
const flagged = voice(findings);
flagged.forge({ ids: [], review: false, tree_ok: false });
flagged.send();
expect(flagged.take('Recommendation: approve\n').status).toBe(0);
const f = flagged.verify();
expect([f.status, f.stdout.includes('merged_count_differs=1/0'), f.stdout.includes('VERIFY: dirty')]).toEqual([1, true, true]);
const wrong = voice(`REVIEWED_TREE: ${'b'.repeat(40)} /elsewhere\n` + findings);
expect([wrong.c.status, wrong.c.stdout.startsWith('COLLECT_WRONG_TREE: reader=outside-voice')]).toEqual([1, true]);
const w = wrong.verify();
expect([w.status, /^OUTPUT outside-voice NOT_REVIEWED .*wrong_tree/m.test(w.stdout), w.stdout.includes('merged_count_differs')]).toEqual([1, true, false]);
wrong.send();
expect(wrong.take('Recommendation: approve\n').status).toBe(0);
const r = wrong.verify();
expect([r.status, r.stdout.includes('replaced_by_a_review'), r.stdout.includes('VERIFY: clean briefs=1 outputs=2 voided=1')]).toEqual([0, true, true]);
const elsewhere = pass();
run(['sent', '--ledger', elsewhere.ledger, '--role', 'specialist', '--name', 'testing', '--sent', elsewhere.file]);
const refused = run(['collect', '--ledger', elsewhere.ledger, '--role', 'specialist', '--name', 'testing', '--output', elsewhere.w('else.out', `REVIEWED_TREE: ${'b'.repeat(40)} /elsewhere\nNO FINDINGS\n`), '--merged', elsewhere.merged]);
expect(refused.stdout.startsWith('COLLECT_WRONG_TREE: reader=specialist/testing')).toBe(true);
fs.writeFileSync(elsewhere.ledger, readJsonl<Record<string, unknown>>(elsewhere.ledger).map(e => JSON.stringify(e.event === 'output' ? { ...e, tree_ok: true, review: true, reported_tree: e.tree } : e)).join('\n') + '\n');
fs.writeFileSync(elsewhere.merged, '');
expect(run(['judge', '--ledger', elsewhere.ledger, '--merged', elsewhere.merged, '--judged', elsewhere.w('judged.jsonl', '')]).status).toBe(0);
const e = run(['verify', '--ledger', elsewhere.ledger]);
expect([e.status, /^OUTPUT specialist\/testing NOT_REVIEWED .*wrong_tree/m.test(e.stdout), e.stdout.includes('VERIFY: dirty')]).toEqual([1, true, true]);
});
});
describe('the workspace paragraph (GST-482)', () => {
+2 -2
View File
@@ -215,7 +215,7 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
const ranged = run(['--range', 'origin/main..HEAD', '--base', 'main']);
expect(ranged.status).toBe(1);
expect(ranged.stderr).toMatch(/sealed\.transcript\s+runs\/c1\/transcript\.jsonl/);
expect(ranged.stdout).toMatch(/^SEALED: refused findings=1 commits=1 visibility=unknown \(counted public\)/m);
expect(ranged.stdout).toMatch(/^SEALED: refused findings=1 commits=1 skipped=0 visibility=unknown \(counted public\)/m);
const dflt = run(['--base', 'main']);
expect(dflt.status).toBe(1);
sh(s.work, s.env, ['push', '-q', 'origin', 'results']);
@@ -252,7 +252,7 @@ describe('sealed.allow_paths: base-branch exceptions applied after the deny glob
expect(hook(only).code).toBe(0);
const pre = preflight(only);
expect(pre.code).toBe(0);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
const json = JSON.parse(preflight(only, ['--json']).out);
expect(json.exempt).toHaveLength(1);
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
+196
View File
@@ -0,0 +1,196 @@
/**
* GSTA-156 P1: the sealed guard rescans only what the destination does not
* already hold. gstack-evals#22 was retargeted onto main after its parent #6
* squash-landed; its range still listed #6's seven commits (one added and
* later deleted a held-out file), so the guard refused a push whose every
* byte the destination already had as refs/pull/6/head. A commit is skipped
* only when it is an ancestor of the destination's own refs/pull/<n>/head for
* a squash (#n) on the base since the merge-base, or when its own
* --verbatim patch-id and diff equal a base commit's. No combined-diff
* matching; any lookup failure scans everything.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush');
const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate');
const ZERO = '0'.repeat(40);
const POLICY = JSON.stringify({ sealed: { paths: ['heldout/**'], answer_markers: ['SEALED ANSWER:'], local_paths: true } }, null, 2) + '\n';
interface Scratch { root: string; work: string; origin: string; state: string; env: Record<string, string> }
const scratches: Scratch[] = [];
afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); });
function sh(s: Scratch, args: string[]): string {
const r = spawnSync('git', args, { cwd: s.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...s.env } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return (r.stdout ?? '').trim();
}
function scratch(): Scratch {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-skip-')));
const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state');
fs.mkdirSync(work); fs.mkdirSync(state);
const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const s = { root, work, origin, state, env };
scratches.push(s);
spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(s, ['init', '-q', '-b', 'main']);
commit(s, { 'README.md': '# evals\n', '.gstack/pregate.json': POLICY }, 'base');
sh(s, ['remote', 'add', 'origin', origin]);
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['remote', 'set-head', 'origin', 'main']);
return s;
}
function commit(s: Scratch, files: Record<string, string | null>, msg: string): string {
for (const [rel, body] of Object.entries(files)) {
const abs = path.join(s.work, rel);
if (body === null) { fs.rmSync(abs, { force: true }); continue; }
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
sh(s, ['add', '-A']); sh(s, ['commit', '-q', '--allow-empty', '-m', msg]);
return sh(s, ['rev-parse', 'HEAD']);
}
/** The #22 shape: parent #6 has seven commits (the second adds heldout/answers.json, the fifth deletes it); main lands it as one squash "(#6)"; results stacks one commit on the parent. */
function retargeted(s: Scratch, opts: { publishPull: boolean }): void {
sh(s, ['checkout', '-q', '-b', 'parent']);
commit(s, { 'cells/a.md': 'a\n' }, 'cell a');
commit(s, { 'heldout/answers.json': '{"q1":"b"}\n' }, 'stage answers');
commit(s, { 'cells/b.md': 'b\n' }, 'cell b');
commit(s, { 'cells/c.md': 'c\n' }, 'cell c');
commit(s, { 'heldout/answers.json': null }, 'drop answers');
commit(s, { 'cells/d.md': 'd\n' }, 'cell d');
commit(s, { 'cells/e.md': 'e\n' }, 'cell e');
if (opts.publishPull) sh(s, ['push', '-q', 'origin', 'parent:refs/pull/6/head']);
sh(s, ['checkout', '-q', 'main']);
sh(s, ['merge', '-q', '--squash', 'parent']);
sh(s, ['commit', '-q', '-m', 'feat: cells a-e (#6)']);
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', '-b', 'results', 'parent']);
commit(s, { 'cells/f.md': 'f\n' }, 'cell f');
}
function hook(s: Scratch, url = s.origin) {
const head = sh(s, ['rev-parse', 'HEAD']);
const r = spawnSync('bun', [PREPUSH, 'origin', url], { cwd: s.work, input: `refs/heads/results ${head} refs/heads/results ${ZERO}\n`, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, err: r.stderr ?? '' };
}
function preflight(s: Scratch, args: string[] = []) {
const r = spawnSync('bun', [PREGATE, 'sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
describe('sealed skips what the destination already holds', () => {
test('the #22 squash-retarget: seven commits published as refs/pull/6/head are skipped, the push is clear, the hook and the preflight agree, and --no-skip still refuses', () => {
const s = scratch();
retargeted(s, { publishPull: true });
const h = hook(s);
expect(h.err).toContain('skipped 7 commit(s) the destination already has as refs/pull/6/head');
expect(h.code).toBe(0);
const p = preflight(s);
expect(p.code).toBe(0);
expect(p.out).toMatch(/^SEALED: clear findings=0 commits=1 skipped=7 /m);
const full = preflight(s, ['--no-skip']);
expect(full.code).toBe(1);
expect(full.out).toMatch(/^SEALED: refused findings=1 commits=8 skipped=0 /m);
expect(full.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
expect(sh(s, ['for-each-ref', 'refs/gstack/'])).toBe('');
const ledger = fs.readFileSync(path.join(s.state, 'security', 'egress.jsonl'), 'utf8');
expect(ledger).toContain('"sink":"sealed-guard"');
});
test('an add-then-delete parent whose combined diff equals the squash but was never published still refuses', () => {
const s = scratch();
retargeted(s, { publishPull: false });
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
const p = preflight(s);
expect(p.code).toBe(1);
expect(p.out).toMatch(/^SEALED: refused findings=1 commits=8 skipped=0 /m);
});
test('a ref lookup failure scans everything and says so', () => {
const s = scratch();
retargeted(s, { publishPull: true });
sh(s, ['remote', 'set-url', 'origin', path.join(s.root, 'gone.git')]);
const h = hook(s, path.join(s.root, 'gone.git'));
expect(h.code).toBe(1);
expect(h.err).toContain('could not list refs/pull/<n>/head on origin');
expect(h.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
});
test('a cherry-picked commit whose patch landed on the base is skipped, naming the base commit', () => {
const s = scratch();
sh(s, ['checkout', '-q', '-b', 'results']);
const mine = commit(s, { 'notes.md': 'intro\nraw at /home/alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['checkout', '-q', 'main']);
commit(s, { 'other.md': 'x\n' }, 'unrelated');
sh(s, ['cherry-pick', mine]);
sh(s, ['push', '-q', 'origin', 'main']);
const landed = sh(s, ['rev-parse', 'HEAD']);
expect(landed).not.toBe(mine);
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(0);
expect(h.err).toContain(`skipped commit ${mine.slice(0, 12)} (its patch landed on the base as ${landed.slice(0, 12)})`);
expect(preflight(s).out).toMatch(/^SEALED: clear findings=0 commits=0 skipped=1 /m);
});
test('a whitespace near-miss of a landed commit is scanned and refuses', () => {
const s = scratch();
sh(s, ['checkout', '-q', '-b', 'results']);
commit(s, { 'notes.md': 'intro\nraw at /home/alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['checkout', '-q', 'main']);
commit(s, { 'notes.md': 'intro\nraw at /home/ alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.local_path\s+notes\.md:2/);
expect(preflight(s).out).toMatch(/^SEALED: refused findings=1 commits=1 skipped=0 /m);
});
test('a landed commit still gets the label_join check, because the join can form only on the branch', () => {
const s = scratch();
const rec = (extra: Record<number, string>) => ['{', ' "id": "r1",', ' "k1": 1,', ' "k2": 2,', ' "k3": 3,', ' "k4": 4,', ' "k5": 5,', ' "k6": 6,', ' "k7": 7', '}'].flatMap((l, i) => extra[i] ? [l, extra[i]!] : [l]).join('\n') + '\n';
const labeled = JSON.stringify({ sealed: { paths: ['heldout/**'], label_keys: ['stratum'] } }, null, 2) + '\n';
commit(s, { '.gstack/pregate.json': labeled, 'rec.json': rec({}) }, 'labeled policy');
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', '-b', 'results']);
commit(s, { 'rec.json': rec({ 1: ' "stratum": "decoy",' }) }, 'stratum');
const head = commit(s, { 'rec.json': rec({ 1: ' "stratum": "decoy",', 7: ' "head": "abc1234def5678",' }) }, 'head');
sh(s, ['checkout', '-q', 'main']);
sh(s, ['cherry-pick', head]);
sh(s, ['push', '-q', 'origin', 'main']);
expect(sh(s, ['show', 'HEAD:rec.json'])).not.toContain('stratum');
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.label_join\s+rec\.json/);
const p = preflight(s);
expect(p.code).toBe(1);
expect(p.out).toMatch(/^SEALED: refused findings=1 commits=1 skipped=1 /m);
});
test('a landed commit that differs only in bytes that are not UTF-8 is scanned and refuses', () => {
const s = scratch();
sh(s, ['checkout', '-q', '-b', 'results']);
fs.writeFileSync(path.join(s.work, 'notes.md'), Buffer.from('intro\nraw at /home/alice/evals/raw\xe8.jsonl\n', 'latin1'));
sh(s, ['add', 'notes.md']); sh(s, ['commit', '-q', '-m', 'notes']);
sh(s, ['checkout', '-q', 'main']);
fs.writeFileSync(path.join(s.work, 'notes.md'), Buffer.from('intro\nraw at /home/alice/evals/raw\xe9.jsonl\n', 'latin1'));
sh(s, ['add', 'notes.md']); sh(s, ['commit', '-q', '-m', 'notes']);
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.local_path\s+notes\.md:2/);
expect(preflight(s).out).toMatch(/^SEALED: refused findings=1 commits=1 skipped=0 /m);
});
});
+2 -1
View File
@@ -284,7 +284,8 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => {
const r = runScript(base, install, state);
expect(r.status).toBe(0);
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
expect(log).toMatch(/UP_TO_DATE|UPDATING/);
const lockPid = fs.existsSync(path.join(lockDir, 'pid')) ? fs.readFileSync(path.join(lockDir, 'pid'), 'utf8').trim() : 'no lock';
expect(log, `planted dead pid ${dead.pid} (alive now: ${isAlive(dead.pid)}); lock pid after 15 s: ${lockPid} (alive: ${isAlive(Number(lockPid))}); script exit ${r.status}, stderr: ${r.stderr}`).toMatch(/UP_TO_DATE|UPDATING/);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
+4 -1
View File
@@ -171,7 +171,10 @@ describe('shard engine: process lifecycle', () => {
});
expect(paid.status).toBe('timed-out');
expect(summaryExitCode(summarize([paid]))).toBe(1);
expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true);
const paidChild = Number(fs.readFileSync(pidFile, 'utf8'));
const paidChildDead = await waitDead(paidChild);
const procStat = (pid: number) => { try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim(); } catch { return 'no /proc entry'; } };
expect(paidChildDead, `paid grandchild ${paidChild} of group ${paid.groupPid} 2 s after the timeout kill: ${procStat(paidChild)}`).toBe(true);
}, 30_000);
test('win32 semantics: no process group and never a negative-pid kill', async () => {
+51 -4
View File
@@ -10,11 +10,11 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults,
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, validateFreeCiPlan, verifyFreeCiResults,
type FreeCiResult, type FreeShardOutcome,
} from '../scripts/test-free-shards';
import {
assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts,
assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem,
readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions,
} from '../scripts/lib/shard-jobs';
@@ -89,6 +89,52 @@ describe('--jobs validation (FREE_JOBS_INVALID)', () => {
});
});
describe('per-shard k in the plan (--ci-plan --shard-jobs)', () => {
test('--shard-jobs belongs to --ci-plan only and takes the same positive whole numbers', () => {
const base = ['--ci-plan', 'plan.json', '--shards', '4'];
expect(parseCliOptions([...base, '--shard-jobs', '2']).shardJobs).toBe(2);
expect('shardJobs' in parseCliOptions(base)).toBe(false);
for (const raw of ['0', '2.5', 'x']) expect(() => parseCliOptions([...base, '--shard-jobs', raw])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs a positive whole number`);
expect(() => parseCliOptions(['--shard-jobs', '2'])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs --ci-plan`);
expect(() => parseCliOptions(['--ci-run', 'p.json', '--shard', '1', '--result', 'r.json', '--shard-jobs', '2'])).toThrow(FREE_JOBS_INVALID);
});
test('each shard plans min(k, its parallel files); the exclusive shard and one-file shards stay one child, and k = 1 keeps the plan id', () => {
const withExclusive = [...files, EXCLUSIVE];
const plain = createFreeCiPlan(withExclusive, 3, durations, 'revision-a');
expect(createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 1)).toEqual(plain);
expect(plain.shards.every(shard => !('jobs' in shard))).toBe(true);
const planned = createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 4);
expect(planned.id).not.toBe(plain.id);
expect(planned.shards.map(shard => shard.files)).toEqual(plain.shards.map(shard => shard.files));
const exclusiveShard = planned.shards.at(-1)!;
expect(exclusiveShard.files).toEqual([EXCLUSIVE]);
expect('jobs' in exclusiveShard).toBe(false);
for (const shard of planned.shards.slice(0, -1)) expect(shard.jobs).toBe(Math.min(4, shard.files.length) > 1 ? Math.min(4, shard.files.length) : undefined);
expect(() => validateFreeCiPlan(planned, withExclusive, 'revision-a')).not.toThrow();
expect(plannedShardJobs(['a', 'b', 'c'], 2, file => file === 'a')).toBe(2);
expect(plannedShardJobs(['a', 'b'], 4, file => file === 'a')).toBeUndefined();
expect(plannedShardJobs([], 4, () => false)).toBeUndefined();
});
test('a plan whose per-shard k is out of range is invalid, and every shard result must have run at its planned k', () => {
const isSerial = (file: string) => file === EXCLUSIVE;
expect(plannedShardJobsProblem({ shard: 1, files }, isSerial)).toBeNull();
expect(plannedShardJobsProblem({ shard: 1, files, jobs: 7 }, isSerial)).toBeNull();
for (const jobs of [1, 0, 8, 2.5, '2']) expect(plannedShardJobsProblem({ shard: 1, files, jobs }, isSerial)).toContain('CI shard 1 plans --jobs');
expect(plannedShardJobsProblem({ shard: 2, files: [EXCLUSIVE, files[0]], jobs: 2 }, isSerial)).toContain('for 1 parallel file(s)');
const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2);
expect(plan.shards[0].jobs).toBe(2);
const tampered = structuredClone(plan);
tampered.shards[0].jobs = 99;
expect(() => validateFreeCiPlan(tampered, files, 'revision-a')).toThrow();
const k1: FreeCiResult = { planId: plan.id, revision: 'revision-a', outcome: passed(plan.shards[0].files), retry: null };
expect(() => verifyFreeCiResults(plan, [k1])).toThrow('CI shard 1 ran at --jobs 1; the plan says 2');
const plainPlan = createFreeCiPlan(files, 1, durations, 'revision-a');
expect(() => verifyFreeCiResults(plainPlan, [{ ...k1, planId: plainPlan.id }])).not.toThrow();
});
});
describe('k = 1 golden: PR 2 changes nothing in CI', () => {
test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => {
const h = harness(files);
@@ -160,7 +206,8 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } });
expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']);
expect(h.retries).toHaveLength(1);
expect(() => verifyFreeCiResults(h.plan, [result])).not.toThrow();
expect(() => verifyFreeCiResults({ ...h.plan, shards: [{ ...h.shard, jobs: 3 }] }, [result])).not.toThrow();
expect(() => verifyFreeCiResults(h.plan, [result])).toThrow('CI shard 1 ran at --jobs 3; the plan says 1');
});
test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => {
@@ -263,7 +310,7 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
});
test('--ci-verify rejects tampered sub-shard records', () => {
const plan = createFreeCiPlan(files, 1, durations, 'revision-a');
const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2);
const shard = plan.shards[0];
const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]);
const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10);
+1 -1
View File
@@ -308,7 +308,7 @@ describe('Claude Code child launch', () => {
const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
const argsFile = path.join(h.root, 'claude-args.txt');
const fake = path.join(h.root, 'fake-claude');
fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\n`, { mode: 0o755 });
fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}.tmp" && mv "${argsFile}.tmp" "${argsFile}"\n`, { mode: 0o755 });
const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake });
expect(r.code).toBe(0);
const session = h.field(r.out, 'CHILD', 'session');
+5 -2
View File
@@ -35,13 +35,16 @@ const result = (out: string) => line(out, 'GSTACK_RESULT:');
const next = (out: string) => line(out, 'NEXT:');
const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin');
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/ beside a held maintenance.lock, which a local copy refuses on every run; the seam has its own name because the fixture push's own detached maintenance can hold objects/maintenance.lock at that moment (EEXIST in Free Tests 41288253305 and 41288417894, 2026-11-01); no_claim=which process removed the loose object those runs named
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
const fx = make();
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock'));
const objects = path.join(fx.repo.origin, 'objects');
fs.writeFileSync(path.join(objects, 'maintenance.lock'), '');
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(objects, 'gstack-dangling-seam'));
const coord = coordinatorClone(fx.repo);
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
expect(fs.readdirSync(path.join(coord, '.git', 'objects'))).not.toContain('gstack-dangling-seam');
});
describe('the train command surface', () => {
+76
View File
@@ -662,3 +662,79 @@ describe('a CHANGELOG re-heading after the docs audit (GST-906, GBRA-258, gbrain
}
});
});
describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-277, gbrain #8425)', () => {
const exceptionFile = (f: ShipFx, head: string, extra: Record<string, unknown> = {}) => {
const file = path.join(f.root, 'loop-exception.json');
fs.writeFileSync(file, JSON.stringify({ accepted_by: 'GStack Minder (rule 23 grant)', date: '2026-11-02', via: 'GSTA-36', reason: 'the pre-agreed fallback after the fix-check blocker, then ship', fix_check: { head, findings: 1 }, ...extra }));
return file;
};
const afterFixCheck = () => {
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'README.md': '# Fx, with the branch feature\n' });
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'src/x.ts': 'export const x = 3;\n', 'README.md': '# Fx, with the fallback\n' }); f.commit('owner fallback after the fix-check');
return { f, fixHead, reviewed, post };
};
// Value: protects=an edit the owner accepted after a fix-check ships through Step 16 without another review round (R51 cap);
// fails_when=finish routes the accepted src edit as behavior or stales the docs stage on the accepted doc, or accepts it
// without the exception file; seam=finish's route and docsFresh; no_claim=the receipt and queue checks (exceptionProblems)
test('paths changed since fix_check.head route as accepted and the docs stage carries them; without the file it still stops', () => {
const { f, fixHead, reviewed, post } = afterFixCheck();
const plain = finish(f, reviewed, post);
expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]);
expect(lineOf(plain.stdout, 'ROUTE')).toMatch(/ behavior=1 docs=1 metadata=0 tests=0 base=unknown context=0$/);
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`]);
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 accepted=2$/);
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).toBe(`REVIEW_LOOP_EXCEPTION: accepted README.md,src/x.ts (changed since fix-check ${fixHead.slice(0, 12)}; accepted by GStack Minder (rule 23 grant) 2026-11-02 via GSTA-36)`);
expect(lineOf(r.stdout, 'DOCS')).toBe('DOCS: fresh against the Step 14.5 post-audit snapshot (carried: tests=0 base=0 requested=0 accepted=2)');
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
});
// Value: protects=the exception covers only the frozen scope and only a real fix-check; fails_when=a path the branch never
// touched at the fix-check, or an exception naming a commit outside the branch or a malformed record, is accepted; seam=finish
test('a path outside the frozen scope still routes as behavior; a fix-check head off the branch or a malformed record stops', () => {
const { f, fixHead, reviewed, post } = afterFixCheck();
f.write({ 'src/new.ts': 'export const n = 1;\n' }); f.commit('a new file after the fix-check');
const outside = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`]);
expect(lineOf(outside.stdout, 'ROUTE')).toMatch(/ behavior=1 .* accepted=2$/);
expect([outside.status, outside.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/new.ts)')]).toEqual([3, true]);
const off = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, 'e'.repeat(40))}`]);
expect([off.status, off.stdout]).toEqual([3, expect.stringContaining(`STOP: --review-loop-exception: fix_check.head eeeeeeeeeeee is not HEAD or an ancestor of it`)]);
const bad = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { reason: '' })}`]);
expect([bad.status, bad.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: review_loop_exception.reason must be a non-empty one-line string')]);
const missing = finish(f, reviewed, post, ['--review-loop-exception', `@${path.join(f.root, 'no-such-record.json')}`]);
expect([missing.status, missing.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: ')]);
});
// Value: protects=fix_check.files only narrows the frozen scope; fails_when=a listed path the branch never touched at the
// fix-check routes as accepted, or a touched path the list leaves out is accepted anyway; seam=finish's route
test('fix_check.files narrows the accepted scope and cannot add a path the branch had not touched at the fix-check', () => {
const { f, fixHead, reviewed, post } = afterFixCheck();
f.write({ 'src/new.ts': 'export const n = 1;\n' }); f.commit('a new file after the fix-check');
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { fix_check: { head: fixHead, findings: 1, files: ['src/x.ts', 'src/new.ts'] } })}`]);
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=1 docs=1 metadata=0 tests=0 base=unknown context=0 accepted=1$/);
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).toStartWith('REVIEW_LOOP_EXCEPTION: accepted src/x.ts (');
expect([r.status, r.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/new.ts)')]).toEqual([3, true]);
const none = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead, { fix_check: { head: fixHead, findings: 1, files: [] } })}`]);
expect([none.status, none.stdout]).toEqual([3, expect.stringContaining('STOP: --review-loop-exception: fix_check.files lists no paths')]);
});
// Value: protects=a test weakened after the fix-check is never owner-accepted, with or without --reviewed-base;
// fails_when=finish accepts a gate-edited test (a .skip, a removed assertion) because no base sides were given; seam=finish's route
test('a gate-edited test after the fix-check still routes as behavior under the exception', () => {
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n" });
const fixHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = wtree(f);
const reviewedBase = mergeBase(f);
const post = snapshot(f);
f.write({ 'test/x.test.ts': "import { test } from 'bun:test';\ntest.skip('a', () => {});\n" }); f.commit('weaken the test after the fix-check');
for (const extra of [[], ['--reviewed-base', reviewedBase]]) {
const r = finish(f, reviewed, post, ['--review-loop-exception', `@${exceptionFile(f, fixHead)}`, ...extra]);
expect([r.status, lineOf(r.stdout, 'ROUTE'), r.stdout.includes('STOP: behavior, tests or build inputs changed since review (test/x.test.ts)')]).toEqual([3, expect.stringMatching(/ behavior=1 .* accepted=0$/), true]);
}
});
});
+1 -1
View File
@@ -911,7 +911,7 @@ describe('office-hours skill structure', () => {
describe('investigate skill structure', () => {
const content = fs.readFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), 'utf-8');
for (const section of ['Iron Law', 'Root Cause', 'Pattern Analysis', 'Hypothesis',
for (const section of ['Iron Law', 'How to work', 'Scope Lock',
'DEBUG REPORT', '3-strike', 'BLOCKED']) {
test(`contains ${section}`, () => expect(content).toContain(section));
}
+2
View File
@@ -36,6 +36,8 @@ const HOOK_FILES = [
'lib/result-codes-eval.ts',
'lib/result-codes-plan.ts',
'lib/result-codes-autoplan.ts',
'lib/result-codes-browse.ts',
'lib/result-codes-clean.ts',
];
const SLUG = 'stop-hook-slug-test';
const BUDGET_MS = 2000;
+34 -2
View File
@@ -4,7 +4,9 @@
* that moved out of it.
*/
import { expect, test } from 'bun:test';
import { existsSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards';
import { windowsCurationLine } from '../scripts/lib/free-ci-health';
@@ -27,7 +29,7 @@ const WINDOWS_CURATED_FLOOR = 795;
test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations', 'windows_jobs']);
expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']);
const plan = windows.jobs['windows-plan'];
expect(plan['runs-on']).toBe('ubuntu-24.04');
@@ -50,6 +52,36 @@ test('the Windows lane runs nightly and by hand, plans, runs one strict shard pe
expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
});
test.skipIf(!Bun.which('bash'))('windows-plan passes the one sub-shard constant, or a validated dispatch override, to --shard-jobs', () => {
const planStep = windows.jobs['windows-plan'].steps.find((step: any) => step.id === 'plan');
expect(windows.env.WINDOWS_FREE_JOBS).toMatch(/^[1-4]$/);
expect(planStep.env.JOBS_OVERRIDE).toBe('${{ inputs.windows_jobs }}');
expect(planStep.run).not.toContain('${{');
const directory = mkdtempSync(path.join(os.tmpdir(), 'windows plan '));
const bin = path.join(directory, 'bin');
mkdirSync(bin);
writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$*" > "$RUNNER_TEMP/bun-args"\necho \'{"shard":[1]}\'\n', { mode: 0o755 });
const toShell = (p: string) => p.split(path.sep).join('/');
const resolve = (override: string) => {
rmSync(path.join(directory, 'bun-args'), { force: true });
const result = spawnSync('bash', ['-c', planStep.run], {
env: { ...process.env, ...windows.env, JOBS_OVERRIDE: override, PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory),
GITHUB_OUTPUT: toShell(path.join(directory, 'output')), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) },
encoding: 'utf8', timeout: 5000,
});
return { status: result.status, args: existsSync(path.join(directory, 'bun-args')) ? readFileSync(path.join(directory, 'bun-args'), 'utf8') : '' };
};
try {
expect(resolve('')).toEqual({ status: 0, args: expect.stringContaining(`--shards 6 --shard-jobs ${windows.env.WINDOWS_FREE_JOBS}`) });
expect(resolve('2')).toEqual({ status: 0, args: expect.stringContaining('--shards 6 --shard-jobs 2') });
for (const bad of ['0', '5', '2; true', 'x']) {
const refused = resolve(bad);
expect(refused.status, bad).not.toBe(0);
expect(refused.args, bad).toBe('');
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => {
for (const argv of [['--windows-only', '--ci-plan', 'p.json'], ['--windows-only', '--ci-run', 'p.json', '--shard', '1', '--result', 'r.json'], ['--windows-only', '--ci-verify', 'p.json', '--results', 'd']]) {
expect(parseCliOptions(argv).windowsOnly).toBe(true);