mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(eval-plan): per-arm pilot projection and --seat-metered arms (#3653) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): guard tolerates Claude Code's cwd-reset footer and recovers after an unreadable init (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (journal reader strips the cwd-reset footer; an init with no text block no longer locks the session) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: journal reader skips a failed or unreadable init, as invocation() does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(eval-plan): all-seat pilot, single-attempt arm and --seat-metered CLI wiring Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): summed deviation adds arm deviations, matching the summed band (arms share rows) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (test pins an unpriced seat-metered arm) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (seat-metered declaration fixed per --out, price row still required, a real charge settles as spent) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: classify autoplan-ceo-only in the Codex host metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a seat-metered arm that settles a charge is projected as a priced arm Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): /eval-plan --seat-metered and per-arm projection Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(eval-plan): skill prose stays under its context budget and carries no $N token Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run autoplan-ceo-only in the marathon lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(skills): a seat-metered arm leaves the seat set once it settles a charge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for /autoplan --ceo-only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): init_failed covers unreadable init output and the cwd-reset line (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: count the autoplan-ceo-only paid file in the shard inventory Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan): #3651 tests pass the test-code type ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): CAP finding titles carry a one-line description (GBRA-223 claim 2) The DX error contract told both autoplan DX voices to title capped findings exactly CAP4:<path>:<item>, so gates, tasks and reconcile saw a bare code. The title is now CAP4:<path>:<item> <what fails>; the grader already parses that shape. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): reconcile pairs same-file findings that carry no line (GBRA-223 claim 1) Plan reviews name files, rarely lines, and reconcile associated by location only when both findings had a line, so findings both voices raised on one file were filed new and native-only. A same-file pair with a missing line is now associated as unverified (basis file), never confirmed; a CAP code no longer counts in title comparison. DRIVER.md names submit --reconcile. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): open Critical and High findings reach the gate as apply items (GBRA-223 claim 3) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap binds on every unattended host; --estimate-usd persists; DRIVER names --usage-usd (GBRA-223 claim 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): run binds complete api results, names a truncated response, --max-tokens; DRIVER documents run (GBRA-223 claim 6) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): carry an item-by-item answer when the later gate asks nothing new (GBRA-223 claim 7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(dx): trim the compact error contract so plan-devex-review stays under its size pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (p1b applies finding fixes, api attempts reserve no host default, cross-file title pairing, paired severity counted) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review fixes (priced api reservations, no file-only twins, two-pass pairing, partial replies keep p1-dependent defaults open, outside refusal degrades) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * P7: record whose words answered a gate (--words-by agent:<id>), never carry an agent's own words, import each row under its own answerer (GEVA-2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: review pass 2 (p1b needs --rec-hash after recommend; api calls send the output limit they reserve for) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: fragment for the /autoplan field-report wave (GST-672) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: captain record notes --words-by agent attribution Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow finding apply items in approval-class and Captain flow tests; bind run-api limit to DEFAULT_MAX_TOKENS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave autoplan: lower the gemini plan-devex-review pin to its exact size after #3781's DX trim Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): CAP titles on different paths never match by title in reconcile or confirm rounds (wave review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a truncated api call settles its cost and a retry is admitted afresh against the cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): an applied finding stays open until the revised plan is reviewed; a shared location alone never twins two findings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(approval): a plan gate answered in an agent's own words is not owner approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gate): a new answer replaces the earlier answer's authorship and words Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a failed api call with no usage keeps its attempt open; reservations price the bound prompt (wave fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed: only a call refused before sending keeps its attempt; a sent call with unknown usage settles at its reservation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): the 16384 output default applies to both providers; changelog for the wave's integrator fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): how run settles a failed api call; changelog scopes it to the native voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): a spend cap fails closed on every exit of run; an unpriced model reserves the default once a cap is set Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): every sent call settles; an unpriced model under a cap reserves the default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): DRIVER and help say a cap reserves in any session kind and how run settles a sent call 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>
195 lines
14 KiB
TypeScript
195 lines
14 KiB
TypeScript
/**
|
|
* `gstack-autoplan answer` (plan B2, B9): a stale, unparsed or conflicting
|
|
* reply writes nothing; a reply that leaves approval items pending records
|
|
* only the auto items and keeps the gate; `all` or `p1a` with no
|
|
* content-changing override finalizes run.json.status=complete without a new
|
|
* paid phase; a non-recommended choice on a reviewed item reopens only the
|
|
* affected phase plus Eng (Eng last), archives that phase's bound files and
|
|
* bumps the gate revision at the next gate; `p1d` reopens every phase; `p1e`
|
|
* ends incomplete. Pins decisions rows, journal events and status tokens.
|
|
*/
|
|
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 { attemptsOf, autoplan, journal, readRun, recCarrying, resultOf, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
|
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
|
|
/** A gated run with a disagreement in ceo (taste item d1), the fixture's user challenges (uc1 ceo, uc2 eng) and its open Critical/High findings (d2 ceo, d3 and d4 eng). */
|
|
function gatedRun(name: string, extra: string[] = []): string {
|
|
const out = startSmallRun(path.join(TMP, name), extra);
|
|
const disagree = JSON.stringify([
|
|
{ id: 'O1', severity: 'Low', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'minor' },
|
|
{ id: 'O2', severity: 'High', title: 'Gate reuse on tree equality alone is unsound', file: 'lib/tree-receipt.ts', line: 3 },
|
|
{ id: 'O3', severity: 'Low', title: 'Docs link the quickstart page', user_challenge: true },
|
|
]);
|
|
const ceoNext = autoplan(['next', '--out', out]);
|
|
expect(ceoNext.status).toBe(0);
|
|
review(path.join(out, 'ceo-native-prompt.md'));
|
|
review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree });
|
|
const ids = attemptsOf(out, 'ceo');
|
|
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0);
|
|
const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]);
|
|
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=0 unverified=1 disagree=1 new=1/);
|
|
const eng = runPhase(out, 'eng');
|
|
expect(eng.outside.stdout).toContain('status=gate_pending');
|
|
return out;
|
|
}
|
|
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
|
|
/**
|
|
* An accepted plan change is a pending amendment (B-core interlock): `next` refuses with PLAN_AMEND_PENDING and writes
|
|
* nothing until the plan is edited and `revise --incorporates` names the decision. Returns the revise output.
|
|
*/
|
|
function incorporate(out: string, ids: string[], edit: string) {
|
|
const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
|
|
const blocked = autoplan(['next', '--out', out]);
|
|
expect(blocked.status).toBe(3);
|
|
expect(blocked.stderr).toMatch(new RegExp(`${ids.join(',')}.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
|
|
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
|
|
const plan = path.join(path.dirname(out), 'plan.md');
|
|
fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover', `${edit} Tests cover`));
|
|
const revised = autoplan(['revise', '--out', out, '--plan', plan, '--incorporates', ids.join(',')]);
|
|
expect(revised.status, revised.stderr).toBe(0);
|
|
expect(revised.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
|
|
expect(journal(out).filter(j => j.event === 'amendment_incorporated').map(j => j.decision)).toEqual(expect.arrayContaining(ids));
|
|
return revised;
|
|
}
|
|
|
|
describe('answer', () => {
|
|
test('the gate lists the disagreement and the open Critical/High findings as auto items and the user challenges as approval items', () => {
|
|
const out = gatedRun('shape');
|
|
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
|
|
expect(gate.gate_rev).toBe(1);
|
|
expect(gate.items.map((i: any) => [i.id, i.kind, i.phase ?? null])).toEqual([['d1', 'auto', 'ceo'], ['uc1', 'approval', 'ceo'], ['d2', 'auto', 'ceo'], ['uc2', 'approval', 'eng'], ['d3', 'auto', 'eng'], ['d4', 'auto', 'eng'], ['p1', 'approval', null]]);
|
|
expect(gate.items.filter((i: any) => i.finding).map((i: any) => i.id)).toEqual(['d2', 'd3', 'd4']);
|
|
expect(gate.items[0].why).toMatch(/voices disagree: severity: native High, outside Low/);
|
|
}, 30_000);
|
|
|
|
test('stale, unparsed and conflicting replies write nothing; a reply that leaves approval items pending keeps the gate', () => {
|
|
const out = gatedRun('stale');
|
|
const before = { run: fs.readFileSync(path.join(out, 'run.json'), 'utf8'), decisions: fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'), journal: journal(out).length };
|
|
const stale = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '7', '--reply', 'all']);
|
|
expect(stale.status).toBe(3);
|
|
expect(stale.stderr).toMatch(/\(GATE_REV_STALE\)$/m);
|
|
expect(stale.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
|
|
const unparsed = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1b approve it']);
|
|
expect(unparsed.status).toBe(1);
|
|
expect(unparsed.stderr).toMatch(/unparsed: approve, it.*\(GATE_REPLY_UNPARSED\)$/m);
|
|
const conflicting = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1a d1b p1a']);
|
|
expect(conflicting.status).toBe(1);
|
|
expect(conflicting.stderr).toMatch(/conflicts with d1a.*\(GATE_REPLY_UNPARSED\)$/m);
|
|
const missingRev = autoplan(['answer', '--by', 'human', '--out', out, '--reply', 'all']);
|
|
expect(missingRev.status).toBe(3);
|
|
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before.run);
|
|
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before.decisions);
|
|
expect(journal(out).length).toBe(before.journal);
|
|
// partial: only the auto item answered; approval items, and finding items whose default waits on p1 (p1b applies them), stay pending
|
|
const partial = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1a', '--json']);
|
|
expect(partial.status, partial.stderr).toBe(0);
|
|
expect(JSON.parse(partial.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending', pending: ['uc1', 'uc2', 'p1'] });
|
|
const rows = decisions(out);
|
|
expect(rows.find((r: any) => r.label === 'D1')).toMatchObject({ status: 'approved', chosen: 'a' });
|
|
expect(rows.filter((r: any) => r.status === 'pending').map((r: any) => r.label)).toEqual(['UC1', 'D2', 'UC2', 'D3', 'D4', 'P1']);
|
|
}, 30_000);
|
|
|
|
test('`all` carrying the open findings finalizes without another paid phase: status complete, decisions answered, no phase reopened', () => {
|
|
const out = gatedRun('approve');
|
|
const counter = readRun(out).attempt_counter;
|
|
const reply = recCarrying(out);
|
|
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', reply]);
|
|
expect(r.status, r.stderr).toBe(0);
|
|
expect(r.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m);
|
|
expect(r.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
|
|
const run = readRun(out);
|
|
expect(run.status).toBe('complete');
|
|
expect(run.attempt_counter).toBe(counter);
|
|
expect(decisions(out).every((d: any) => d.status === (/^D[234]$/.test(d.label) ? 'overridden' : 'approved') && d.reply === reply)).toBe(true);
|
|
expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]);
|
|
const again = autoplan(['next', '--out', out]);
|
|
expect(again.stdout).toContain('status=complete');
|
|
expect(readRun(out).attempt_counter).toBe(counter);
|
|
}, 30_000);
|
|
|
|
test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => {
|
|
const out = gatedRun('reopen', ['--no-carry-forward']);
|
|
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', recCarrying(out, 'all', ['d1b']), '--json']);
|
|
expect(r.status, r.stderr).toBe(0);
|
|
const runId = readRun(out).run;
|
|
expect(JSON.parse(r.stdout)).toMatchObject({ status: 'amend_pending', outcome: 'amend_pending', reopened: [], amendments: [`${runId}-d1`] });
|
|
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
|
|
expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]);
|
|
// the accepted change is applied to the plan and named; revise reopens the phases that reviewed the old plan, once
|
|
const revised = incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.');
|
|
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
|
|
const run = readRun(out);
|
|
expect(run.phases.ceo).toEqual({});
|
|
expect(run.phases.eng).toEqual({});
|
|
expect(run.phases.design.skipped).toBeTruthy();
|
|
expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]);
|
|
expect(run.status).toBe('running');
|
|
for (const f of ['ceo-native-prompt.r1.md', 'ceo-outside-prompt.r1.md', 'ceo-consensus.r1.json', 'eng-native-prompt.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true);
|
|
for (const f of ['ceo-native-prompt.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false);
|
|
// sealed copies are attempt-owned: they stay in place and bound across the reopen
|
|
const sealed = run.history.flatMap((h: any) => ['native', 'outside'].map(v => h.previous[v].output));
|
|
expect(sealed.every((o: string) => /\.a\d+\.bound\.md$/.test(o) && fs.existsSync(path.join(out, o)) && run.artifacts[o])).toBe(true);
|
|
const d1 = decisions(out).find((d: any) => d.label === 'D1');
|
|
expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 });
|
|
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
|
|
// the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones
|
|
const ceo = runPhase(out, 'ceo');
|
|
expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m);
|
|
expect(ceo.outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
|
|
const eng = runPhase(out, 'eng');
|
|
expect(eng.outside.stdout).toContain('status=gate_pending');
|
|
expect(readRun(out).gate_rev).toBe(2);
|
|
const rows = decisions(out);
|
|
expect(rows.filter((x: any) => x.gate_rev === 1)).toHaveLength(7);
|
|
expect(rows.filter((x: any) => x.gate_rev === 2 && x.status === 'pending').length).toBeGreaterThan(0);
|
|
const staleAgain = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
|
expect(staleAgain.status).toBe(3);
|
|
// closing gate_rev 2 exports a run that validates: rev-2 rows get their own ids beside rev 1's
|
|
const approve = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', recCarrying(out)]);
|
|
expect(approve.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
|
|
expect(autoplan(['export', '--out', out]).status).toBe(0);
|
|
const ids = decisions(out).map((x: any) => x.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
expect(decisions(out).filter((x: any) => x.gate_rev === 2).every((x: any) => x.id.startsWith(`${readRun(out).run}-r2-`))).toBe(true);
|
|
expect(decisions(out).find((x: any) => x.gate_rev === 1 && x.label === 'D1')!.id).toBe(`${readRun(out).run}-d1`);
|
|
const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
expect(tasks.length).toBeGreaterThan(0);
|
|
for (const t of tasks) expect(t.blocked_by).toEqual([`${readRun(out).run}-r2-p1`]);
|
|
const valid = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
|
|
expect(valid.stdout + valid.stderr).toContain('ARTIFACT_VALID: run=');
|
|
expect(valid.status).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
|
|
const out = gatedRun('eng-only', ['--max-rounds', '3', '--no-carry-forward']);
|
|
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', recCarrying(out, 'all', ['uc2b']), '--json']);
|
|
const runId = readRun(out).run;
|
|
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
|
|
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
|
|
// the revised plan changes the hash both phases reviewed, so revise (not the answer) reruns ceo, then eng, on v2
|
|
const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.');
|
|
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
|
|
expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['ceo', expect.stringContaining('revise')], ['eng', expect.stringContaining('revise')]]);
|
|
expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
|
|
const eng = runPhase(out, 'eng');
|
|
expect(eng.outside.stdout).toContain('status=gate_pending');
|
|
const interrogate = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', recCarrying(out, 'all', ['p1c']), '--json']);
|
|
expect(JSON.parse(interrogate.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending' });
|
|
const revise = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', recCarrying(out, 'all', ['p1d']), '--json']);
|
|
expect(JSON.parse(revise.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] });
|
|
runPhase(out, 'ceo');
|
|
runPhase(out, 'eng');
|
|
const reject = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '3', '--reply', recCarrying(out, 'all', ['p1e'])]);
|
|
expect(reject.status).toBe(0);
|
|
expect(reject.stdout).toContain('GSTACK_RESULT: skill=autoplan status=incomplete run=' + out);
|
|
expect(readRun(out).deviations).toContain('plan rejected by the owner at gate_rev 3');
|
|
}, 30_000);
|
|
});
|