mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
feat(ship): /ship and /autoplan size their review to the diff (GSTA-131) (#3527)
* 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>
This commit is contained in:
1 parent
44f19d0106
commit
148d28ea81
68 files changed
+2069
-266
No files matched your search
@@ -19,8 +19,8 @@ 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) and the fixture's user challenges (uc1 ceo, uc2 eng). */
|
||||
function gatedRun(name: string): string {
|
||||
const out = startSmallRun(path.join(TMP, name));
|
||||
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 },
|
||||
@@ -166,7 +166,7 @@ describe('answer', () => {
|
||||
}, 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');
|
||||
const out = gatedRun('eng-only', ['--max-rounds', '3']);
|
||||
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
|
||||
const runId = readRun(out).run;
|
||||
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] });
|
||||
|
||||
@@ -43,15 +43,15 @@ describe('spend: the default host estimate binds and budget_exhausted is a resum
|
||||
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--spend-cap', '12']);
|
||||
expect(first.status, first.stderr).toBe(0);
|
||||
expect(first.stdout).toMatch(/^ESTIMATE: 5\.00 per voice reserved for each host-subagent attempt/m);
|
||||
expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/3 eng=1\/3 max_rounds=3 host_spend=unknown$/m);
|
||||
expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/2 eng=1\/2 max_rounds=2 host_spend=unknown$/m);
|
||||
expect(spend(out).reserved.map((x: any) => x.estimate_usd)).toEqual([5, 5]);
|
||||
expect(readRunJson(out).max_rounds).toBe(3);
|
||||
expect(readRunJson(out).max_rounds).toBe(2);
|
||||
phaseVia(r, out, 'ceo', NONE);
|
||||
expect(unknownUsd(out)).toBe(10);
|
||||
const stopped = r.ap(['next', '--out', out]);
|
||||
expect(stopped.status).toBe(3);
|
||||
expect(stopped.stderr).toMatch(/\(SPEND_CAP_EXCEEDED\)$/m);
|
||||
expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/3,eng:1\/3$/m);
|
||||
expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/2,eng:1\/2$/m);
|
||||
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan close --out ${out} --phase eng --deferred`);
|
||||
expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan budget --out ${out} --spend-cap <usd>`);
|
||||
expect(stopped.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=incomplete run=${out}`);
|
||||
@@ -97,7 +97,7 @@ describe('close --deferred', () => {
|
||||
expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m);
|
||||
const close = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']);
|
||||
expect(close.status, close.stderr).toBe(0);
|
||||
expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/3 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m);
|
||||
expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/2 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m);
|
||||
expect(close.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running');
|
||||
const run = readRunJson(s.out);
|
||||
expect(run.phases.ceo.outside.status).toBe('skipped');
|
||||
@@ -126,9 +126,9 @@ describe('close --deferred', () => {
|
||||
expect(v.status, v.stdout + v.stderr).toBe(0);
|
||||
const reply = answer(s.out, 1, 'rec');
|
||||
expect(reply.status, reply.stderr).toBe(0);
|
||||
expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=4 \(uc\d+b by human\)$/m);
|
||||
expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=3 \(uc\d+b by human\)$/m);
|
||||
expect(reply.stdout).toMatch(/^REOPENED: ceo,eng/m);
|
||||
expect(reply.stdout).toMatch(/^ROUND_GRANTED: eng cap=4 \(reruns after ceo; uc\d+b by human\)$/m);
|
||||
expect(reply.stdout).toMatch(/^ROUND_GRANTED: eng cap=3 \(reruns after ceo; uc\d+b by human\)$/m);
|
||||
expect(readRunJson(s.out).round_grants).toEqual({ ceo: 1, eng: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const ok = (res: { status: number | null; stderr: string; stdout: string }) => {
|
||||
|
||||
describe('unattended runs on the Capy host', () => {
|
||||
test('a three-revision run driven only through the CLI validates with unique ids and records gstack_version', () => {
|
||||
const s = start();
|
||||
const s = start(['--max-rounds', '3']);
|
||||
expect(readRunJson(s.out).gstack_version).toBe(VERSION);
|
||||
for (const rev of [1, 2]) {
|
||||
phaseVia(r, s.out, 'ceo');
|
||||
@@ -134,7 +134,7 @@ describe('unattended runs on the Capy host', () => {
|
||||
describe('conformance: one run through every mechanism', () => {
|
||||
test('canonical and attested binds, an unavailable voice, partial then full replies, PLAN_AMEND_PENDING, revise --incorporates, an interruption, three gate revisions', () => {
|
||||
const c: CapyRunner = r;
|
||||
const s = start();
|
||||
const s = start(['--max-rounds', '3']);
|
||||
const run = readRunJson(s.out).run;
|
||||
// ceo: native canonical (fixture), outside attested record
|
||||
ok(c.review(path.join(s.out, 'ceo-native-prompt.md')));
|
||||
|
||||
@@ -31,8 +31,8 @@ function start(args: string[] = [], text = SMALL_PLAN): { out: string; dir: stri
|
||||
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(): { out: string; dir: string; plan: string } {
|
||||
const s = start();
|
||||
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;
|
||||
@@ -97,7 +97,7 @@ describe('revise', () => {
|
||||
});
|
||||
|
||||
test('a second revise never overwrites the first archive', () => {
|
||||
const s = gated();
|
||||
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: [] });
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('per-axis provenance, --no-dx and the plan marker', () => {
|
||||
const out = path.join(host.base, 'run-pin');
|
||||
const r = next(['--out', out, '--plan', file, '--no-ui', '--developer-tool']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) source=ui:flag,dx:flag$/m);
|
||||
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) docs_only=no \(detected\) source=ui:flag,dx:flag$/m);
|
||||
expect(r.stdout).toContain(`SCOPE_PIN: add <!-- gstack-autoplan-scope: --no-ui --developer-tool --> as the first line of ${file} to keep these flags`);
|
||||
expect(fs.readFileSync(file).equals(before)).toBe(true);
|
||||
expect(readRun(out).scope).toMatchObject({ cli_flags: ['--no-ui', '--developer-tool'], marker: null, sources: { ui: 'flag', dx: 'flag' } });
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* GSTA-131 token efficiency in the unattended /autoplan runner, on the
|
||||
* simulated Capy host: new unattended runs default to two review rounds (a
|
||||
* resumed run keeps what it persisted); `revise --incorporates` applies an
|
||||
* edit without re-review when every incorporated decision resolves below High
|
||||
* and nothing new comes into scope, and reopens on anything else; the gate at
|
||||
* the round cap prints ESCALATE while High findings stay open; a docs-only
|
||||
* plan runs CEO (native voice only) and Eng; export counts every dispatched
|
||||
* attempt and a relative input-token estimate. Every spawn has a timeout.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { detectDocsOnly, docsClassPath } from '../lib/autoplan-scope';
|
||||
import { ROOT, 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 NONE = { native: [], outside: [] };
|
||||
const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, jitter drawn from a seeded source');
|
||||
const DX_V2 = SMALL_PLAN.replace('Tests cover', 'Add a CLI flag --max-retries and an error message for a bad value. Tests cover');
|
||||
const DOCS_PLAN = '# Retry docs\n\n## Implementation plan\n\nRewrite README.md and docs/howto-retry.md so they explain the retry backoff, and add a CHANGELOG.md entry.\n\n## Review record\n';
|
||||
const journal = (out: string) => readJsonl(path.join(out, 'attempts.jsonl'));
|
||||
const voices = (stdout: string) => stdout.split('\n').filter(l => l.startsWith('ATTEMPT: ')).map(l => /voice=(\w+)/.exec(l)![1]);
|
||||
function start(args: string[] = [], text = SMALL_PLAN) {
|
||||
const dir = path.join(r.host.base, `tok${++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, first };
|
||||
}
|
||||
const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; };
|
||||
const disagreement = (native: string, outside: string) => ({
|
||||
native: [{ id: 'F1', severity: native, title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' }],
|
||||
outside: [{ id: 'O1', severity: outside, title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' }],
|
||||
});
|
||||
/** A run gated on one ceo disagreement d1, answered with its plan-changing option: amend_pending on d1. */
|
||||
function amended(native: string, outside: string, args: string[] = []) {
|
||||
const s = start(args);
|
||||
phaseVia(r, s.out, 'ceo', disagreement(native, outside));
|
||||
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending');
|
||||
const res = r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except d1b']);
|
||||
expect(res.status, res.stderr).toBe(0);
|
||||
expect(readRunJson(s.out).status).toBe('amend_pending');
|
||||
return { ...s, run: readRunJson(s.out).run as string };
|
||||
}
|
||||
function bindNative(out: string) {
|
||||
const run = readRunJson(out);
|
||||
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: '[]' }).status).toBe(0);
|
||||
return r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]);
|
||||
}
|
||||
|
||||
describe('a quiet revise after a capped one', () => {
|
||||
// Value: protects=the owner still sees an earlier capped delta; fails_when=a below-High revise after a capped one leaves the
|
||||
// answered gate in place (gate_rev unchanged, the delta item never rendered); seam=none; no_claim=none
|
||||
test('regates so the capped delta reaches the owner', () => {
|
||||
const s = start(['--max-rounds', '1']);
|
||||
phaseVia(r, s.out, 'ceo', {
|
||||
native: [{ id: 'F1', severity: 'High', title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' },
|
||||
{ id: 'F2', severity: 'Medium', title: 'Cap value is unexplained', file: 'lib/config.ts', line: 3, fix: 'explain the cap' }],
|
||||
outside: [{ id: 'O1', severity: 'Low', title: 'Jitter source is unstated', file: 'lib/worker.ts', line: 19, fix: 'name the source' },
|
||||
{ id: 'O2', severity: 'Informational', title: 'Cap value is unexplained', file: 'lib/config.ts', line: 3, fix: 'explain the cap' }],
|
||||
});
|
||||
phaseVia(r, s.out, 'eng', NONE);
|
||||
expect(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except d1b d2b']).status).toBe(0);
|
||||
const first = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir), '--incorporates', 'd1']);
|
||||
expect(first.stdout).toMatch(/capped=/);
|
||||
const second = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s (explained), jitter drawn from a seeded source')), '--incorporates', 'd2']);
|
||||
expect(second.status, second.stderr).toBe(0);
|
||||
expect(second.stdout).toContain('without re-review (below High; plan +');
|
||||
const run = readRunJson(s.out);
|
||||
expect(run.status).toBe('gate_pending');
|
||||
expect(run.gate_rev).toBe(2);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).gate_rev).toBe(2);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('round budget', () => {
|
||||
test('a new unattended run defaults to two rounds per phase', () => {
|
||||
const s = start();
|
||||
expect(s.first.stdout).toMatch(/^ROUNDS: ceo=1\/2 eng=1\/2 max_rounds=2 host_spend=unknown$/m);
|
||||
expect(readRunJson(s.out).max_rounds).toBe(2);
|
||||
});
|
||||
|
||||
test('a resumed run keeps the max_rounds it persisted', () => {
|
||||
const s = start();
|
||||
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify({ ...readRunJson(s.out), max_rounds: 1 }, null, 2) + '\n');
|
||||
expect(r.ap(['resume', '--out', s.out]).status).toBe(0);
|
||||
expect(readRunJson(s.out).max_rounds).toBe(1);
|
||||
expect(r.ap(['status', '--out', s.out]).stdout).toMatch(/^ROUNDS: ceo=1\/1 eng=1\/1 max_rounds=1 /m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revise by severity', () => {
|
||||
test('below-High decisions apply without re-review: no phase reopens, snapshots rebind, the run completes', () => {
|
||||
const g = amended('Medium', 'Informational');
|
||||
const before = readRunJson(g.out);
|
||||
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
|
||||
expect(rev.status, rev.stderr).toBe(0);
|
||||
expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=none$/m);
|
||||
expect(rev.stdout).toContain(`APPLIED: ${g.run}-d1 without re-review (below High; plan +`);
|
||||
expect(rev.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete');
|
||||
const run = readRunJson(g.out);
|
||||
const row = journal(g.out).find(e => e.event === 'applied_without_rereview');
|
||||
expect(row).toMatchObject({ decisions: [`${g.run}-d1`], severities: { [`${g.run}-d1`]: 'Medium' }, phases: ['ceo', 'eng'], old_plan_sha256: before.phases.ceo.snapshot });
|
||||
for (const p of ['ceo', 'eng']) {
|
||||
expect(run.phases[p].snapshot).toBe(row.new_plan_sha256);
|
||||
expect(run.phases[p].rebound).toEqual([expect.objectContaining({ from: before.phases[p].snapshot, to: row.new_plan_sha256, decisions: [`${g.run}-d1`] })]);
|
||||
expect(run.phases[p].closed_at).toBe(before.phases[p].closed_at);
|
||||
}
|
||||
expect(run.attempt_counter).toBe(before.attempt_counter);
|
||||
expect(journal(g.out).filter(e => e.event === 'reopened')).toEqual([]);
|
||||
expect(run.amendments[0]).toMatchObject({ status: 'incorporated' });
|
||||
const v = r.validate(g.out);
|
||||
expect(v.status, v.stdout + v.stderr).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
test('a High decision reopens as before', () => {
|
||||
const g = amended('High', 'Low');
|
||||
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
|
||||
expect(rev.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m);
|
||||
expect(rev.stdout).not.toContain('APPLIED:');
|
||||
expect(journal(g.out).some(e => e.event === 'applied_without_rereview')).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
test('a decision of unknown severity reopens', () => {
|
||||
const g = amended('Medium', 'Informational');
|
||||
const file = path.join(g.out, 'decisions.jsonl');
|
||||
fs.writeFileSync(file, readJsonl(file).map(d => JSON.stringify(d.label === 'D1' ? { ...d, origin: undefined } : d)).join('\n') + '\n');
|
||||
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir), '--incorporates', 'd1']);
|
||||
expect(rev.status, rev.stderr).toBe(0);
|
||||
expect(rev.stdout).toMatch(/reopened=ceo,eng$/m);
|
||||
expect(rev.stdout).not.toContain('APPLIED:');
|
||||
}, 30_000);
|
||||
|
||||
test('a below-High edit that brings a phase into scope reopens and adds it', () => {
|
||||
const g = amended('Medium', 'Informational');
|
||||
const rev = r.ap(['revise', '--out', g.out, '--plan', v2File(g.dir, DX_V2), '--incorporates', 'd1']);
|
||||
expect(rev.status, rev.stderr).toBe(0);
|
||||
expect(rev.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng added=dx$/m);
|
||||
expect(rev.stdout).not.toContain('APPLIED:');
|
||||
}, 30_000);
|
||||
|
||||
test('at the round cap a below-High edit applies without a capped delta; a High one keeps the unreviewed-delta gate item', () => {
|
||||
const low = amended('Medium', 'Informational', ['--max-rounds', '1']);
|
||||
const quiet = r.ap(['revise', '--out', low.out, '--plan', v2File(low.dir), '--incorporates', 'd1']);
|
||||
expect(quiet.stdout).toContain(`APPLIED: ${low.run}-d1 without re-review (below High; plan +`);
|
||||
expect(readRunJson(low.out).capped_deltas).toBeUndefined();
|
||||
const high = amended('High', 'Low', ['--max-rounds', '1']);
|
||||
const capped = r.ap(['revise', '--out', high.out, '--plan', v2File(high.dir), '--incorporates', 'd1']);
|
||||
expect(capped.stdout).toMatch(/^REVISED: plan=\S+ reopened=none capped=ceo,eng$/m);
|
||||
expect(capped.stdout).not.toContain('APPLIED:');
|
||||
expect(readRunJson(high.out).capped_deltas.map((d: any) => d.phase)).toEqual(['ceo', 'eng']);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('ESCALATE at the round cap', () => {
|
||||
const HIGH = { native: [{ severity: 'High', title: 'Retries can double-charge', fix: 'idempotency key' }], outside: [] };
|
||||
test('the gate at the cap with a High finding open names the decision', () => {
|
||||
const s = start(['--max-rounds', '1']);
|
||||
phaseVia(r, s.out, 'ceo', HIGH);
|
||||
const eng = phaseVia(r, s.out, 'eng', NONE);
|
||||
expect(eng.outside.stdout).toMatch(/^GATE: gate_rev=1 /m);
|
||||
expect(eng.outside.stdout.split('\n').filter(l => l.startsWith('ESCALATE:'))).toEqual(['ESCALATE: evidence sufficient, decision needed (ceo: 1 High open)']);
|
||||
}, 30_000);
|
||||
test('control: below the cap, or at it with nothing High open, no ESCALATE', () => {
|
||||
const s = start();
|
||||
phaseVia(r, s.out, 'ceo', HIGH);
|
||||
expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).not.toContain('ESCALATE:');
|
||||
const m = start(['--max-rounds', '1']);
|
||||
phaseVia(r, m.out, 'ceo', { native: [{ severity: 'Medium', title: 'Jitter source is unstated' }], outside: [] });
|
||||
const gate = phaseVia(r, m.out, 'eng', NONE).outside.stdout;
|
||||
expect(gate).toMatch(/^GATE: /m);
|
||||
expect(gate).not.toContain('ESCALATE:');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('docs-only', () => {
|
||||
test('docs-class paths', () => {
|
||||
for (const p of ['README.md', 'CHANGELOG.md', 'TODOS.md', 'docs/howto-retry.md', 'docs/designs/x/PLAN.md', 'notes.md', './README.md', 'docs/images/flow.png', 'docs/designs']) expect(docsClassPath(p, ROOT), p).toBe(true);
|
||||
for (const p of ['docs/review-policy.md', 'autoplan/SKILL.md.tmpl', 'autoplan/SKILL.md', 'review/sections/army.md', 'scripts/resolvers/autoplan-driver.ts', 'autoplan/DRIVER.md',
|
||||
'captain/templates/brief.md', 'autoplan/notes.md', 'AGENTS.md', 'CLAUDE.md', 'lib/worker.ts', 'bin/gstack-autoplan']) expect(docsClassPath(p, ROOT), p).toBe(false);
|
||||
expect(detectDocsOnly('Rewrite README.md and docs/howto.md.', ROOT)).toMatchObject({ docsOnly: true, code: [] });
|
||||
expect(detectDocsOnly('Rewrite README.md and docs/review-policy.md.', ROOT)).toMatchObject({ docsOnly: false, code: ['docs/review-policy.md'] });
|
||||
expect(detectDocsOnly('Make the retry docs clearer.', ROOT).docsOnly).toBe(false);
|
||||
});
|
||||
|
||||
test('detected: CEO dispatches the native voice only, Eng both; SCOPE reports it; DISPATCHES counts what ran', () => {
|
||||
const s = start([], DOCS_PLAN);
|
||||
expect(s.first.stdout).toMatch(/^SCOPE: ui=no .* docs_only=yes \(detected\) source=ui:flag,dx:detected$/m);
|
||||
expect(s.first.stdout).toMatch(/^PHASE: ceo snapshot=[0-9a-f]{64} order=ceo,eng$/m);
|
||||
expect(s.first.stdout).toContain('OUTSIDE: ceo not_run reason=docs_only (native voice only)');
|
||||
expect(voices(s.first.stdout)).toEqual(['native']);
|
||||
expect(readRunJson(s.out).phases.ceo.outside).toMatchObject({ status: 'disabled', outcome: 'not_run', reason: 'docs_only' });
|
||||
expect(bindNative(s.out).stdout).toMatch(/^PHASE_CLOSED: ceo /m);
|
||||
const eng = phaseVia(r, s.out, 'eng', NONE);
|
||||
expect(voices(eng.next.stdout)).toEqual(['native', 'outside']);
|
||||
expect(eng.outside.stdout).toContain('status=gate_pending');
|
||||
const exp = r.ap(['export', '--out', s.out]);
|
||||
expect(exp.status, exp.stderr).toBe(0);
|
||||
const run = readRunJson(s.out);
|
||||
const bytes = journal(s.out).filter(e => e.event === 'dispatched').reduce((t, e) => t + e.prompt_bytes, 0);
|
||||
expect(run.dispatches).toEqual({ total: 3, by_phase: { ceo: { native: 1 }, eng: { native: 1, outside: 1 } } });
|
||||
expect(run.est_input_tokens).toBe(Math.round(bytes / 4));
|
||||
expect(exp.stdout).toContain(`DISPATCHES: 3 est_input_tokens=${run.est_input_tokens} (relative estimate)`);
|
||||
const v = r.validate(s.out);
|
||||
expect(v.status, v.stdout + v.stderr).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
test('--docs-only on the first next forces CEO + Eng on a code plan; a phase-enabling flag outranks detection', () => {
|
||||
const flagged = start(['--docs-only', '--developer-tool']);
|
||||
expect(flagged.first.stdout).toMatch(/ docs_only=yes \(flag\) source=/m);
|
||||
expect(readRunJson(flagged.out).phase_order).toEqual(['ceo', 'eng']);
|
||||
const code = start();
|
||||
expect(code.first.stdout).toMatch(/ docs_only=no \(detected\) source=/m);
|
||||
expect(voices(code.first.stdout)).toEqual(['native', 'outside']);
|
||||
const dx = start(['--developer-tool'], DOCS_PLAN);
|
||||
expect(dx.first.stdout).toMatch(/ docs_only=no \(detected\) source=/m);
|
||||
expect(readRunJson(dx.out).phase_order).toEqual(['ceo', 'dx', 'eng']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch counts', () => {
|
||||
test('failed attempts count; a run.json without the fields still validates', () => {
|
||||
const s = start();
|
||||
expect(bindNative(s.out).status).toBe(0);
|
||||
expect(r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner crashed']).stdout).toMatch(/^PHASE_CLOSED: ceo /m);
|
||||
phaseVia(r, s.out, 'eng', NONE);
|
||||
const exp = r.ap(['export', '--out', s.out, '--json']);
|
||||
expect(JSON.parse(exp.stdout)).toMatchObject({ dispatches: { total: 4, by_phase: { ceo: { native: 1, outside: 1 }, eng: { native: 1, outside: 1 } } } });
|
||||
const run = readRunJson(s.out);
|
||||
expect(run.est_input_tokens).toBeGreaterThan(0);
|
||||
delete run.dispatches; delete run.est_input_tokens;
|
||||
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
|
||||
const v = r.validate(s.out);
|
||||
expect(v.status, v.stdout + v.stderr).toBe(0);
|
||||
expect(r.ap(['status', '--out', s.out]).status).toBe(0);
|
||||
}, 30_000);
|
||||
});
|
||||
+17
-17
@@ -205,9 +205,9 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; later passes read only the range since the previous pass; three passes
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
|
||||
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -328,12 +328,11 @@ repository-landing asks, including on Apple repos.
|
||||
`git log origin/<base>..HEAD --oneline`.
|
||||
|
||||
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
|
||||
names a PR, run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
|
||||
`CARRY: carried` means the product diff against `origin/<base>` has the same
|
||||
patch-id as the reviewed head's (only a base merge, tests or release metadata
|
||||
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
|
||||
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
|
||||
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
|
||||
names a PR, run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -397,8 +396,7 @@ This verdict never skips Step 9 or its finding, approval and convergence gates.
|
||||
Other rows provide context, not a substitute for Eng Review:
|
||||
- Recommend CEO Review for product/business or scope decisions, not routine fixes or cleanup.
|
||||
- Recommend Design Review for UI/UX work, not backend, infrastructure or prompt-only work.
|
||||
- Adversarial review always includes a native pass. Available, enabled outside
|
||||
challenges supplement it; diffs of 200+ lines also get the structured P1 gate.
|
||||
- Step 9's review plan decides the adversarial passes (Step 11).
|
||||
- Outside Voice is the default-on plan review after CEO/Eng review. `codex_reviews`
|
||||
disables that extra step. Provider failure uses native fallback and records
|
||||
missing outside coverage; this dashboard row never gates shipping.
|
||||
@@ -506,12 +504,14 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
## Step 11.5: Bind the reviews
|
||||
|
||||
1. **Select the two reviews.** Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-review-read`.
|
||||
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
|
||||
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
|
||||
Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a
|
||||
last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`;
|
||||
none after `ADVERSARIAL: skipped`). Match each to its saved
|
||||
handle, original token and source; reject outside-provider or older records.
|
||||
2. **Compare their content.** Require the native record's `review_binding.state`
|
||||
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
|
||||
2. **Compare their content.** All three snapshots must match: the native record's
|
||||
`wtree` (its `review_binding.state` `verified`), the Step 9 record's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`; after a planned skip, the
|
||||
last two with `review_freshness` `CURRENT`. A mismatch or a missing required
|
||||
record/field blocks release preparation: report **Review records missing or mismatched**
|
||||
and insert `9 → 10 → 11 → 11.5` before Step 12. Bind the new records at 11.5.
|
||||
Never attach new tokens to old work.
|
||||
@@ -751,7 +751,7 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
|
||||
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
|
||||
can qualify; scripts, dependencies and runtime configuration require live tests.
|
||||
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
|
||||
make evidence STALE even without a new code review. Use this example only once
|
||||
make evidence STALE even without a new code review; a Step 14 TODOS.md edit reruns them. Use this example only once
|
||||
every allowed edit is release metadata:
|
||||
|
||||
```bash
|
||||
|
||||
+17
-17
@@ -205,9 +205,9 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; later passes read only the range since the previous pass; three passes
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
|
||||
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -328,12 +328,11 @@ repository-landing asks, including on Apple repos.
|
||||
`git log origin/<base>..HEAD --oneline`.
|
||||
|
||||
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
|
||||
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
|
||||
`CARRY: carried` means the product diff against `origin/<base>` has the same
|
||||
patch-id as the reviewed head's (only a base merge, tests or release metadata
|
||||
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
|
||||
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
|
||||
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
|
||||
names a PR, run `~/.claude/skills/gstack/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -397,8 +396,7 @@ This verdict never skips Step 9 or its finding, approval and convergence gates.
|
||||
Other rows provide context, not a substitute for Eng Review:
|
||||
- Recommend CEO Review for product/business or scope decisions, not routine fixes or cleanup.
|
||||
- Recommend Design Review for UI/UX work, not backend, infrastructure or prompt-only work.
|
||||
- Adversarial review always includes a native pass. Available, enabled outside
|
||||
challenges supplement it; diffs of 200+ lines also get the structured P1 gate.
|
||||
- Step 9's review plan decides the adversarial passes (Step 11).
|
||||
- Outside Voice is the default-on plan review after CEO/Eng review. `codex_reviews`
|
||||
disables that extra step. Provider failure uses native fallback and records
|
||||
missing outside coverage; this dashboard row never gates shipping.
|
||||
@@ -506,12 +504,14 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
## Step 11.5: Bind the reviews
|
||||
|
||||
1. **Select the two reviews.** Run `~/.claude/skills/gstack/bin/gstack-review-read`.
|
||||
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
|
||||
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
|
||||
Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a
|
||||
last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`;
|
||||
none after `ADVERSARIAL: skipped`). Match each to its saved
|
||||
handle, original token and source; reject outside-provider or older records.
|
||||
2. **Compare their content.** Require the native record's `review_binding.state`
|
||||
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
|
||||
2. **Compare their content.** All three snapshots must match: the native record's
|
||||
`wtree` (its `review_binding.state` `verified`), the Step 9 record's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`; after a planned skip, the
|
||||
last two with `review_freshness` `CURRENT`. A mismatch or a missing required
|
||||
record/field blocks release preparation: report **Review records missing or mismatched**
|
||||
and insert `9 → 10 → 11 → 11.5` before Step 12. Bind the new records at 11.5.
|
||||
Never attach new tokens to old work.
|
||||
@@ -751,7 +751,7 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
|
||||
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
|
||||
can qualify; scripts, dependencies and runtime configuration require live tests.
|
||||
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
|
||||
make evidence STALE even without a new code review. Use this example only once
|
||||
make evidence STALE even without a new code review; a Step 14 TODOS.md edit reruns them. Use this example only once
|
||||
every allowed edit is release metadata:
|
||||
|
||||
```bash
|
||||
|
||||
+17
-17
@@ -203,9 +203,9 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; later passes read only the range since the previous pass; three passes
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
|
||||
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -326,12 +326,11 @@ repository-landing asks, including on Apple repos.
|
||||
`git log origin/<base>..HEAD --oneline`.
|
||||
|
||||
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
|
||||
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
|
||||
`CARRY: carried` means the product diff against `origin/<base>` has the same
|
||||
patch-id as the reviewed head's (only a base merge, tests or release metadata
|
||||
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
|
||||
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
|
||||
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
|
||||
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -396,8 +395,7 @@ This verdict never skips Step 9 or its finding, approval and convergence gates.
|
||||
Other rows provide context, not a substitute for Eng Review:
|
||||
- Recommend CEO Review for product/business or scope decisions, not routine fixes or cleanup.
|
||||
- Recommend Design Review for UI/UX work, not backend, infrastructure or prompt-only work.
|
||||
- Adversarial review always includes a native pass. Available, enabled outside
|
||||
challenges supplement it; diffs of 200+ lines also get the structured P1 gate.
|
||||
- Step 9's review plan decides the adversarial passes (Step 11).
|
||||
- Outside Voice is the default-on plan review after CEO/Eng review. `codex_reviews`
|
||||
disables that extra step. Provider failure uses native fallback and records
|
||||
missing outside coverage; this dashboard row never gates shipping.
|
||||
@@ -505,12 +503,14 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
## Step 11.5: Bind the reviews
|
||||
|
||||
1. **Select the two reviews.** Run `$GSTACK_ROOT/bin/gstack-review-read`.
|
||||
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
|
||||
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
|
||||
Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a
|
||||
last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`;
|
||||
none after `ADVERSARIAL: skipped`). Match each to its saved
|
||||
handle, original token and source; reject outside-provider or older records.
|
||||
2. **Compare their content.** Require the native record's `review_binding.state`
|
||||
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
|
||||
2. **Compare their content.** All three snapshots must match: the native record's
|
||||
`wtree` (its `review_binding.state` `verified`), the Step 9 record's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`; after a planned skip, the
|
||||
last two with `review_freshness` `CURRENT`. A mismatch or a missing required
|
||||
record/field blocks release preparation: report **Review records missing or mismatched**
|
||||
and insert `9 → 10 → 11 → 11.5` before Step 12. Bind the new records at 11.5.
|
||||
Never attach new tokens to old work.
|
||||
@@ -754,7 +754,7 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
|
||||
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
|
||||
can qualify; scripts, dependencies and runtime configuration require live tests.
|
||||
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
|
||||
make evidence STALE even without a new code review. Use this example only once
|
||||
make evidence STALE even without a new code review; a Step 14 TODOS.md edit reruns them. Use this example only once
|
||||
every allowed edit is release metadata:
|
||||
|
||||
```bash
|
||||
|
||||
+17
-17
@@ -205,9 +205,9 @@ not a commit ID. Use `git diff <old-tree> <new-tree>` to compare these snapshots
|
||||
**Review policy** (docs/review-policy.md): a finding blocks another review pass
|
||||
only when it would fail a test, break production, or lose data or secrets;
|
||||
everything below that floor ships as a named note in the PR body. Pass 1 reviews
|
||||
the diff; later passes read only the range since the previous pass; three passes
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. The ship receipt's
|
||||
`review_loop` counters record the outcome; `ship: review_scope` (Step 3) is the knob.
|
||||
the diff; a later pass reads only the range; two full passes plus one fix-check
|
||||
is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3)
|
||||
is the knob; the receipt's `review_loop` records the outcome.
|
||||
|
||||
### Ship control flow
|
||||
|
||||
@@ -328,12 +328,11 @@ repository-landing asks, including on Apple repos.
|
||||
`git log origin/<base>..HEAD --oneline`.
|
||||
|
||||
4. **Carry check.** If `gh pr list --head <branch-name> --state open --json number`
|
||||
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base>`.
|
||||
`CARRY: carried` means the product diff against `origin/<base>` has the same
|
||||
patch-id as the reviewed head's (only a base merge, tests or release metadata
|
||||
changed; the receipt's `carry` object holds the range-diff evidence): the receipt
|
||||
now binds HEAD and the run is complete at its `GSTACK_RESULT` line, so stop.
|
||||
`CARRY: refused` or `current` continues; a product change always re-runs Steps 2–16.
|
||||
names a PR, run `$GSTACK_ROOT/bin/gstack-ship-receipt carry --pr <n> --base <base> --dry-run`.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -398,8 +397,7 @@ This verdict never skips Step 9 or its finding, approval and convergence gates.
|
||||
Other rows provide context, not a substitute for Eng Review:
|
||||
- Recommend CEO Review for product/business or scope decisions, not routine fixes or cleanup.
|
||||
- Recommend Design Review for UI/UX work, not backend, infrastructure or prompt-only work.
|
||||
- Adversarial review always includes a native pass. Available, enabled outside
|
||||
challenges supplement it; diffs of 200+ lines also get the structured P1 gate.
|
||||
- Step 9's review plan decides the adversarial passes (Step 11).
|
||||
- Outside Voice is the default-on plan review after CEO/Eng review. `codex_reviews`
|
||||
disables that extra step. Provider failure uses native fallback and records
|
||||
missing outside coverage; this dashboard row never gates shipping.
|
||||
@@ -507,12 +505,14 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
|
||||
## Step 11.5: Bind the reviews
|
||||
|
||||
1. **Select the two reviews.** Run `$GSTACK_ROOT/bin/gstack-review-read`.
|
||||
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
|
||||
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
|
||||
Select this invocation's final Step 9 record (`skill:"review"`, `via:"ship"`, or a
|
||||
last 3.5 `review-fix-check`) and Step 11 native record (`skill:"adversarial-review"`;
|
||||
none after `ADVERSARIAL: skipped`). Match each to its saved
|
||||
handle, original token and source; reject outside-provider or older records.
|
||||
2. **Compare their content.** Require the native record's `review_binding.state`
|
||||
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
|
||||
2. **Compare their content.** All three snapshots must match: the native record's
|
||||
`wtree` (its `review_binding.state` `verified`), the Step 9 record's
|
||||
`review_binding.start_wtree` and `review_binding.end_wtree`; after a planned skip, the
|
||||
last two with `review_freshness` `CURRENT`. A mismatch or a missing required
|
||||
record/field blocks release preparation: report **Review records missing or mismatched**
|
||||
and insert `9 → 10 → 11 → 11.5` before Step 12. Bind the new records at 11.5.
|
||||
Never attach new tokens to old work.
|
||||
@@ -756,7 +756,7 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
|
||||
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
|
||||
can qualify; scripts, dependencies and runtime configuration require live tests.
|
||||
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
|
||||
make evidence STALE even without a new code review. Use this example only once
|
||||
make evidence STALE even without a new code review; a Step 14 TODOS.md edit reruns them. Use this example only once
|
||||
every allowed edit is release metadata:
|
||||
|
||||
```bash
|
||||
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "code",
|
||||
"commit": "392f50d",
|
||||
"paths": [
|
||||
"CHANGELOG.md",
|
||||
"VERSION",
|
||||
"agents-digest/gstack-AGENTS.md",
|
||||
"package.json",
|
||||
"plan-ceo-review/sections/review-sections.md",
|
||||
"plan-devex-review/sections/review-sections.md",
|
||||
"plan-eng-review/sections/review-sections.md",
|
||||
"review/SKILL.md",
|
||||
"review/SKILL.md.tmpl",
|
||||
"review/sections/manifest.json",
|
||||
"review/sections/plan-completion.md",
|
||||
"review/sections/review-army.md",
|
||||
"scripts/resolvers/outside-voice-steps.ts",
|
||||
"scripts/resolvers/plan-gates.ts",
|
||||
"scripts/resolvers/review-army.ts",
|
||||
"scripts/resolvers/review-scope.ts",
|
||||
"scripts/resolvers/sideways.ts",
|
||||
"ship/sections/review-army.md",
|
||||
"test/docsync-command-grammar.test.ts",
|
||||
"test/fixtures/design-floor-focus-cover-all.json",
|
||||
"test/fixtures/design-floor-focus-picker.ts",
|
||||
"test/fixtures/docsync-branch-listing-public.json",
|
||||
"test/fixtures/docsync-two-attempts-public.json",
|
||||
"test/fixtures/ship-local-hook-train-replies.json",
|
||||
"test/fixtures/skill-md-size-allowlist.json",
|
||||
"test/helpers/carve-guards.ts",
|
||||
"test/helpers/docsync-contract.ts",
|
||||
"test/helpers/docsync-interface.ts",
|
||||
"test/helpers/docsync-observer.ts",
|
||||
"test/helpers/free-fixtures-data.ts",
|
||||
"test/helpers/ship-hook-actor.ts",
|
||||
"test/outside-voice-preflight.test.ts",
|
||||
"test/plan-design-floor-fixture.test.ts",
|
||||
"test/plan-review-cases.test.ts",
|
||||
"test/review-workflow-clarity.test.ts",
|
||||
"test/ship-document-release-dispatch.test.ts",
|
||||
"test/ship-hook-actor.test.ts",
|
||||
"test/skill-e2e-ship-docsync.test.ts"
|
||||
],
|
||||
"deleted": [],
|
||||
"stamp_only": [
|
||||
"package.json"
|
||||
],
|
||||
"scope": {
|
||||
"SCOPE_FRONTEND": "false",
|
||||
"SCOPE_BACKEND": "true",
|
||||
"SCOPE_PROMPTS": "false",
|
||||
"SCOPE_TESTS": "true",
|
||||
"SCOPE_DOCS": "true",
|
||||
"SCOPE_CONFIG": "true",
|
||||
"SCOPE_MIGRATIONS": "false",
|
||||
"SCOPE_API": "false",
|
||||
"SCOPE_AUTH": "false"
|
||||
},
|
||||
"gate_tagged": 0,
|
||||
"diff_lines": 1491,
|
||||
"diff_bytes": 205941,
|
||||
"numbers": {
|
||||
"publishes": false,
|
||||
"claims": 0,
|
||||
"fingerprint": "none"
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "docs-only",
|
||||
"commit": "ab9a854",
|
||||
"paths": [
|
||||
"CHANGELOG.md",
|
||||
"README.md",
|
||||
"VERSION",
|
||||
"agents-digest/gstack-AGENTS.md",
|
||||
"docs/ADDING_A_HOST.md",
|
||||
"docs/domain-skills.md",
|
||||
"docs/gbrain-quick-tour.md",
|
||||
"docs/hosts.md",
|
||||
"docs/install.md",
|
||||
"docs/privacy.md",
|
||||
"docs/qa-without-a-webpage.md",
|
||||
"docs/standalone-binaries.md",
|
||||
"docs/troubleshooting.md",
|
||||
"docs/uninstall.md",
|
||||
"docs/voice-input.md",
|
||||
"docs/why-gstack.md",
|
||||
"package.json"
|
||||
],
|
||||
"deleted": [],
|
||||
"stamp_only": [
|
||||
"package.json"
|
||||
],
|
||||
"scope": {
|
||||
"SCOPE_FRONTEND": "false",
|
||||
"SCOPE_BACKEND": "false",
|
||||
"SCOPE_PROMPTS": "false",
|
||||
"SCOPE_TESTS": "false",
|
||||
"SCOPE_DOCS": "true",
|
||||
"SCOPE_CONFIG": "true",
|
||||
"SCOPE_MIGRATIONS": "false",
|
||||
"SCOPE_API": "false",
|
||||
"SCOPE_AUTH": "false"
|
||||
},
|
||||
"gate_tagged": 0,
|
||||
"diff_lines": 1329,
|
||||
"diff_bytes": 153962,
|
||||
"numbers": {
|
||||
"publishes": true,
|
||||
"claims": 34,
|
||||
"fingerprint": "36e9f8f080a2cdcd"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -22,13 +22,13 @@
|
||||
"cursor/gstack-plan-design-review": 108900,
|
||||
"cursor/gstack-plan-devex-review": 115228,
|
||||
"cursor/gstack-plan-eng-review": 145996,
|
||||
"cursor/gstack-review": 121628,
|
||||
"cursor/gstack-review": 121626,
|
||||
"factory/gstack-autoplan": 110763,
|
||||
"factory/gstack-land-and-deploy": 100105,
|
||||
"factory/gstack-plan-design-review": 111675,
|
||||
"factory/gstack-plan-devex-review": 118591,
|
||||
"factory/gstack-plan-eng-review": 149104,
|
||||
"factory/gstack-review": 125401,
|
||||
"factory/gstack-review": 125399,
|
||||
"gbrain/gstack-autoplan": 108892,
|
||||
"gbrain/gstack-plan-eng-review": 128623,
|
||||
"hermes/gstack-autoplan": 108982,
|
||||
@@ -37,18 +37,18 @@
|
||||
"kiro/gstack-plan-design-review": 108752,
|
||||
"kiro/gstack-plan-devex-review": 115080,
|
||||
"kiro/gstack-plan-eng-review": 145858,
|
||||
"kiro/gstack-review": 121452,
|
||||
"kiro/gstack-review": 121450,
|
||||
"openclaw/gstack-autoplan": 108715,
|
||||
"openclaw/gstack-plan-eng-review": 127138,
|
||||
"opencode/gstack-autoplan": 109234,
|
||||
"opencode/gstack-plan-design-review": 110187,
|
||||
"opencode/gstack-plan-devex-review": 115836,
|
||||
"opencode/gstack-plan-eng-review": 147265,
|
||||
"opencode/gstack-review": 122250,
|
||||
"opencode/gstack-review": 122248,
|
||||
"slate/gstack-autoplan": 108372,
|
||||
"slate/gstack-plan-design-review": 108826,
|
||||
"slate/gstack-plan-devex-review": 115154,
|
||||
"slate/gstack-plan-eng-review": 145927,
|
||||
"slate/gstack-review": 121540
|
||||
"slate/gstack-review": 121538
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -123,7 +123,7 @@
|
||||
"## Step 22: Terminal line (every run)"
|
||||
],
|
||||
"sections/adversarial.md": [
|
||||
"## Step 11: Adversarial review (always-on)"
|
||||
"## Step 11: Adversarial review"
|
||||
],
|
||||
"sections/changelog.md": [
|
||||
"## Step 13: CHANGELOG (auto-generate)"
|
||||
|
||||
@@ -69,12 +69,14 @@ export interface PhaseRunOptions { nativeModel?: string; outsideModel?: string;
|
||||
export function runPhase(out: string, phase: string, o: PhaseRunOptions = {}): { next: SpawnSyncReturns<string>; native: SpawnSyncReturns<string>; outside: SpawnSyncReturns<string> } {
|
||||
const next = autoplan(['next', '--out', out]);
|
||||
if (next.status !== 0) throw new Error(`next failed: ${next.stderr}\n${next.stdout}`);
|
||||
for (const voice of ['native', 'outside'] as const) {
|
||||
const outsideDisabled = readRun(out).phases[phase]?.outside?.status === 'disabled';
|
||||
for (const voice of outsideDisabled ? ['native'] as const : ['native', 'outside'] as const) {
|
||||
const r = review(path.join(out, `${phase}-${voice}-prompt.md`), o.breakVoice === voice && o.breakKind ? { FIXTURE_BREAK: o.breakKind } : {});
|
||||
if (r.status !== 0) throw new Error(`fixture reviewer failed: ${r.stderr}`);
|
||||
}
|
||||
const ids = attemptsOf(out, phase);
|
||||
const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']);
|
||||
if (outsideDisabled) return { next, native, outside: native };
|
||||
const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]);
|
||||
return { next, native, outside };
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
|
||||
maxSkeletonBytes: 60_913, // GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): the hoisted skeleton (59,706) plus this PR's growth, keeping the hoist's 65-byte headroom; measured 60,848. Before: + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535. + harness lane E (2026-10-26, issue row 3: four Captains re-ran /ship on identical product code): Step 1's carry check (gstack-ship-receipt carry binds an earlier receipt to HEAD when the product patch-id is unchanged); measured 82,165. + the Review policy paragraph (the blocking floor, pass structure and cap, NO FINDINGS, the review_loop receipt counters, the review_scope knob), stated in the skeleton because gstack is the canonical holder of its review policy (docs/review-policy.md); measured 82,645.
|
||||
maxSkeletonBytes: 60_960, // GSTA-131 token efficiency (2026-10-27, Garry's D1): Step 1's delta carry (dry run, pregate at HEAD, then carry --pregate) and Step 11.5's fix-check and planned-skip binding, +60 bytes after tightening Step 1 and the Review policy paragraph; measured 60,960 (before: 60,900 on 10b77de4). Before that: GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): the hoisted skeleton (59,706) plus this PR's growth, keeping the hoist's 65-byte headroom; measured 60,848. Before: + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 78_275. + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): Step 16 stage 4 ZERO-RUN/unknown receipt rows, stage 5 honesty inventory, Step 17 open-gate ready-PR gate; measured 80,540 (2026-10-10). + rule 22 / GBRA-108 (2026-10-25): Step 22's terminal line (gstack-ship-receipt result, the GSTACK_RESULT line a parent greps) and Step 18's unattended routing (an open PR at HEAD gets its body updated, else handoff), offset in part by tightening Steps 14, 16, 18 and 20 prose after the 2026-10-14 tightening left 2 bytes of headroom; measured 81,535. + harness lane E (2026-10-26, issue row 3: four Captains re-ran /ship on identical product code): Step 1's carry check (gstack-ship-receipt carry binds an earlier receipt to HEAD when the product patch-id is unchanged); measured 82,165. + the Review policy paragraph (the blocking floor, pass structure and cap, NO FINDINGS, the review_loop receipt counters, the review_scope knob), stated in the skeleton because gstack is the canonical holder of its review policy (docs/review-policy.md); measured 82,645.
|
||||
minUnionBytes: 181_000, // token-reduction Phases 1-2 (v1.69.x branch); measured union 201,464
|
||||
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
|
||||
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
|
||||
|
||||
@@ -189,6 +189,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/pty-idle-turn-end.json': ['test/helpers/claude-pty-runner.runners.unit.test.ts', 'test/helpers/claude-pty-runner.screen.unit.test.ts'],
|
||||
'test/fixtures/redact-diagram/**': ['test/redact-context-exemptions.test.ts'],
|
||||
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
|
||||
'test/fixtures/review-depth/**': ['test/review-dispatch-compare.test.ts'],
|
||||
'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'],
|
||||
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
|
||||
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
|
||||
|
||||
@@ -23,7 +23,7 @@ test('adversarial outside failures retain the required native pass without dupli
|
||||
expect(preflight).toMatch(/(?:do not dispatch a duplicate|without duplicating it)/);
|
||||
expect(preflight).not.toMatch(/fall(?:ing)? back to (?:a|the) .*subagent/i);
|
||||
const output = generateAdversarialStep(ctx);
|
||||
expect(output).toContain('adversarial subagent (always runs)');
|
||||
expect(output).toContain(skillName === 'ship' ? 'adversarial subagent (on `DISPATCH: adversarial-native`)' : 'adversarial subagent (always runs)');
|
||||
expectMentions(output, [['do not', 'dispatch', 'retain']], 'output');
|
||||
expect(output.match(/Retain the required native pass without duplicating it; it cannot complete outside coverage\./g)).toHaveLength(2);
|
||||
expect(output).not.toContain("Use the caller's fallback");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const none = scanNumbers({});
|
||||
const base = (o: Partial<PlanInput>): PlanInput => ({ paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, history: { fullPasses: 0, last: 'none' }, numbers: none, ...o });
|
||||
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}:${d.effort}`);
|
||||
|
||||
describe('classifyPath: agent-read markdown is code', () => {
|
||||
test.each([
|
||||
['README.md', 'docs'], ['CHANGELOG.md', 'docs'], ['docs/howto-x.md', 'docs'], ['TODOS.md', 'docs'],
|
||||
['ship/SKILL.md.tmpl', 'code'], ['review/SKILL.md', 'code'], ['ship/sections/review-army.md', 'code'], ['scripts/resolvers/review-army.ts', 'code'],
|
||||
['docs/review-policy.md', 'code'], ['autoplan/DRIVER.md.tmpl', 'code'], ['captain/templates/brief.md', 'code'], ['AGENTS.md', 'code'], ['review/specialists/security.md', 'code'],
|
||||
['test/review-depth.test.ts', 'tests'], ['src/a.test.ts', 'tests'], ['pkg/x_test.go', 'tests'], ['test/fixtures/a.json', 'tests'],
|
||||
['lib/review-depth.ts', 'code'], ['package.json', 'code'],
|
||||
])('%s -> %s', (p, c) => expect(classifyPath(p)).toBe(c as any));
|
||||
});
|
||||
|
||||
describe('classifyDiff', () => {
|
||||
test('docs only stays docs', () => expect(classifyDiff({ paths: ['README.md', 'docs/a.md'], gateTagged: null }).class).toBe('docs'));
|
||||
test('a skill-template-only diff is code, not docs', () => expect(classifyDiff({ paths: ['ship/SKILL.md.tmpl'], gateTagged: 0 }).class).toBe('code'));
|
||||
test('mixed diff takes the most restrictive class', () => {
|
||||
expect(classifyDiff({ paths: ['README.md', 'test/a.test.ts'], gateTagged: 0 }).class).toBe('tests');
|
||||
expect(classifyDiff({ paths: ['README.md', 'lib/a.ts'], gateTagged: 0 }).class).toBe('code');
|
||||
});
|
||||
test('a scope error fails closed to code', () => expect(classifyDiff({ paths: ['README.md'], scope: { SCOPE_ERROR: 'unmatched' }, gateTagged: 0 }).class).toBe('code'));
|
||||
test('code with the gate scan unavailable fails closed to gate', () => expect(classifyDiff({ paths: ['lib/a.ts'], gateTagged: null }).class).toBe('gate'));
|
||||
test.each([
|
||||
[{ scope: { SCOPE_AUTH: 'true' } }], [{ scope: { SCOPE_MIGRATIONS: 'true' } }], [{ gateTagged: 2 }],
|
||||
[{ paths: ['lib/a.ts', '.github/workflows/ci.yml'] }], [{ paths: ['lib/a.ts', 'lib/credentials.ts'] }], [{ deleted: ['lib/old.ts'] }],
|
||||
])('gate signal %j raises code to gate', (o: any) => expect(classifyDiff({ paths: ['lib/a.ts'], gateTagged: 0, ...o }).class).toBe('gate'));
|
||||
test('docs never become gate from code-only signals', () => expect(classifyDiff({ paths: ['README.md'], gateTagged: null, deleted: ['docs/old.md'] }).class).toBe('docs'));
|
||||
});
|
||||
|
||||
describe('nextStep: the transition table', () => {
|
||||
test('pass 1 is full', () => expect(nextStep({ fullPasses: 0, last: 'none' }).step).toBe('full'));
|
||||
test('no edits is done', () => expect(nextStep({ fullPasses: 1, last: 'full' }).step).toBe('done'));
|
||||
test('a fixed blocker earns full pass 2', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true }).step).toBe('full'));
|
||||
test('below-floor fixes earn one fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true }).step).toBe('fix-check'));
|
||||
test('a fix touching gate code earns a full pass instead of a fix-check', () => expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, touchedGate: true }).step).toBe('full'));
|
||||
test('at the full-pass cap a fixed blocker gets a fix-check', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true }).step).toBe('fix-check'));
|
||||
test('at the cap a gate-touching fix still gets a fix-check, never a third full pass', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', fixedBlocking: true, touchedGate: true }).step).toBe('fix-check'));
|
||||
test('a blocker open at the cap stops', () => expect(nextStep({ fullPasses: FULL_PASS_CAP, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||
test('a blocker open with no fix stops', () => expect(nextStep({ fullPasses: 1, last: 'full', blockingOpen: true }).step).toBe('stop'));
|
||||
test('a fix-check that finds a blocker stops, never a third full pass', () => {
|
||||
expect(nextStep({ fullPasses: 1, last: 'fix-check', blockingOpen: true }).step).toBe('stop');
|
||||
expect(nextStep({ fullPasses: 2, last: 'fix-check', fixedBlocking: true }).step).toBe('stop');
|
||||
});
|
||||
test('a clean fix-check is done', () => expect(nextStep({ fullPasses: 1, last: 'fix-check' }).step).toBe('done'));
|
||||
});
|
||||
|
||||
describe('scanNumbers', () => {
|
||||
test('finds claims and fingerprints them', () => {
|
||||
const a = scanNumbers({ 'docs/bench.md': ['Median dropped 48% to 1.2s', 'no numbers here'] });
|
||||
expect(a).toMatchObject({ publishes: true, claims: 1 });
|
||||
expect(scanNumbers({ 'docs/bench.md': ['Median dropped 47% to 1.2s'] }).fingerprint).not.toBe(a.fingerprint);
|
||||
});
|
||||
test('versions, dates, issue and step numbers and plain prose are not claims', () => expect(scanNumbers({ 'CHANGELOG.md': ['## [1.91.283.0] - 2026-10-27', 'Fixes the ship loop (#3516), see Step 9.4 and rule 23.'] }).publishes).toBe(false));
|
||||
test.each([
|
||||
['the smallest two-sided p with 16 clusters is 2 x 0.5^6 = 0.031'],
|
||||
['the miss split is 10/52'], ['p falls to 3.05e-5'], ['median 1.4 passes per PR'], ['Hot-memory hits: 10 of 62'],
|
||||
])('a bare numeric claim counts: %s', line => expect(scanNumbers({ 'docs/comparing-runs.md': [line] }).publishes).toBe(true));
|
||||
});
|
||||
|
||||
describe('planReview', () => {
|
||||
test('docs class: core only, no outside voice', () => {
|
||||
const p = planReview(base({ paths: ['README.md'], scope: {}, gateTagged: null, diffLines: 300 }));
|
||||
expect(p.class).toBe('docs');
|
||||
expect(p.dispatches).toEqual([]);
|
||||
});
|
||||
test('docs class with numeric claims keeps the outside voice', () => {
|
||||
const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) }));
|
||||
expect(roles(p)).toEqual(['outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('tests class: testing specialist only', () => {
|
||||
const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } }));
|
||||
expect(roles(p)).toEqual(['specialist:testing:full']);
|
||||
});
|
||||
test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => {
|
||||
const p = planReview(base({}));
|
||||
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:security:full', 'specialist:performance:full', 'specialist:simplification:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
|
||||
});
|
||||
test('gate class forces red team and ignores hit-rate gating', () => {
|
||||
const p = planReview(base({ gateTagged: 1, gated: ['security'] }));
|
||||
expect(roles(p)).toContain('red-team:red-team:full');
|
||||
expect(roles(p)).toContain('specialist:security:full');
|
||||
});
|
||||
test('code class honours hit-rate gating and small-diff skip', () => {
|
||||
expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full');
|
||||
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]);
|
||||
});
|
||||
test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => {
|
||||
const p = planReview(base({ adversarialRan: true, history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
expect(p.next.step).toBe('full');
|
||||
expect(roles(p).some(r => r.startsWith('outside-voice') || r.startsWith('adversarial'))).toBe(false);
|
||||
});
|
||||
test('a repeat that became code class keeps the adversarial pass Step 11 has not run yet', () => {
|
||||
const p = planReview(base({ history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
|
||||
expect(roles(p)).toContain('adversarial-native:adversarial:full');
|
||||
expect(planReview(base({ paths: ['docs/a.md'], history: { fullPasses: 1, last: 'full', fixedBlocking: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false);
|
||||
});
|
||||
test('a fix-check is one full-effort dispatch', () => {
|
||||
expect(roles(planReview(base({ history: { fullPasses: 1, last: 'full', fixedBelow: true } })))).toEqual(['fix-check:fix-check:full']);
|
||||
});
|
||||
test('changed numeric claims rerun only the outside voice', () => {
|
||||
const numbers = scanNumbers({ 'docs/bench.md': ['3x faster'] });
|
||||
const p = planReview(base({ numbers, reviewedNumbers: 'old', history: { fullPasses: 1, last: 'full', fixedBelow: true } }));
|
||||
expect(roles(p)).toEqual(['fix-check:fix-check:full', 'outside-voice:outside-voice (numbers changed):host']);
|
||||
});
|
||||
test('no reviewer runs below full effort (GSTA-131 eval: a light fix-check missed a fail-open a full one caught)', () => {
|
||||
for (const h of [{ fullPasses: 0, last: 'none' }, { fullPasses: 1, last: 'full', fixedBelow: true }, { fullPasses: 1, last: 'full', fixedBlocking: true }] as const)
|
||||
expect(planReview(base({ gateTagged: 1, history: h })).dispatches.filter(d => d.role !== 'outside-voice').every(d => d.effort === 'full')).toBe(true);
|
||||
});
|
||||
test('done dispatches nothing', () => expect(planReview(base({ history: { fullPasses: 1, last: 'full' } })).dispatches).toEqual([]));
|
||||
});
|
||||
|
||||
describe('GSTA-131 pass-1 review repairs', () => {
|
||||
test('rule docs agents follow are code, and markdown inside a skill directory is code when the root is known', () => {
|
||||
for (const p of ['docs/captain.md', 'docs/unattended.md', 'docs/pregate.md']) expect(classifyPath(p)).toBe('code');
|
||||
expect(classifyPath('autoplan/notes.md')).toBe('docs');
|
||||
expect(classifyPath('autoplan/notes.md', ROOT)).toBe('code');
|
||||
expect(classifyPath('docs/howto-x.md', ROOT)).toBe('docs');
|
||||
});
|
||||
test.each([
|
||||
[{ gateTagged: 3 }], [{ gateTagged: null }], [{ deleted: ['test/old.test.ts'] }],
|
||||
])('a tests-only diff with gate signal %j is gate class', (o: any) => expect(classifyDiff({ paths: ['test/a.test.ts'], gateTagged: 0, ...o }).class).toBe('gate'));
|
||||
test('a version-only package.json stays docs; the same path with any other edit is code', () => {
|
||||
expect(classifyDiff({ paths: ['package.json', 'CHANGELOG.md'], stampOnly: ['package.json'], gateTagged: 0 }).class).toBe('docs');
|
||||
expect(classifyDiff({ paths: ['package.json', 'CHANGELOG.md'], gateTagged: 0 }).class).toBe('code');
|
||||
});
|
||||
test('an open blocker never routes to a fix-check, even when other fixes landed', () => {
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBelow: true, blockingOpen: true }).step).toBe('stop');
|
||||
expect(nextStep({ fullPasses: 1, last: 'full', fixedBlocking: true, blockingOpen: true }).step).toBe('stop');
|
||||
});
|
||||
test('code and test source publish no numbers; prose and data files do', () => {
|
||||
expect(scanNumbers({ 'test/a.test.ts': ['const n = 3;', 'for (let i = 0; i < 10; i++)', 'expect(x).toBe(0.5)'] }).publishes).toBe(false);
|
||||
expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true);
|
||||
});
|
||||
test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => {
|
||||
expect(planReview(base({ force: 'all' })).dispatches.filter(d => d.role === 'specialist').length).toBe(9);
|
||||
const has = (o: Partial<PlanInput>) => roles(planReview(base(o))).some(r => r.startsWith('red-team'));
|
||||
expect(has({ diffLines: 120 })).toBe(false);
|
||||
expect(has({ diffLines: 201 })).toBe(true);
|
||||
expect(has({ diffLines: 120, criticalFound: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GSTA-131 coverage: Red Team and specialist arms', () => {
|
||||
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}`);
|
||||
test('Red Team runs on code past 200 lines or after a CRITICAL, and on every gate diff', () => {
|
||||
expect(roles(planReview(base({ diffLines: 120 })))).not.toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 201 })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 120, criticalFound: true })))).toContain('red-team:red-team');
|
||||
expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team');
|
||||
});
|
||||
test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => {
|
||||
const picked = (scope: Record<string, string>) => roles(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
|
||||
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('specialist:data-migration');
|
||||
expect(picked({ SCOPE_API: 'true' })).toContain('specialist:api-contract');
|
||||
expect(picked({ SCOPE_FRONTEND: 'true' })).toContain('specialist:design');
|
||||
expect(roles(planReview(base({ scope: {} })))).toContain('specialist:state-machines');
|
||||
expect(roles(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('specialist:state-machines');
|
||||
const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' }));
|
||||
expect(docsAll.class).toBe('docs');
|
||||
expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5);
|
||||
expect(roles(planReview(base({ paths: ['docs/a.md'], force: ['security'] })))).toEqual(['specialist:security']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'review-dispatch-compare.ts');
|
||||
const FIXTURES = ['test/fixtures/review-depth/docs-only.json', 'test/fixtures/review-depth/code.json'].map(f => path.join(ROOT, f));
|
||||
const run = (args: string[]) => {
|
||||
const r = runScriptSync(SCRIPT, [...FIXTURES.flatMap(f => ['--fixture', f]), ...args], { cwd: ROOT, encoding: 'utf8', timeout: 120_000 });
|
||||
expect(r.status).toBe(0);
|
||||
return r.stdout as string;
|
||||
};
|
||||
|
||||
describe('scripts/review-dispatch-compare.ts', () => {
|
||||
test('the new policy never dispatches more reviewers than the legacy one, and a docs diff costs under a third', () => {
|
||||
const out = JSON.parse(run(['--json']));
|
||||
expect(out.tokens_source).toBe('DEFAULT');
|
||||
expect(out.ship.length).toBe(10);
|
||||
for (const row of out.ship) {
|
||||
expect(row.new_dispatches).toBeLessThanOrEqual(row.legacy_dispatches);
|
||||
expect(row.new_tokens).toBeLessThanOrEqual(row.legacy_tokens);
|
||||
}
|
||||
for (const row of out.ship.filter((r: any) => r.class === 'docs')) expect(row.ratio).toBeLessThan(0.34);
|
||||
for (const f of out.autoplan) for (const r of f.runs) expect(r.next).toBeLessThanOrEqual(r.legacy);
|
||||
});
|
||||
|
||||
test('--tokens replaces the default output estimates and the table says which it used', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-compare-'));
|
||||
try {
|
||||
const tok = path.join(dir, 'measured.json');
|
||||
fs.writeFileSync(tok, JSON.stringify({ specialist: { full: 6929, light: 5446 } }));
|
||||
const table = run(['--tokens', tok]);
|
||||
expect(table).toContain(`TOKENS: measured (${tok})`);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO: median=0\.\d+ min=0\.\d+ max=\d\.\d+ \(n=10\)$/m);
|
||||
expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); code median=0\.\d+ \(n=5\)$/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { summarizeReviewLoop, reviewLoopLine, FIX_CHECK_SKILL } from '../lib/review-scope';
|
||||
import { parseReviewLoop, receiptSummaryLine } from '../lib/ship-receipt';
|
||||
|
||||
const since = '2026-10-27T10:00:00Z';
|
||||
const row = (o: Record<string, unknown>) => ({ via: 'ship', review_scope: 'frozen', findings: [], ...o });
|
||||
|
||||
describe('review loop dispatch counters', () => {
|
||||
test('fix-check rows count as fix_checks, never passes, and dispatch events add up by role', () => {
|
||||
const rows = [
|
||||
row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z', findings: [{ fingerprint: 'a:1:x', action: 'auto-fixed' }],
|
||||
dispatches: [{ role: 'specialist', prompt_bytes: 4000, diff_bytes: 8000 }, { role: 'specialist', prompt_bytes: 4000, diff_bytes: 8000 }, { role: 'red-team', prompt_bytes: 2000 }] }),
|
||||
row({ skill: 'adversarial-review', timestamp: '2026-10-27T10:05:00Z', dispatches: [{ role: 'adversarial-native', prompt_bytes: 1000 }, { role: 'outside-voice', status: 'unknown' }] }),
|
||||
row({ skill: FIX_CHECK_SKILL, timestamp: '2026-10-27T10:10:00Z', dispatches: [{ role: 'fix-check', prompt_bytes: 400, diff_bytes: 600 }] }),
|
||||
row({ skill: 'review', timestamp: '2026-10-27T09:00:00Z', dispatches: [{ role: 'specialist', prompt_bytes: 99999 }] }),
|
||||
];
|
||||
const s = summarizeReviewLoop(rows, since);
|
||||
expect(s).toMatchObject({ passes: 1, fixed: 1, fix_checks: 1, dispatches: { specialist: 2, 'red-team': 1, 'adversarial-native': 1, 'outside-voice': 1, 'fix-check': 1 }, est_input_tokens: Math.round(28000 / 4) });
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=1 scope=frozen fix_checks=1 dispatches=6 est_input_tokens=7000');
|
||||
});
|
||||
|
||||
test('older rows without dispatch events fall back to the specialist stats and print the old line plus a count', () => {
|
||||
const rows = [row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z', specialists: { testing: { dispatched: true }, security: { dispatched: false, reason: 'scope' }, 'red-team': { dispatched: true } } })];
|
||||
const s = summarizeReviewLoop(rows, since);
|
||||
expect(s.dispatches).toEqual({ specialist: 1, 'red-team': 1 });
|
||||
expect(s.est_input_tokens).toBeUndefined();
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen dispatches=2');
|
||||
});
|
||||
|
||||
test('rows with neither keep the historical line byte for byte', () => {
|
||||
const s = summarizeReviewLoop([row({ skill: 'review', timestamp: '2026-10-27T10:01:00Z' })], since);
|
||||
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=1 blocking_found=0 deferred=0 noted=0 fixed=0 scope=frozen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('receipt round trip', () => {
|
||||
test('parseReviewLoop keeps the new counters and drops malformed ones; an old receipt parses unchanged', () => {
|
||||
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0 })).toEqual({ passes: 1, blocking_found: 0, deferred: 0 });
|
||||
expect(parseReviewLoop({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, est_input_tokens: 7000, dispatches: { specialist: 2, 'Bad Key': 3, 'red-team': -1, 'fix-check': 1 } }))
|
||||
.toEqual({ passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, est_input_tokens: 7000, dispatches: { specialist: 2, 'fix-check': 1 } });
|
||||
});
|
||||
test('the SHIP_RECEIPT line carries fix_checks and the dispatch total only when recorded', () => {
|
||||
const r = { schema: 1, skill: 'ship', head: 'a'.repeat(40), base: 'main', pr: 7 } as any;
|
||||
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0 } })).toContain('passes=1 blocking_found=0 deferred=0');
|
||||
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0 } })).not.toContain('dispatches=');
|
||||
expect(receiptSummaryLine({ ...r, review_loop: { passes: 1, blocking_found: 0, deferred: 0, fix_checks: 1, dispatches: { specialist: 2, 'fix-check': 1 } } })).toContain('deferred=0 fix_checks=1 dispatches=3');
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,19 @@ describe('lib: deriveFromFindings, bindReview order and reviewFreshness', () =>
|
||||
expect(fs.existsSync(path.join(dir, `${token}.json`))).toBe(false);
|
||||
});
|
||||
|
||||
test('a review-fix-check record binds like a pass, so Step 11.5 can read its CURRENT freshness', () => {
|
||||
const dir = path.join(home, 'starts', '.review-starts');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const token = '66666666-2222-3333-4444-555555555555';
|
||||
const w = 'c'.repeat(64);
|
||||
fs.writeFileSync(path.join(dir, `${token}.json`), JSON.stringify({ skill: 'review-fix-check', repo: '/r', branch: 'b', wtree: w, started_at: 't' }));
|
||||
const env = { GSTACK_REVIEW_DIR: path.join(home, 'starts'), GSTACK_REVIEW_REPO: '/r', GSTACK_REVIEW_BRANCH: 'b', GSTACK_STAMP_WTREE: w };
|
||||
const bound = bindReview({ skill: 'review-fix-check', status: 'clean', completed: true, converged: true }, token, env);
|
||||
expect(bound.review_binding).toMatchObject({ state: 'verified', start_wtree: w, end_wtree: w });
|
||||
expect(reviewFreshness(bound, w)?.status).toBe('CURRENT');
|
||||
expect(reviewFreshness(bound, 'd'.repeat(64))?.status).toBe('STALE');
|
||||
});
|
||||
|
||||
test('reviewFreshness reads findings_open: resolved criticals are CURRENT, claimed issues_found stays conservative', () => {
|
||||
const w = 'b'.repeat(64);
|
||||
const base = { skill: 'review', completed: true, converged: true, wtree: w, review_binding: { state: 'verified', start_wtree: w, end_wtree: w } };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-review-plan');
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
|
||||
function repo(files: Record<string, string>): { dir: string; state: string } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-plan-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'review-plan-state-'));
|
||||
dirs.push(dir, state);
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 });
|
||||
git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# x\n'); git('add', '.'); git('commit', '-qm', 'base');
|
||||
git('checkout', '-qb', 'feat');
|
||||
for (const [p, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), text); }
|
||||
git('add', '.'); git('commit', '-qm', 'feat');
|
||||
return { dir, state };
|
||||
}
|
||||
const run = (r: { dir: string; state: string }, args: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', ...args], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
|
||||
describe('gstack-review-plan', () => {
|
||||
test('a docs-only diff plans no dispatch and says why', () => {
|
||||
const r = run(repo({ 'docs/howto.md': '# How to\nStep one.\n' }), []);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REVIEW_PLAN: class=docs pass=1 next=full dispatches=0 numbers=no');
|
||||
expect(r.stdout).toContain('SKIP: outside-voice (docs class, no numeric claims)');
|
||||
});
|
||||
test('a docs diff that publishes numbers keeps the outside voice', () => {
|
||||
const r = run(repo({ 'docs/bench.md': 'Median latency fell 42% to 180 ms.\n' }), []);
|
||||
expect(r.stdout).toContain('DISPATCH: outside-voice outside-voice effort=host');
|
||||
});
|
||||
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders audits at host effort', () => {
|
||||
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
|
||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
||||
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
|
||||
expect(plan.dispatches.find((d: any) => d.role === 'specialist').effort).toBe('full');
|
||||
expect(plan.audit_effort).toBe('light');
|
||||
expect(JSON.parse(run(r, ['--json', '--no-tiers']).stdout).audit_effort).toBe('host');
|
||||
});
|
||||
test('a below-floor fix after pass 1 plans one fix-check', () => {
|
||||
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
|
||||
expect(r.stdout).toContain('next=fix-check dispatches=1');
|
||||
expect(r.stdout).toContain('DISPATCH: fix-check fix-check effort=full');
|
||||
});
|
||||
test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => {
|
||||
const r = repo({ 'docs/howto.md': '# x\n' });
|
||||
const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; };
|
||||
const files = ['--fix-list', w('fix.txt', 'lib/a.ts:3 guard now refuses unknown'), '--hunks', w('hunks.diff', '+if (s !== "MERGED") return false;'), '--tests', w('tests.txt', '12 pass 0 fail')];
|
||||
const ok = spawnSync(BIN, ['--fix-check-prompt', ...files], { cwd: r.dir, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(ok.status).toBe(0);
|
||||
expect(ok.stdout).toContain('You are the fix-check reviewer');
|
||||
expect(ok.stdout).toContain('lib/a.ts:3 guard now refuses unknown');
|
||||
expect(ok.stdout).toContain('(none found)');
|
||||
const missing = spawnSync(BIN, ['--fix-check-prompt', '--fix-list', path.join(r.state, 'nope.txt'), '--hunks', files[3]!, '--tests', files[5]!], { cwd: r.dir, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(missing.status).toBe(2);
|
||||
});
|
||||
test('untracked files count, a version-only package.json bump stays docs, and review_effort_tiers off renders audits at host effort', () => {
|
||||
const r = repo({ 'docs/a.md': '# a\n' });
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 });
|
||||
git('checkout', '-q', 'main');
|
||||
fs.writeFileSync(path.join(r.dir, 'package.json'), '{\n "name": "x",\n "version": "1.0.0"\n}\n');
|
||||
git('add', '.'); git('commit', '-qm', 'package');
|
||||
git('checkout', '-q', 'feat'); git('reset', '-q', '--hard', 'main');
|
||||
fs.writeFileSync(path.join(r.dir, 'package.json'), '{\n "name": "x",\n "version": "1.0.1"\n}\n');
|
||||
git('commit', '-qam', 'stamp');
|
||||
const stamp = JSON.parse(run(r, ['--json']).stdout);
|
||||
expect(stamp.stamp_only).toContain('package.json');
|
||||
expect(stamp.class).toBe('docs');
|
||||
fs.mkdirSync(path.join(r.dir, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(path.join(r.dir, 'lib', 'new.ts'), 'export const a = 1;\n');
|
||||
const withUntracked = JSON.parse(run(r, ['--json']).stdout);
|
||||
expect(withUntracked.paths).toContain('lib/new.ts');
|
||||
expect(withUntracked.class).toBe('code');
|
||||
const cfg = spawnSync(path.join(import.meta.dir, '..', 'bin', 'gstack-config'), ['set', 'review_effort_tiers', 'off'], { cwd: r.dir, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(cfg.status).toBe(0);
|
||||
expect(JSON.parse(run(r, ['--json']).stdout).audit_effort).toBe('host');
|
||||
const bad = spawnSync(path.join(import.meta.dir, '..', 'bin', 'gstack-config'), ['set', 'review_effort_tiers', 'maybe'], { cwd: r.dir, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(bad.status).not.toBe(0);
|
||||
expect(bad.stderr).toContain("review_effort_tiers 'maybe' not recognized");
|
||||
});
|
||||
test('without --gate-tagged the plan runs the gate scan itself', () => {
|
||||
const r = repo({ 'lib/a.ts': 'export const a = 1;\n' });
|
||||
const res = spawnSync(BIN, ['--base', 'main', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 120_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(res.status, res.stderr).toBe(0);
|
||||
expect(typeof JSON.parse(res.stdout).gate_tagged).toBe('number');
|
||||
});
|
||||
test('usage errors exit 2', () => {
|
||||
expect(spawnSync(BIN, [], { encoding: 'utf8', timeout: 10_000 }).status).toBe(2);
|
||||
expect(run(repo({ 'a.md': 'x' }), ['--last', 'later']).status).toBe(2);
|
||||
});
|
||||
test('scratch under .gstack/tmp/ is ignored, a subdirectory run plans the whole repository, and --adversarial-ran drops the repeat adversarial pass', () => {
|
||||
const r = repo({ 'docs/a.md': '# a\n' });
|
||||
fs.mkdirSync(path.join(r.dir, '.gstack', 'tmp'), { recursive: true });
|
||||
fs.writeFileSync(path.join(r.dir, '.gstack', 'tmp', 'fix-check-list.ts'), 'export const x = 1;\n');
|
||||
const plan = JSON.parse(run(r, ['--json']).stdout);
|
||||
expect(plan.class).toBe('docs');
|
||||
expect(plan.paths).toEqual(['docs/a.md']);
|
||||
const sub = spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', '--json'], { cwd: path.join(r.dir, 'docs'), encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } });
|
||||
expect(JSON.parse(sub.stdout).paths).toEqual(['docs/a.md']);
|
||||
const code = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
|
||||
const repeat = ['--pass', '1', '--last', 'full', '--fixed-blocking'];
|
||||
expect(run(code, repeat).stdout).toContain('DISPATCH: adversarial-native adversarial effort=full');
|
||||
const ran = run(code, [...repeat, '--adversarial-ran']).stdout;
|
||||
expect(ran).not.toContain('DISPATCH: adversarial-native');
|
||||
expect(ran).toContain('SKIP: adversarial-native and outside-voice (run once per invocation)');
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,27 @@ describe('ship source controller', () => {
|
||||
expect(freshness).not.toContain('**No attempt remains or no repair is available:**');
|
||||
});
|
||||
|
||||
test('the review plan drives the repeat, the fix-check binds under its own skill, and Step 11.5 accepts a fix-check or a planned adversarial skip', () => {
|
||||
const decide = review.slice(review.indexOf('### Decide whether to repeat Step 9'), review.indexOf('**Required-probe parent gate:**'));
|
||||
for (const flag of ['--fixed-blocking', '--fixed-below', '--blocking-open', '--touched-gate', '--critical-found', '--adversarial-ran', '--reviewed-numbers <fp>']) expect(decide).toContain(flag);
|
||||
expect(decide).toContain('its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.');
|
||||
expect(decide).toContain('`gstack-review-log --start review-fix-check` token');
|
||||
expect(decide).toContain('`"skill":"review-fix-check"` with `--finish <token>`');
|
||||
expect(compact(read('ship/sections/adversarial.md.tmpl'))).toContain("Step 9's last full-pass plan governs this step");
|
||||
const bind = compact(entry.slice(entry.indexOf('## Step 11.5:'), entry.indexOf('## Step 12:')));
|
||||
expect(bind).toContain("last 3.5 `review-fix-check`");
|
||||
expect(bind).toContain('none after `ADVERSARIAL: skipped`');
|
||||
expect(bind).toContain('`review_freshness` `CURRENT`');
|
||||
});
|
||||
|
||||
test('Step 1 carries a receipt only after a dry run and a pregate at HEAD', () => {
|
||||
const carry = compact(entry.slice(entry.indexOf('4. **Carry check.**'), entry.indexOf('5. Display historical readiness')));
|
||||
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.'];
|
||||
const at = order.map(t => carry.indexOf(t));
|
||||
expect(at.every(i => i >= 0)).toBe(true);
|
||||
expect(at).toEqual([...at].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('repairs use one ordered work list without a return stack', () => {
|
||||
const text = compact(controller);
|
||||
expect(compact(entry)).toContain('**Next steps:**');
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* gstack-ship-receipt carry with a pregate: eligibility is not publication. A receipt that recorded a
|
||||
* pregate rebinds to a new head only with a pregate current for that head (docs/ship-policy.md#receipt-carry).
|
||||
*/
|
||||
import { afterEach, 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 { extractReceipt } from '../lib/ship-receipt';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const pregate = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']);
|
||||
const outFile = (repo: FixtureRepo) => path.join(repo.work, '.gstack/tmp/pregate.json');
|
||||
|
||||
describe('carry with a recorded pregate', () => {
|
||||
// Value: protects=a carried receipt never claims the old tree's pregate; fails_when=carry rebinds without a current pregate
|
||||
// or accepts a stale one; seam=none; no_claim=gh pr edit (--body stands in)
|
||||
test('refuses without a current --pregate, dry-run still reports eligibility, and a current pregate rebinds', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
expect(pregate(repo).status).toBe(EXIT.ok);
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--pregate', outFile(repo), '--body', body]);
|
||||
expect(w.status).toBe(EXIT.ok);
|
||||
const oldPregate = fs.readFileSync(outFile(repo), 'utf8');
|
||||
repo.git('checkout', '-q', 'main');
|
||||
write(repo.work, 'docs/README.md', '# docs moved on main\n');
|
||||
repo.git('commit', '-qam', 'main: docs'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
|
||||
const bare = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.out).toContain('CARRY: refused (RECEIPT_CARRY_PREGATE_STALE)');
|
||||
expect(bare.err).toContain(`run gstack-pregate at HEAD ${head.slice(0, 12)}`);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
|
||||
|
||||
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run']);
|
||||
expect([dry.status, dry.out]).toEqual([EXIT.ok, expect.stringContaining('CARRY: would carry')]);
|
||||
|
||||
const stalePath = path.join(repo.root, 'old-pregate.json');
|
||||
fs.writeFileSync(stalePath, oldPregate);
|
||||
const stale = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--pregate', stalePath]);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toContain('PREGATE_STALE');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
|
||||
|
||||
expect(pregate(repo).status).toBe(EXIT.ok);
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--pregate', outFile(repo)]);
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(carried.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)}`);
|
||||
const after = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
expect(after.head).toBe(head);
|
||||
expect(after.pregate).toContain(`current for ${head.slice(0, 12)}`);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=--head cannot borrow another tree's pregate; fails_when=carry verifies the checkout's tree but stamps the
|
||||
// pregate current for a different --head; seam=none; no_claim=gh pr edit (--body stands in)
|
||||
test('a --pregate from a checkout at another commit never rebinds the receipt to --head', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
expect(pregate(repo).status).toBe(EXIT.ok);
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--pregate', outFile(repo), '--body', body]).status).toBe(EXIT.ok);
|
||||
const target = commit(repo, { 'test/util.test.js': "require('node:assert').strictEqual(1, 2);\n" });
|
||||
repo.git('checkout', '-q', reviewed);
|
||||
expect(pregate(repo).status).toBe(EXIT.ok);
|
||||
const borrowed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', target, '--pregate', outFile(repo)]);
|
||||
expect(borrowed.status).toBe(EXIT.refused);
|
||||
expect(borrowed.out).toContain('CARRY: refused (RECEIPT_CARRY_PREGATE_STALE)');
|
||||
expect(borrowed.err).toContain(`not the carried head ${target.slice(0, 12)}`);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 1757,
|
||||
"why": "GSTA-131 token efficiency: review plan dispatch, fix-check 3.5, plan-governed adversarial step, Step 1 delta carry, Step 11.5 fix-check and planned-skip binding, 400-word findings cap, light Step 7/8 audits; screen repair: one decision each for the loop exits, adversarial dispatch and Step 16 receipts"
|
||||
}
|
||||
@@ -1619,13 +1619,14 @@ describe('Codex skill', () => {
|
||||
expect(content).toContain('200');
|
||||
});
|
||||
|
||||
test('adversarial review in /ship always runs both passes', () => {
|
||||
test('adversarial review in /ship runs the passes the review plan dispatches', () => {
|
||||
const content = readShipUnion();
|
||||
expect(content).toContain('Adversarial review (always-on)');
|
||||
expect(content).toContain("Step 9's last full-pass plan governs this step");
|
||||
expect(content).toContain('## Step 11: Adversarial review\n');
|
||||
expect(content).toContain('adversarial-review');
|
||||
expect(content).toContain('run-with-timeout --effort high ');
|
||||
expect(content).toContain('Investigate and fix');
|
||||
expect(content).toContain('Claude adversarial subagent (always runs)');
|
||||
expect(content).toContain('Claude adversarial subagent (on `DISPATCH: adversarial-native`)');
|
||||
});
|
||||
|
||||
test('scope drift detection in /review and /ship', () => {
|
||||
|
||||
Reference in new issue
Block a user