Files
gstack/test/autoplan-answer-invalidation.test.ts
Garry Tanandcapy-ai[bot] 945ac301a9 v1.91.97.0 fix: closable unattended /autoplan runs on Capy (field wave B, items 6-13) (#3165)
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): scope keys on rendered surfaces, per-axis provenance, --no-dx and a read-only plan marker

UI scope reads the Implementation plan only and needs a rendered file path or a UI
term next to a visual noun; bare UI words stay weak evidence (sections load, no
Design phase). Scope records sources {ui,dx} (flag|marker|detected), --no-dx turns
DX off, a first-line <!-- gstack-autoplan-scope: ... --> marker is read and never
written, and the first next prints SCOPE: evidence plus a SCOPE_PIN hint. The
snapshot tool's scope output carries uiRequired from the same detector and
autoplan Step 2 uses it instead of a prose grep (field wave item 11).

Intentional pin change: test/review-checklists.test.ts Scope literal and the
source= line shape (source=ui:detected,dx:detected).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(plan-reality): a receipt per measured number, the rule text for a rule claim, dot-path receipts

Each clause stating a measured quantity (a unit, or a number after measured/costs/
takes/took) needs its own receipt; misses print unreceipted_numbers=<n> and one
NUMBER_UNRECEIPTED: <clause> line each. A line claiming a decision rule must quote
it or link its file:line (unquoted=<n>). Either miss maps to the existing verdict
(incomplete unattended, repair otherwise). RECEIPT_RE accepts dot paths and needs
a hex letter or a commit/backtick context for a sha. New pure checkPlanReality(planText)
runs the same checks over plan prose for gstack-autoplan submit (field wave item 12).
The unreceipted= text token is now pinned as well as the JSON.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): generated DRIVER.md, the compact entry for a parent driving gstack-autoplan

autoplan/DRIVER.md (from autoplan/DRIVER.md.tmpl) is what a parent agent on any host
reads instead of autoplan/SKILL.md: phase order and scope flags, the next/submit/
answer/revise/export loop with the plan-specified submit --record/--attest/
--unavailable, revise --incorporates/--withdraw and next --scaffold, the reviewer
RESULT FORMAT, the gate grammar including all as-is, the run files, the result codes
and the coordinator contract, every list rendered from code (117 lines). gen:skill-docs
emits it as a Claude asset, so normal, out-dir and dry-run inventories and the
freshness check cover it. gstack-autoplan --help and the first next print
DRIVER: <root>/autoplan/DRIVER.md, resolved through an install root's recorded
.source-path (new lib/source-root.ts) (field wave item 13).

Intentional pin change: test/gen-skill-docs-checks.test.ts asset inventory gains
autoplan/DRIVER.md.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP

gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(install): install verdict and install admission at skill start

bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.

gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check

The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.

Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* result codes: PLAN_SECTIONS_INVALID, PLAN_AMEND_PENDING, GSTACK_VERSION_CHANGED, INSTALL_VERDICT_FAILED, ARTIFACT_GATE_UNSETTLED

Troubleshooting anchors for each and the regenerated doctor components table (field wave lane B-core).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* artifact contract: changes, recommended_basis, carried decisions, attested count, settled-gate check

Findings and decisions carry the user challenge's `changes` value; decisions record recommended_basis, origin,
carried_from and chosen_text; validate refuses a complete run with an unsettled gate (ARTIFACT_GATE_UNSETTLED) and
prints attested=<n>. The gate reply grammar gains `all as-is` and resolves partial replies against prior answers
(field wave B2a/B2b).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* autoplan: run-state fields, plan-text hashing, version migration and admission modules

RunState gains gstack_version, plan_input/heading, scope_flags, plan_revision, amendments and plan_reality; voices
record receipt kind and reviewed hash. New pure modules: Implementation-plan review hash, scaffold and drift lines
(B1c); gstack_version stamp and legacy decision-id migration (B1a); install-verdict and Bun-minimum admission
with a local verdict shim until lane A's lib/install-verdict.ts lands.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

bin/gstack-autoplan-snapshot.ts exports its section scanner and review projection, and initializes only a plan with
no section headings by wrapping it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* autoplan: closable runs - submit --record/--attest/--unavailable, revise, amendment interlock, agreeing-UC defaults, admission

B1a: an older run directory prints GSTACK_VERSION_CHANGED once and migrates legacy decision ids; a newer one refuses.
B1b: submit binds hand-run records (fence, table, severity list, No findings.), attested binds need --attempt,
--attempt is optional with one open attempt, --unavailable/--failed close an outside voice native_only, and submit
caches the plan-reality report by Implementation-plan hash.
B1c: revise reviews a revised plan in the same run as one recoverable transaction (reopen on hash drift plus Eng,
add newly in-scope phases, .r<rev>/.v<k> archives, gate_rev+1 at the gate); PLAN_SECTIONS_INVALID and next --scaffold;
PLAN_CHANGED / PLAN_SOURCE_CHANGED drift hints.
B2a/B2b: user challenges carry changes; agreeing voices with the same changes on a confirmed finding recommend b with
both remedies shown (or the reconcile proposal); accepted changes print APPLY and become pending amendments that next
and export refuse past until revise --incorporates names them or --withdraw drops one; all as-is; partial replies,
DECIDED block, carry-forward and rerun demotion.
Mutating verbs pass admission (GSTACK_CAPY install verdict; unattended Bun minimum; override recorded as a deviation).
Intentional pin rewrite: test/autoplan-answer-invalidation.test.ts reopen tests now revise --incorporates before rerun.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names

