Files
gstack/lib/autoplan-revise.ts
Garry Tanandcapy-ai[bot] 9d9d4c31bb v1.93.0.0 wave(browse): #3700 #3773 #3779 #3817 #3842 (#3858)
* feat(loadouts): Opus 5.5 lite loadout with explicit model detection (GSTA-161)

modules/loadouts.json gains the lite loadout (eleven coaching preamble
pieces dropped; upgrade-check and brain-sync kept) and a models map that
sends only claude-opus-5-5 (and its dated snapshots) to it. Detection is
explicit: Capy passes --model to the installer, Claude Code opts in with
./setup --loadout lite; anything unmapped, invalid or unreadable serves full.
The checkout always stays the full render.

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

* docs(loadouts): Opus 5.5 lite, detection order, Capy SKILL_ROOT, setup --loadout; troubleshooting anchors; changelog fragment (GSTA-161)

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

* fix(loadouts): claude_loadout read through CONFIG_BIN; capy skill context ceiling for the --model line (GSTA-161)

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

* fix: pre-landing review fixes (prune keeps a reused loadout folder, GSTACK_LOADOUT_SKILL_ROOT, claude_loadout guard test) (GSTA-161)

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

* fix: pre-landing review fixes (Capy skill passes the model as GSTACK_MODEL so an older installer ignores it) (GSTA-161)

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

* fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)

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

* docs: loadout source example, install and resolve commands, Capy launch-file note (GSTA-161)

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

* feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537)

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

* fix: pre-landing review fixes

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

* test: classify autoplan-ceo-only in the Codex host metadata

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

* fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code

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

* test: run autoplan-ceo-only in the marathon lane

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

* docs: changelog entry for /autoplan --ceo-only

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

* test: count the autoplan-ceo-only paid file in the shard inventory

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

* feat: gstack-autoplan next --ceo-only runs the CEO review alone and ends reviewed (GST-537)

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

* fix: budget and close recover a budget-stopped CEO-only run; reviewed is at rest for checkpoints

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

* fix: CEO-only finish leaves owner-only cap items undecided, refuses marker phase flags and a plan at the report path, and replaces ceo-review.md atomically

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

* fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan

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

* docs: CEO_ONLY_CONFLICT covers a plan linked to the report path

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

