mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
193 lines
12 KiB
TypeScript
193 lines
12 KiB
TypeScript
/**
|
|
* `gstack-autoplan revise --out <dir> --plan <v2>` (plan B1c, item 9): the run
|
|
* reviews a revised plan in the same run directory. Drift is the
|
|
* Implementation-plan hash: every opened phase whose reviewed hash differs is
|
|
* reopened (open attempts superseded), newly in-scope phases are added, the
|
|
* archives keep `.r<rev>` names with `.v<k>` only on collision, revise at
|
|
* gate_pending is its own journal event and the next gate is gate_rev + 1.
|
|
* `next` and `answer` print the drift hints; a plan missing one of the two
|
|
* sections refuses with PLAN_SECTIONS_INVALID and `next --scaffold` reviews a
|
|
* scaffolded copy. Runs on the simulated Capy host.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
|
|
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
|
|
|
|
const r = capyRunner();
|
|
afterAll(() => r.cleanup());
|
|
let n = 0;
|
|
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts');
|
|
const RECORD_ONLY = SMALL_PLAN + '\nReviewed once; notes only.\n';
|
|
const DX_V2 = SMALL_PLAN.replace('Tests cover', 'Add a CLI flag --max-retries and an error message for a bad value. Tests cover');
|
|
function start(args: string[] = [], text = SMALL_PLAN): { out: string; dir: string; plan: string } {
|
|
const dir = path.join(r.host.base, `rev${++n}`);
|
|
const plan = writePlan(dir, text);
|
|
const out = path.join(dir, 'run');
|
|
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]);
|
|
if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`);
|
|
return { out, dir, plan };
|
|
}
|
|
const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
|
|
function gated(args: string[] = []): { out: string; dir: string; plan: string } {
|
|
const s = start(args);
|
|
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
|
|
expect(phaseVia(r, s.out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending');
|
|
return s;
|
|
}
|
|
|
|
describe('revise', () => {
|
|
test('revise after ceo closes reopens ceo, and dx and eng then review the v2 snapshot', () => {
|
|
const s = start(['--developer-tool']);
|
|
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
|
|
const v1 = readRunJson(s.out).phases.ceo.snapshot;
|
|
const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]);
|
|
expect(rev.status, rev.stderr).toBe(0);
|
|
expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo$/m);
|
|
expect(rev.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running');
|
|
for (const phase of ['ceo', 'dx', 'eng']) {
|
|
const p = phaseVia(r, s.out, phase, { native: [], outside: [] });
|
|
expect(p.next.stdout).toMatch(new RegExp(`^PHASE: ${phase} snapshot=`, 'm'));
|
|
expect(fs.readFileSync(path.join(s.out, `${phase}-native-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts');
|
|
expect(fs.readFileSync(path.join(s.out, `${phase}-outside-prompt.md`), 'utf8')).toContain('dead-letter after 8 attempts');
|
|
}
|
|
const run = readRunJson(s.out);
|
|
expect(run.status).toBe('gate_pending');
|
|
expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1);
|
|
expect(run.phases.ceo.snapshot).not.toBe(v1);
|
|
expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1);
|
|
expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r0.md'))).toBe(true);
|
|
const sealed = run.history.find((h: any) => h.phase === 'ceo').previous.native.output;
|
|
expect(sealed).toMatch(/^ceo-native\.a\d+\.bound\.md$/);
|
|
expect(fs.existsSync(path.join(s.out, sealed))).toBe(true);
|
|
expect(run.artifacts[sealed]).toBeTruthy();
|
|
});
|
|
|
|
test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => {
|
|
const s = start();
|
|
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
|
|
const same = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]);
|
|
expect(same.status, same.stderr).toBe(0);
|
|
expect(same.stdout).toMatch(/reopened=none/);
|
|
expect(readRunJson(s.out).phases.ceo.closed_at).toBeTruthy();
|
|
const eng = r.ap(['next', '--out', s.out]);
|
|
expect(eng.stdout).toMatch(/^PHASE: eng /m);
|
|
const open = readRunJson(s.out).phases.eng;
|
|
const changed = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]);
|
|
expect(changed.stdout).toMatch(/reopened=ceo,eng/);
|
|
const superseded = readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'superseded').map(e => e.attempt);
|
|
expect(superseded.sort()).toEqual([open.native.attempt, open.outside.attempt].sort());
|
|
});
|
|
|
|
test('revise at gate_pending is its own journal event, reruns on v2, and the next gate is gate_rev 2', () => {
|
|
const s = gated();
|
|
const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir), '--json']);
|
|
expect(rev.status, rev.stderr).toBe(0);
|
|
expect(JSON.parse(rev.stdout)).toMatchObject({ status: 'running', reopened: ['ceo', 'eng'] });
|
|
const j = readJsonl(path.join(s.out, 'attempts.jsonl'));
|
|
expect(j.find(e => e.event === 'plan_revised')).toMatchObject({ at_gate_rev: 1 });
|
|
expect(j.filter(e => e.event === 'answered')).toEqual([]);
|
|
expect(fs.existsSync(path.join(s.out, 'gate.r1.json'))).toBe(true);
|
|
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
|
|
phaseVia(r, s.out, 'eng', { native: [], outside: [] });
|
|
expect(fs.readFileSync(path.join(s.out, 'eng-native-prompt.md'), 'utf8')).toContain('dead-letter after 8 attempts');
|
|
expect(readRunJson(s.out)).toMatchObject({ status: 'gate_pending', gate_rev: 2 });
|
|
});
|
|
|
|
test('a second revise never overwrites the first archive', () => {
|
|
const s = gated(['--max-rounds', '3']);
|
|
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
|
|
const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md'));
|
|
phaseVia(r, s.out, 'ceo', { native: [], outside: [] });
|
|
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace('jitter 10%', 'jitter 20%'))]).status).toBe(0);
|
|
expect(fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')).equals(first)).toBe(true);
|
|
expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r1.v2.md'))).toBe(true);
|
|
});
|
|
|
|
test('revise adds a newly in-scope phase and never removes one', () => {
|
|
const s = start();
|
|
expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'eng']);
|
|
const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, DX_V2)]);
|
|
expect(rev.status, rev.stderr).toBe(0);
|
|
const run = readRunJson(s.out);
|
|
expect(run.phase_order).toEqual(['ceo', 'dx', 'eng']);
|
|
expect(run.required_phases).toEqual(['ceo', 'dx', 'eng']);
|
|
expect(run.phases.dx.skipped).toBeUndefined();
|
|
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0);
|
|
expect(readRunJson(s.out).phase_order).toEqual(['ceo', 'dx', 'eng']);
|
|
});
|
|
|
|
test('an attested bind is refused when the plan was revised after its attempt was dispatched', () => {
|
|
const s = start();
|
|
const attempt = readRunJson(s.out).phases.ceo.native.attempt;
|
|
expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, RECORD_ONLY)]).stdout).toMatch(/reopened=none/);
|
|
const rec = path.join(s.dir, 'hand.md');
|
|
fs.writeFileSync(rec, 'Hand review.\n\nNo findings.\n');
|
|
const res = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--record', rec, '--model', 'anthropic/claude-opus-5-5', '--attest', 'ran it', '--attempt', attempt]);
|
|
expect(res.status).toBe(1);
|
|
expect(res.stderr).toMatch(/revised after .*dispatched.*\(RESULT_RECEIPT_MISSING\)$/m);
|
|
});
|
|
test('naming a decision that is not a pending amendment refuses with AMENDMENT_UNKNOWN and never asks for the edit just made', () => {
|
|
const s = start();
|
|
const before = fs.readFileSync(path.join(s.out, 'run.json'), 'utf8');
|
|
for (const args of [['--plan', v2File(s.dir), '--incorporates', 'd1b'], ['--withdraw', 'd1b']]) {
|
|
const res = r.ap(['revise', '--out', s.out, ...args]);
|
|
expect(res.status).toBe(3);
|
|
expect(res.stderr).toMatch(/d1b is not a pending amendment \(pending: none\).*\(AMENDMENT_UNKNOWN\)$/m);
|
|
expect(res.stderr).toContain(`; fix: ${RESULT_CODES.AMENDMENT_UNKNOWN.fix} (AMENDMENT_UNKNOWN)`);
|
|
expect(res.stderr).not.toContain('PLAN_AMEND_PENDING');
|
|
expect(res.stderr).not.toContain('edit the plan');
|
|
}
|
|
expect(fs.readFileSync(path.join(s.out, 'run.json'), 'utf8')).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('drift hints', () => {
|
|
test('next prints PLAN_CHANGED when the source plan changed since the snapshot', () => {
|
|
const s = start();
|
|
fs.writeFileSync(s.plan, V2);
|
|
const res = r.ap(['next', '--out', s.out]);
|
|
expect(res.stdout).toMatch(new RegExp(`^PLAN_CHANGED: ${s.plan.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} differs from the reviewed plan; run gstack-autoplan revise --out \\S+ --plan \\S+$`, 'm'));
|
|
});
|
|
test('answer prints PLAN_SOURCE_CHANGED when a reply reopens phases after the source plan changed', () => {
|
|
const s = gated();
|
|
fs.writeFileSync(s.plan, V2);
|
|
const res = r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']);
|
|
expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\)/m);
|
|
expect(res.stdout).toMatch(/^PLAN_SOURCE_CHANGED: .* differs from the reviewed plan; run gstack-autoplan revise --out \S+ --plan \S+$/m);
|
|
});
|
|
});
|
|
|
|
describe('plan sections', () => {
|
|
test('only an Implementation plan: next refuses with PLAN_SECTIONS_INVALID and stdout; --scaffold reviews a copy', () => {
|
|
const dir = path.join(r.host.base, 'scaffold');
|
|
const text = '# P\n\n## Implementation plan\n\nDo the thing in lib/x.ts with a 30 s timeout.\n';
|
|
const plan = writePlan(dir, text);
|
|
const refused = r.ap(['next', '--out', path.join(dir, 'a'), '--plan', plan, '--no-ui']);
|
|
expect(refused.status).toBe(3);
|
|
expect(refused.stderr).toMatch(/no "## Review record".*--scaffold.*\(PLAN_SECTIONS_INVALID\)$/m);
|
|
expect(refused.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
|
|
const ok = r.ap(['next', '--out', path.join(dir, 'b'), '--plan', plan, '--no-ui', '--scaffold']);
|
|
expect(ok.status, ok.stderr).toBe(0);
|
|
expect(ok.stdout).toMatch(/^SCAFFOLDED: added "## Review record"/m);
|
|
expect(ok.stdout).toMatch(/^PHASE: ceo /m);
|
|
expect(fs.readFileSync(plan, 'utf8')).toBe(text);
|
|
expect(fs.readFileSync(path.join(dir, 'b', 'plan-source.md'), 'utf8')).toContain('## Review record');
|
|
});
|
|
test('duplicate or reversed headings refuse with the code even with --scaffold; an empty section says so', () => {
|
|
const dir = path.join(r.host.base, 'ambiguous');
|
|
for (const [name, text] of [['dup', '# P\n\n## Implementation plan\n\nA.\n\n## Implementation plan\n\nB.\n\n## Review record\n'], ['rev', '# P\n\n## Review record\n\nx\n\n## Implementation plan\n\nA.\n']] as const) {
|
|
const plan = writePlan(path.join(dir, name), text);
|
|
const res = r.ap(['next', '--out', path.join(dir, name, 'run'), '--plan', plan, '--no-ui', '--scaffold']);
|
|
expect(res.status).toBe(3);
|
|
expect(res.stderr).toMatch(/\(PLAN_SECTIONS_INVALID\)$/m);
|
|
}
|
|
const plan = writePlan(path.join(dir, 'empty'), '# P\n\n## Implementation plan\n\n## Review record\n');
|
|
const res = r.ap(['next', '--out', path.join(dir, 'empty', 'run'), '--plan', plan, '--no-ui']);
|
|
expect(res.status).toBe(3);
|
|
expect(res.stderr).toMatch(/Implementation plan is empty.*\(PLAN_SECTIONS_INVALID\)$/m);
|
|
});
|
|
});
|