--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session

capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum

docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session

The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.97.0: closable unattended autoplan runs on Capy

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): revise naming a non-pending decision refuses with AMENDMENT_UNKNOWN, not PLAN_AMEND_PENDING

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan): pin CLI refusals to the result-code registry's fix text

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gen-skill-docs): emit autoplan/DRIVER.md only when the autoplan template is in the tree (setup fixtures with a skill subset)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): run.lock is linked into place with its owner written; a fresh unreadable lock is held, not stolen

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): the install reason names the project's pin and gstack's real minimum

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci: run the main-targeted checks after retargeting #3165 to main

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-admission): the PATH-Bun refusal case needs the POSIX Capy host stub; skip it on Windows

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>
2026-10-13 15:17:39 +00:00

188 lines
13 KiB
TypeScript

/**
* `gstack-autoplan answer` (plan B2, B9): a stale, unparsed or conflicting
* reply writes nothing; a reply that leaves approval items pending records
* only the auto items and keeps the gate; `all` or `p1a` with no
* content-changing override finalizes run.json.status=complete without a new
* paid phase; a non-recommended choice on a reviewed item reopens only the
* affected phase plus Eng (Eng last), archives that phase's bound files and
* bumps the gate revision at the next gate; `p1d` reopens every phase; `p1e`
* ends incomplete. Pins decisions rows, journal events and status tokens.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
/** A gated run with a disagreement in ceo (taste item d1) and the fixture's user challenges (uc1 ceo, uc2 eng). */
function gatedRun(name: string): string {
const out = startSmallRun(path.join(TMP, name));
const disagree = JSON.stringify([
{ id: 'O1', severity: 'Low', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'minor' },
{ id: 'O2', severity: 'High', title: 'Gate reuse on tree equality alone is unsound', file: 'lib/tree-receipt.ts', line: 3 },
{ id: 'O3', severity: 'Low', title: 'Docs link the quickstart page', user_challenge: true },
]);
const ceoNext = autoplan(['next', '--out', out]);
expect(ceoNext.status).toBe(0);
review(path.join(out, 'ceo-native-prompt.md'));
review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree });
const ids = attemptsOf(out, 'ceo');
expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0);
const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'gpt-5.5', '--attempt', ids.outside]);
expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
return out;
}
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
/**
* An accepted plan change is a pending amendment (B-core interlock): `next` refuses with PLAN_AMEND_PENDING and writes
* nothing until the plan is edited and `revise --incorporates` names the decision. Returns the revise output.
*/
function incorporate(out: string, ids: string[], edit: string) {
const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8');
const blocked = autoplan(['next', '--out', out]);
expect(blocked.status).toBe(3);
expect(blocked.stderr).toMatch(new RegExp(`${ids.join(',')}.*\\(PLAN_AMEND_PENDING\\)$`, 'm'));
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before);
const plan = path.join(path.dirname(out), 'plan.md');
fs.writeFileSync(plan, SMALL_PLAN.replace('Tests cover', `${edit} Tests cover`));
const revised = autoplan(['revise', '--out', out, '--plan', plan, '--incorporates', ids.join(',')]);
expect(revised.status, revised.stderr).toBe(0);
expect(revised.stdout).toMatch(/^AMENDMENTS: pending=none$/m);
expect(journal(out).filter(j => j.event === 'amendment_incorporated').map(j => j.decision)).toEqual(expect.arrayContaining(ids));
return revised;
}
describe('answer', () => {
test('the gate lists the disagreement as an auto item and the user challenges as approval items', () => {
const out = gatedRun('shape');
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
expect(gate.gate_rev).toBe(1);
expect(gate.items.map((i: any) => [i.id, i.kind, i.phase ?? null])).toEqual([['d1', 'auto', 'ceo'], ['uc1', 'approval', 'ceo'], ['uc2', 'approval', 'eng'], ['p1', 'approval', null]]);
expect(gate.items[0].why).toMatch(/voices disagree: severity: native High, outside Low/);
});
test('stale, unparsed and conflicting replies write nothing; a reply that leaves approval items pending keeps the gate', () => {
const out = gatedRun('stale');
const before = { run: fs.readFileSync(path.join(out, 'run.json'), 'utf8'), decisions: fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'), journal: journal(out).length };
const stale = autoplan(['answer', '--out', out, '--gate-rev', '7', '--reply', 'all']);
expect(stale.status).toBe(3);
expect(stale.stderr).toMatch(/\(GATE_REV_STALE\)$/m);
expect(stale.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
const unparsed = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1b approve it']);
expect(unparsed.status).toBe(1);
expect(unparsed.stderr).toMatch(/unparsed: approve, it.*\(GATE_REPLY_UNPARSED\)$/m);
const conflicting = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1a d1b p1a']);
expect(conflicting.status).toBe(1);
expect(conflicting.stderr).toMatch(/conflicts with d1a.*\(GATE_REPLY_UNPARSED\)$/m);
const missingRev = autoplan(['answer', '--out', out, '--reply', 'all']);
expect(missingRev.status).toBe(3);
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before.run);
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before.decisions);
expect(journal(out).length).toBe(before.journal);
// partial: only the auto item answered; approval items stay pending; nothing reviewed changes
const partial = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1a', '--json']);
expect(partial.status, partial.stderr).toBe(0);
expect(JSON.parse(partial.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending', pending: ['uc1', 'uc2', 'p1'] });
const rows = decisions(out);
expect(rows.find((r: any) => r.label === 'D1')).toMatchObject({ status: 'approved', chosen: 'a' });
expect(rows.filter((r: any) => r.status === 'pending').map((r: any) => r.label)).toEqual(['UC1', 'UC2', 'P1']);
});
test('`all` finalizes without another paid phase: status complete, decisions approved, no phase reopened', () => {
const out = gatedRun('approve');
const counter = readRun(out).attempt_counter;
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m);
expect(r.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
const run = readRun(out);
expect(run.status).toBe('complete');
expect(run.attempt_counter).toBe(counter);
expect(decisions(out).every((d: any) => d.status === 'approved' && d.reply === 'all')).toBe(true);
expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]);
const again = autoplan(['next', '--out', out]);
expect(again.stdout).toContain('status=complete');
expect(readRun(out).attempt_counter).toBe(counter);
});
test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => {
const out = gatedRun('reopen');
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
expect(r.status, r.stderr).toBe(0);
const runId = readRun(out).run;
expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] });
const run = readRun(out);
expect(run.phases.ceo).toEqual({});
expect(run.phases.eng).toEqual({});
expect(run.phases.design.skipped).toBeTruthy();
expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]);
for (const f of ['ceo-native.r1.md', 'ceo-outside.r1.md', 'ceo-consensus.r1.json', 'eng-native.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true);
for (const f of ['ceo-native.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false);
expect(Object.keys(run.artifacts)).not.toContain('ceo-native.md');
const d1 = decisions(out).find((d: any) => d.label === 'D1');
expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 });
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
// the accepted change is applied to the plan and named before any phase reruns; the reopened phases stay reopened once
incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.');
expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']);
// the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones
const ceo = runPhase(out, 'ceo');
expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m);
expect(ceo.outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
expect(readRun(out).gate_rev).toBe(2);
const rows = decisions(out);
expect(rows.filter((x: any) => x.gate_rev === 1)).toHaveLength(4);
expect(rows.filter((x: any) => x.gate_rev === 2 && x.status === 'pending').length).toBeGreaterThan(0);
const staleAgain = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all']);
expect(staleAgain.status).toBe(3);
// closing gate_rev 2 exports a run that validates: rev-2 rows get their own ids beside rev 1's
const approve = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all']);
expect(approve.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
expect(autoplan(['export', '--out', out]).status).toBe(0);
const ids = decisions(out).map((x: any) => x.id);
expect(new Set(ids).size).toBe(ids.length);
expect(decisions(out).filter((x: any) => x.gate_rev === 2).every((x: any) => x.id.startsWith(`${readRun(out).run}-r2-`))).toBe(true);
expect(decisions(out).find((x: any) => x.gate_rev === 1 && x.label === 'D1')!.id).toBe(`${readRun(out).run}-d1`);
const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
expect(tasks.length).toBeGreaterThan(0);
for (const t of tasks) expect(t.blocked_by).toEqual([`${readRun(out).run}-r2-p1`]);
const valid = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
expect(valid.stdout + valid.stderr).toContain('ARTIFACT_VALID: run=');
expect(valid.status).toBe(0);
});
test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
const out = gatedRun('eng-only');
const engOnly = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
const runId = readRun(out).run;
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] });
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
// the revised plan changes the hash ceo reviewed, so revise (not the answer) reruns ceo on v2 before eng
const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.');
expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo$/m);
expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['eng', expect.anything()], ['ceo', expect.stringContaining('revise')]]);
expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
const eng = runPhase(out, 'eng');
expect(eng.outside.stdout).toContain('status=gate_pending');
const interrogate = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all except p1c', '--json']);
expect(JSON.parse(interrogate.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending' });
const revise = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all except p1d', '--json']);
expect(JSON.parse(revise.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] });
runPhase(out, 'ceo');
runPhase(out, 'eng');
const reject = autoplan(['answer', '--out', out, '--gate-rev', '3', '--reply', 'all except p1e']);
expect(reject.status).toBe(0);
expect(reject.stdout).toContain('GSTACK_RESULT: skill=autoplan status=incomplete run=' + out);
expect(readRun(out).deviations).toContain('plan rejected by the owner at gate_rev 3');
});
});