* test(browse): failing-first device and touch tests (GSTA-227 F3, #3058)

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

* fix: the report-path guard keeps the lexical check, skips safe hard links and compares bigint file ids

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

* browse: per-session tabs for parallel agents; tab pins stop moving the shared active tab (#1961)

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

* browse: keep the server.ts tab-pin anchor comment for source guards

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

* feat(browse): per-tab phone emulation and touch input ($B device, tap, swipe, pinch, touch; #3058, GSTA-227 F3)

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

* test: copy lib/result-codes-autoplan.ts into the fixtures that copy the result-code tables, and keep it out of derived closures

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

* docs(qa, aside): touch pointer in /qa, Aside touch recipe verified on Aside 1.26, report Phones row (GSTA-227 F3, #3058)

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

* browse: drop the sessions paragraph from /browse to stay inside the parity cap

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

* changelog: F3 phone emulation and touch entry

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

* browse: hide the ps window in the session harness walk

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

* docs: the decisions.jsonl status list names auto_kept

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

* test(browse): session cap, LRU eviction, headed release and scoped auto-session rows

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

* browse: sessions keep tab <id> picks, sidebar claude stays on the user's tab, pinned rebuild and state load rebind the scope

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

* fix: pre-landing review fixes (touch cancel with no finger held is a no-op)

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

* fix: Step 11 review fixes (headed tab adoption, pinned viewport reset, CLI retry keeps --session, rebuild id remap, --json notice, evicted-session reset, device kept after annotated captures, render opts out of sessions, session: client ids reserved)

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

* fix: headed viewport auto keeps the user's active tab; render's closetab opts out of sessions

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

* docs: sessions, phone emulation and touch in BROWSER.md; session: client ids reserved; TODOS #1961/#3058 shipped

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

* test: copy lib/result-codes-browse.ts into the fixtures that copy the result-code modules

lib/result-codes.ts imports ./result-codes-browse, so fixtures with an explicit
lib list failed with 'Cannot find module'. pr-dependencies lists it with the other
reason-code modules as selection data. GSTA-227

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

* feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627)

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

* browse: name the Aside touch recipe's coordinates <tap-x>/<tap-y> with a numeric grammar

The generated-bash lint (INV-3) rejected the free-text <x>/<y> placeholders. GSTA-227

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

* fix(clean): leave hard-linked and unreadable logs alone; cut through a no-follow descriptor checked against the evaluated inode

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

* test(clean): tests that expect tmp removal see a quiet ps, so a codex elsewhere on the host cannot flake them

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

* fix(clean): an unlistable tmp/ skips its group; liveness asked once per group on apply; BROWSER.md says which logs reset at daemon start

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

* fix(clean): write output synchronously so --json reaches a pipe whole; test fixes for root, wrapper git and the ps stub

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

* fix(clean): every change rechecks the verified folder and the measured file; full writes; EPIPE ends output quietly

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

* test(clean): the early-close test checks the bin's own exit status

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

* docs: list gstack-clean among the standalone CLIs

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

* test(clean): copied-install fixtures and the dependency list carry lib/result-codes-clean.ts

The remote pregate failed 11 cases in gstack-doctor, plan-tune-cathedral and
timeline-stop-hook-slug: each copies an explicit list of lib/result-codes-*.ts
files into a temp install, and lib/result-codes.ts now imports
./result-codes-clean. Same lists GSTA-168 extended for result-codes-eval.

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

* chore: regenerate gstack-doctor-components.sh for the gstack-clean result codes

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

* qa: fold the touch pointer into the Phases sentence to fit the parity size cap after the main merge

qa's union was 95988 bytes (ratio 1.142 > maxSizeRatio 1.142, train 75). Now 95975 (1.1419). GSTA-227

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.10.0 after the main merge

GSTA-227

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

* test(clean): classify gstack-tasks rows files (tasks-*) as kept, after main added the producer

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.20.0 after the main merge

GSTA-227

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

* fix: pre-landing review fixes (restore the lib/result-codes-plan.ts ratchet row the base merge dropped)

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

* test(clean): the producer scan ends a name at a colon, so prose after a path names the file (train 78)

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.30.0 after the main merge

GSTA-227

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

* outside-voice-api: caller receipts, stop_reason, OpenAI max_completion_tokens

A caller can write its own egress receipt (sink, consent, payload class);
the default stays the outside-voice receipt, so existing callers send and
record the same bytes. The result carries the provider's stop reason, and
an OpenAI request gets max_completion_tokens only when the caller sets an
output limit (GST-537 PR 3).

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

* autoplan gate: item classes and the owner's answer provenance

Gate items carry item_class, ruleKept() names the keep-direction pick, and
answered decision rows record owner_mode, owner_named, rec_hash and
answered_role parsed from the owner's own words (GST-537 PR 3).

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

* autoplan: shadow-mode decider module, frozen prompt and spend accounting

The switch (off by default, fixed at init), the lease, one model call per
gate with a page retry and a truncation retry under one deadline, the
once-per-revision record with its journaled sha256, the 10% audit mark,
and shadow-labeled ledger rows that count against the cap, show as
shadow= on GATE_SPEND and stay out of the per-voice price (GST-537 PR 3).

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

* gstack-autoplan: shadow every published gate, the shadow export, config and docs

Every driver command that prints a gate records the shadow first and
checkpoints after it; answer waits for a live lease and records a dead
one retrospectively; gstack-autoplan shadow exports answered revisions
for taste-replay R2 (SHADOW_BEFORE_ANSWER otherwise); gstack-config
autoplan_shadow / autoplan_shadow_model; gstack-artifact validate checks
the record's hash (GST-537 PR 3).

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

* changelog: shadow mode for the CEO-only decider (GST-537 PR 3)

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

* autoplan shadow: /ship review fixes

- register test/fixtures/shadow-fake-model.ts in FREE_FIXTURES (free-fixtures inventory failed)
- withRunLock: count dead-owner reclaims apart from waitMs iterations, so a long wait can still reclaim
- buildPage: fill the decider template in one pass, so slot names inside the plan stay plan text
- revise regate: the shared publish step prints the GATE and ESCALATE lines once

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

* autoplan shadow: reply files never follow a link; the retry reserves against the current cap

- a model reply is written by temp file and rename, so a link at the reply path is replaced, not written through
- each shadow reservation reads the run's current spend cap, so a budget change during the call is not undone

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

* outside-voice-api: write the egress receipt under the caller's env

callModel resolved the receipt's state root from process.env even when the caller passed env, so the shadow
tests wrote to the real ~/.gstack (the free-suite home-containment guard caught it). The receipt now uses
req.env; the shadow tests give the fake model a private HOME, GSTACK_HOME and GSTACK_STATE_ROOT, and the receipt
test no longer sets process.env to pass.

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

* fix(autoplan): CEO-only runs leave main's open Critical/High finding items to the owner (no decision row)

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

* fix(autoplan): shadow pages carry main's finding items; a finding item is never ceo_eligible

After merging #3767 (main 90a170c), open Critical/High findings are gate items
(item_class finding). The shadow records one row per gate item, so its pages now
include them. A CEO-only run leaves those items to the owner, so their rows are
ceo_eligible false (rule_pick was already null). The shadow fixtures follow
main's gate (items, counts, "Do it" tokens); the two merge adapters follow
main's doNext(out, rest, price) and its per-voice reservation (--estimate-usd).

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.61.0 after the main merge

GSTA-227

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

* fix(investigate): drop the five-phase step procedure (GST-938)

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

* changelog: GST-938 fragment wording

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

* fix(investigate): keep the web-search sanitize guard; update BROWSER.md and docs/skills.md (review pass 1)

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

* changelog: GST-938 wording

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

* test(investigate): pin the web-search guard; changelog size (adversarial review)

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

* test(clean): classify restamp-staging.json, the restamp producer main gained after #3779's base (wave browse)

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

* fix: pre-landing review fixes (wave browse): scoped newtab keeps a concurrent tab switch; shadow validate catches a deleted record; a failed shadow publish is journaled

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

* fix: a failed shadow publish journals under the run lock and never throws (wave browse review pass 2)

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

* docs(unattended): shadow publish failures and the missing-record validate check (wave browse docs audit)

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

* changelog: wave browse integrator fixes fragment

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

* fix: shadow reader dedupes by run folder, so two runs started in the same second both count (wave browse CI free-suite 16)

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

* chore: regenerate doctor result anchors in generator order (wave browse rebuild without #3818)

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

* changelog: name the wave fragment after the rebuilt wave

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-11-02 14:49:46 +00:00

256 lines
19 KiB
TypeScript

/**
* autoplan-revise — `gstack-autoplan revise` (plan B1c with the B-core
* amendments): the run reviews a revised plan in the same run directory.
* Drift is the Implementation-plan hash (the snapshot sha reviewers bound
* to), not file bytes: every opened phase whose reviewed hash differs from
* the revision is reopened (open attempts superseded), plus Eng, whose prompt
* carries the earlier phases' consensus; phases the revised plan newly puts in
* scope are added (never removed). Accepted gate changes are discharged only
* when named: `--incorporates <ids>` binds each id to the old and new hashes
* and refuses an unchanged plan; `--withdraw <id>` is the owner's journaled
* decision to drop one. Archives keep `.r<rev>` names with `.v<k>` only on
* collision. An edit that only incorporates decisions whose source findings
* are all below High (no user challenge, no newly in-scope phase) applies
* without re-review: the touched phases' reviewed hash is rebound to the
* revision and `applied_without_rereview` is journaled; anything unsure
* reopens. One recoverable transaction: the staged plan and state are
* written and `revise_intent` journaled before anything moves; `resume`
* (recoverRevise) completes an interrupted revise from the stage.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
import { parseScopeTokens } from './autoplan-scope';
import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts';
import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceRows, writeState, type RunState } from './autoplan-state';
export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: string[]; cwd: string }
export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] }
const amendmentLine = (state: RunState) => `AMENDMENTS: pending=${pendingAmendments(state).map(a => a.decision).join(',') || 'none'}`;
/** The revised plan's text as the run reviews it (cut to the run's heading; scaffolded when the run was). */
function revisionText(state: RunState, file: string): string {
if (!fs.existsSync(file)) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: not found`, 3);
let text = fs.readFileSync(file, 'utf8');
if (state.plan_heading) {
const section = extractHeading(text, state.plan_heading);
if (section === undefined) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: heading ${JSON.stringify(state.plan_heading)} not found`, 3);
text = section;
}
const diag = scaffold(text);
if (diag.problem && !(state.scaffolded && diag.text)) throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${diag.problem}`, 3);
return diag.text ?? text;
}
function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn' | 'settled', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> {
return ids.map(id => {
const a = pendingAmendments(state).find(x => x.decision === id)!;
a.status = status; a.settled_at = at;
Object.assign(a, extra);
return { event: `amendment_${status}`, decision: id, ...extra };
});
}
/** A `--settle`/`--incorporates`/`--withdraw` id: the decision id, or the short item id (`d4`) at the current gate_rev. */
function resolveIds(state: RunState, ids: string[]): string[] {
return ids.map(id => pendingAmendments(state).find(a => a.decision === id || (a.item === id && a.gate_rev === state.gate_rev))?.decision ?? id);
}
/**
* `--settle` evidence: a packaging/ordering change settles on the plan the run already reviews, never on an
* unrevised edit. Alone, the owner's plan file must hash to the active plan, and the active plan must be the one
* the owner accepted the change on or one a later `revise` recorded (plan_revised). With `--plan` (beside
* `--incorporates`), it settles on that revision's hash, which the reopened phases then review.
*/
function settleEvidence(state: RunState, ids: string[], revised?: string): { plan_sha256: string; changes: Record<string, string> } {
const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
const changes: Record<string, string> = {};
for (const id of ids) {
const a = pendingAmendments(state).find(x => x.decision === id)!;
if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3);
changes[id] = a.changes;
if (revised || !a.plan_sha256) continue;
const recorded = [a.plan_sha256, ...readJournal(state.out).filter(r => r.event === 'plan_revised' && r.ts >= a.at).map(r => r.new_plan_sha256 as string)];
if (!recorded.includes(active)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the active plan ${active.slice(0, 12)} is neither the plan ${id} was accepted on (${a.plan_sha256.slice(0, 12)}) nor one revise recorded; revise --plan instead`, 3);
}
if (revised) return { plan_sha256: revised, changes };
let input = '';
try { input = state.plan_input && fs.existsSync(state.plan_input) ? reviewHash(revisionText(state, state.plan_input)) : active; } catch { /* unreadable: refused below */ }
if (input !== active) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the plan file has edits the run has not reviewed (active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan <file> --incorporates <ids> --settle ${ids.join(',')} instead`, 3);
return { plan_sha256: active, changes };
}
const SEVERITY_ORDER = ['Informational', 'Low', 'Medium', 'High', 'Critical'];
export const BELOW_HIGH = ['Informational', 'Low', 'Medium'];
/**
* The severity an incorporated decision resolves to: the highest of the findings its gate item named (the
* decisions.jsonl origin's ids, looked up in that phase's bound rows). `unknown` when the row, its origin or a
* finding is missing; `challenge` for a user challenge, whose direction change is always re-reviewed.
*/
export function decisionSeverity(state: RunState, decision: string): string {
const row = readDecisions(state.out).find(d => d.id === decision);
const [phase, ids] = row?.origin?.split('|') ?? [];
if (!phase || !ids || !state.phases[phase]?.closed_at) return 'unknown';
const rows = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside')];
const found = ids.split('+').filter(id => id && id !== 'null').map(id => rows.find(r => r.id === id));
if (!found.length || found.some(f => !f)) return 'unknown';
if (found.some(f => (f as { user_challenge?: boolean }).user_challenge)) return 'challenge';
return found.map(f => f!.severity).reduce((a, b) => (SEVERITY_ORDER.indexOf(b) > SEVERITY_ORDER.indexOf(a) ? b : a));
}
/** Every accepted change is in: approve, unless a plan delta after a round cap still needs the owner (a new gate revision). */
function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
const carried = carryLines(state);
return carried;
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
/** Every accepted change is discharged: the answered gate stands (complete) unless a revise reopened phases still to rerun. */
function finishAmendments(state: RunState, at: string): string[] {
if (state.status !== 'amend_pending' || pendingAmendments(state).length) return [];
journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null });
if (state.phase_order.some(p => !state.phases[p]?.closed_at)) { state.status = 'running'; return []; }
return approveOrRegate(state, at);
}
export function reviseRun(o: ReviseOptions): ReviseResult {
const out = path.resolve(o.out);
const state = readState(out);
if (['complete', 'incomplete', 'refused'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; revise works on a running or gated run`, 3);
const amending = state.status === 'amend_pending';
const pending = pendingAmendments(state).map(a => a.decision);
const incorporates = resolveIds(state, o.incorporates ?? []), withdraw = resolveIds(state, o.withdraw ?? []), settle = resolveIds(state, o.settle ?? []);
for (const id of [...incorporates, ...withdraw, ...settle]) {
if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3);
}
const at = now();
if (!o.plan) {
const evidence = settle.length ? settleEvidence(state, settle) : undefined;
for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row);
for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] });
const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)];
writeState(state);
return { state, reopened: [], lines };
}
const file = path.resolve(o.plan);
const text = revisionText(state, file);
let newHash: string;
try { newHash = reviewHash(text); } catch (e: any) { throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${e.message}`, 3); }
const oldHash = reviewHash(fs.readFileSync(state.active_plan, 'utf8'));
if (incorporates.length && newHash === oldHash) {
throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3);
}
if (settle.length && newHash !== oldHash && !incorporates.length) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}) and incorporates nothing; incorporate ${settle.join(', ')} instead of settling`, 3);
const evidence = settle.length ? settleEvidence(state, settle, newHash) : undefined;
const k = (state.plan_revision ?? 0) + 1;
const staging = path.join(out, `.revise-v${k}`);
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true });
fs.writeFileSync(path.join(staging, FILES.active), normalizeRevision(text, state.restore));
fs.writeFileSync(path.join(staging, FILES.source), text);
const flags = (state.scope_flags ?? (state.scope.cli_flags ? parseScopeTokens(state.scope.cli_flags, 'run.json') : { ui: state.scope.source === 'flags' ? state.scope.ui : undefined, developerTool: state.scope.developer_tool, agentPrimary: state.scope.agent_primary })) as ScopeFlags;
const detected = detectScope(path.join(staging, FILES.active), o.cwd, flags, file);
const order = PHASES.filter(p => state.phase_order.includes(p) || phaseOrder(detected).includes(p));
const added = order.filter(p => !state.phase_order.includes(p));
const opened = state.phase_order.filter(p => state.phases[p]?.snapshot);
const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash);
const touched = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0));
// Below-High decisions only, every touched phase closed, nothing newly in scope (no added phase, docs-only still holds): the edit is theirs, so no re-review.
const severities = incorporates.map(id => [id, decisionSeverity(state, id)] as const);
const quiet = severities.length > 0 && severities.every(([, sev]) => BELOW_HIGH.includes(sev)) && touched.length > 0 && !added.length
&& touched.every(p => state.phases[p]?.closed_at) && !(state.scope.docs_only && !detected.docs_only);
// A phase that used its last round is not reopened: the delta becomes an owner gate item, so the cap neither loops nor hides it.
const capped = quiet ? [] : touched.filter(p => atCap(state, p));
const reopen = quiet ? [] : touched.filter(p => !capped.includes(p));
const delta = capped.length || quiet ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 };
const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined;
journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle });
voidPendingCheck(state, 'the plan was revised');
const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : [];
for (const phase of capped) {
(state.capped_deltas ??= []).push({ k, phase, round: roundOf(state, phase), cap: roundCap(state, phase)!, old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta, status: 'pending', at });
journal(out, { event: 'cap_delta', k, phase, round: roundOf(state, phase), cap: roundCap(state, phase), old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta });
}
for (const p of quiet ? touched : []) {
const ps = state.phases[p]!;
(ps.rebound ??= []).push({ k, from: ps.snapshot!, to: newHash, decisions: incorporates, at });
ps.snapshot = newHash;
}
for (const p of added) state.phases[p] = {};
state.phase_order = order; state.required_phases = order;
state.scope = { ...detected, ui: detected.ui || state.scope.ui, dx: detected.dx || state.scope.dx };
if (reopen.length || added.length) state.status = 'running';
state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k;
delete state.plan_reality;
const settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }),
...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] })),
...(quiet ? [{ event: 'applied_without_rereview', k, decisions: incorporates, severities: Object.fromEntries(severities), phases: touched, old_plan_sha256: oldHash, new_plan_sha256: newHash }] : [])];
if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete';
// A quiet revise that ends at gate_pending still has an earlier capped delta waiting: the owner gets a new gate for it.
const regate = (capped.length > 0 || quiet) && state.status === 'gate_pending';
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, source_file_sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
...(quiet ? [`APPLIED: ${incorporates.join(',')} without re-review (below High; plan +${delta.added} -${delta.removed} lines)`] : []), amendmentLine(state)];
for (const phase of capped) lines.push(`CAPPED: ${phase} used round ${roundOf(state, phase)} of ${roundCap(state, phase)}: not reopened; the gate asks the owner about the unreviewed delta (+${delta.added} -${delta.removed} lines)`);
if (regate) {
lines.push(...approveOrRegate(state, at));
writeState(state);
}
if (state.status === 'complete') {
lines.push(...closeDraftDirections(state, at.slice(0, 10)), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`);
exportRun(state);
writeState(state);
}
return { state, reopened: reopen, lines };
}
/** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */
function publish(state: RunState, staging: string, renames: Array<[string, string]>, committed: Record<string, unknown>, settled: Array<Record<string, unknown>>): void {
applyRenames(state.out, renames);
if (process.env.GSTACK_AUTOPLAN_FAULT === 'revise-after-archive') throw new Error('fault injected: revise interrupted after archiving (GSTACK_AUTOPLAN_FAULT, tests only)');
const stagedActive = path.join(staging, FILES.active);
if (fs.existsSync(stagedActive)) fs.renameSync(stagedActive, state.active_plan);
const stagedSource = path.join(staging, FILES.source);
if (fs.existsSync(stagedSource)) fs.renameSync(stagedSource, state.plan_source);
exportRun(state);
writeState(state);
journal(state.out, committed);
for (const row of settled) journal(state.out, row);
fs.rmSync(staging, { recursive: true, force: true });
}
/** `resume`: complete a revise whose intent is journaled without its commit, or record it abandoned when nothing was staged. */
export function recoverRevise(out: string): string[] {
const rows = readJournal(out);
const intent = rows.filter(r => r.event === 'revise_intent').pop();
if (!intent || rows.some(r => (r.event === 'plan_revised' || r.event === 'revise_abandoned') && r.k === intent.k)) return [];
const staging = path.resolve(out, intent.staging);
const stagedRun = path.join(staging, FILES.run);
if (!fs.existsSync(stagedRun)) {
journal(out, { event: 'revise_abandoned', k: intent.k });
fs.rmSync(staging, { recursive: true, force: true });
return [`REVISE_RECOVERED: v${intent.k} abandoned (nothing was published)`];
}
const stage = JSON.parse(fs.readFileSync(stagedRun, 'utf8')) as { state: RunState; renames: Array<[string, string]>; committed: Record<string, unknown>; settled: Array<Record<string, unknown>> };
fs.writeFileSync(path.join(out, FILES.run), JSON.stringify(stage.state, null, 2) + '\n');
const state = readState(out);
publish(state, staging, stage.renames, { ...stage.committed, recovered: true }, stage.settled);
return [`REVISE_RECOVERED: v${intent.k} completed (reopened=${(stage.committed.reopened as string[]).join(',') || 'none'})`];
}