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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 22:12:04 +00:00
1 parent e56602051a
commit 483d84c5bd
17 files changed
+383 -41

No files matched your search

+2 -2
View File
@@ -57,8 +57,8 @@ line yourself; gstack never writes the plan.
### Commands
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds.
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold] [--ceo-only]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `<dir>/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`).
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
later calls: open the next phase or reprint open attempts; never redispatches on its own.
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
+16 -7
View File
@@ -2,7 +2,7 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
@@ -31,10 +31,11 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { amendmentSteps, answerGate, closeDeferred, recommendGate } from '../lib/autoplan-gate';
import { checkCeoOnlyFlags, finishRun, refuseCeoOnly } from '../lib/autoplan-ceo-only';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry';
import { DEFAULT_CARRY_CHECK_CAP_USD, submitCarryCheck } from '../lib/autoplan-carry';
import { ANSWERED_BY_RE } from '../lib/gate-list';
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
@@ -55,10 +56,11 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
--max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
--ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with <out>/ceo-review.md, never approved
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
@@ -182,16 +184,18 @@ function doNext(out: string, rest: string[], json: boolean): number {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
if (!fresh) checkCeoOnlyFlags(rest, undefined, readState(out));
if (fresh) {
const plan = flag(rest, '--plan');
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan <file> on the first next`, 3);
const ceoOnly = checkCeoOnlyFlags(rest, path.resolve(plan), undefined);
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
const maxRounds = num(rest, '--max-rounds');
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
const carryCheckCapUsd = num(rest, '--carry-check-cap');
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
@@ -248,6 +252,7 @@ function doRevise(out: string, rest: string[], json: boolean): number {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
refuseCeoOnly(readState(out), 'revise');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
@@ -261,6 +266,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
if (!phase || !reason?.trim()) return usage('close needs --phase <p> --deferred "<reason>" --by <human|agent:<id>>');
if (by === undefined || !ANSWERED_BY_RE.test(by)) throw new RunError('ANSWER_BY_REQUIRED', `close records who deferred the phase: pass --by human or --by agent:<id> (got ${JSON.stringify(by ?? null)})`, 3);
const state = readState(out);
refuseCeoOnly(state, 'close');
const ps = state.phases[phase];
if (!state.phase_order.includes(phase as Phase) || ps?.closed_at || ps?.skipped) throw new RunError('PHASE_NOT_AWAITING', `${phase} is ${ps?.skipped ? 'skipped' : ps?.closed_at ? 'already closed' : 'not a phase of this run'}`, 3);
const stopped = state.status === 'incomplete' && !!state.stop;
@@ -268,7 +274,7 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
if (!currentPhase(state)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : []));
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
@@ -291,6 +297,7 @@ function doCheck(out: string, rest: string[], json: boolean): number {
function doBudget(out: string, rest: string[], json: boolean): number {
const state = readState(out);
refuseCeoOnly(state, 'budget');
const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') });
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, spend_cap_usd: state.spend_cap_usd, max_rounds: state.max_rounds ?? null, out });
@@ -383,12 +390,13 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'answer') {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
if (reply === undefined) return usage('answer needs --reply "<text>"');
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
if (rev === undefined) { const s = readState(out); refuseCeoOnly(s, 'answer'); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'answer');
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
writeState(state);
const lines = [...r.lines];
@@ -403,6 +411,7 @@ async function main(argv: string[]): Promise<number> {
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
return mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'recommend');
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
+3
View File
@@ -354,6 +354,9 @@ gstack_result_anchor() {
PREREG_DIAGNOSTIC_UNTESTED) printf '%s' 'prereg-diagnostic-untested' ;;
PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;;
EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;;
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
*) return 1 ;;
esac
}
+9
View File
@@ -0,0 +1,9 @@
**A parent agent can now run the CEO-only review through the driver: `gstack-autoplan next --ceo-only` (GST-537).** `/autoplan --ceo-only` only worked inside a Claude Code session, so a crew or eval harness driving `gstack-autoplan` from another host had to run the whole review and stop at the approval gate. Now the same review-only run works from the driver on any host.
### Added
- **`gstack-autoplan next --ceo-only`** (first `next` only): opens the CEO phase alone. When it closes, the run prints `REVIEWED:`, writes `<dir>/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=<dir>`. It writes no gate, never edits the plan and never approves anything.
- **Decision rows with status `auto_kept`**: each item a gate would have asked is recorded as kind `auto`, the plan's direction kept, with no answer time. Tasks in a CEO-only run wait on no decision. `run.json` now accepts `reviewed`, and `gstack-artifact validate` passes a finished CEO-only run.
- **Coded refusals**: `--ceo-only` beside `--ui`, `--developer-tool` or `--agent-primary`, or inside a plan's scope marker, refuses with `CEO_ONLY_CONFLICT`; on a directory that holds a full run, `CEO_ONLY_AFTER_INIT`. `answer`, `recommend`, `revise`, `close`, `budget` and `gstack-approvals import` refuse a CEO-only run with `CEO_ONLY_NO_GATE`.
### Fixed
- `gstack-approvals import --autoplan` no longer imports from a run whose `run.json` is present but not valid JSON; it refuses with `ARTIFACT_INVALID_JSON` instead of guessing the run's identity.
+33
View File
@@ -3102,6 +3102,39 @@ result.
**Fix.** Run `gstack-autoplan status --out <dir>` and submit for the attempt
the last `next` printed (`ATTEMPT: ... phase=<p> voice=<v>`).
<a id="ceo-only-conflict"></a>
### `CEO_ONLY_CONFLICT`
**Meaning.** `gstack-autoplan next --ceo-only` was combined with `--ui`,
`--developer-tool` or `--agent-primary`, each of which adds a phase a
CEO-only run never opens, or the plan's `gstack-autoplan-scope` marker names
`--ceo-only`. The mode is a command-line choice for one run, so a plan can't
pin it.
**Fix.** Drop the conflicting flag (or the marker token), or run the full
review without `--ceo-only`.
<a id="ceo-only-after-init"></a>
### `CEO_ONLY_AFTER_INIT`
**Meaning.** `next --ceo-only` named a run directory that already holds a full
review run. A run keeps the mode it started with.
**Fix.** Start the CEO-only run in a new `--out` directory, passing
`--ceo-only` on its first `next`.
<a id="ceo-only-no-gate"></a>
### `CEO_ONLY_NO_GATE`
**Meaning.** `answer`, `recommend`, `revise`, `close` or `budget` was run on
a CEO-only run, or `gstack-approvals import` was pointed at one. A CEO-only
run writes no gate, ends `status=reviewed` and is never approved, so there is
nothing to answer, change or import.
**Fix.** Read `<run dir>/ceo-review.md`. For a gate and an approval, start a
full review in a new directory: `gstack-autoplan next --out <new dir> --plan
<file>` without `--ceo-only`.
<a id="attempt-mismatch"></a>
### `ATTEMPT_MISMATCH`
+24 -6
View File
@@ -560,12 +560,12 @@ without being shown to the owner.
## CEO-only runs
`/autoplan --ceo-only` runs the CEO review by itself and asks nothing. It runs
in the session that invokes the skill; `gstack-autoplan`, the driver a parent
agent uses, has no CEO-only mode yet, and `run.json` does not accept the
`reviewed` status. It needs Claude Code: other hosts inline autoplan's sections
and do not carry this one yet. It is for a harness or a crew that wants the strategy pass
without the full gauntlet, such as an eval comparing gstack's CEO review with a
plain prompt.
in the session that invokes the skill and needs Claude Code: other hosts inline
autoplan's sections and do not carry this one yet. A parent agent on any host
gets the same run from the driver with `gstack-autoplan next --ceo-only` (see
below). It is for a harness or a crew that wants the strategy pass without the
full gauntlet, such as an eval comparing gstack's CEO review with a plain
prompt.
- **What runs:** Phase 0, the outside-reviewer preflight and the CEO phase.
Design, DX, Eng and the final gate do not run.
@@ -591,6 +591,24 @@ plain prompt.
`test/skill-e2e-autoplan-ceo-only.test.ts` runs it on a plan that makes the
interactive CEO review ask a question and fails if this mode asks anything.
**From the driver.** `gstack-autoplan next --out <dir> --plan <file>
--ceo-only` opens only the CEO phase; the parent dispatches and submits its
voices as usual. When CEO closes, the run prints `REVIEWED:` and ends
`GSTACK_RESULT: skill=autoplan status=reviewed run=<dir>`, with
`<dir>/ceo-review.md` in the same sections, hashed in `run.json`. Each item a
gate would have asked becomes a `decisions.jsonl` row of kind `auto` and status
`auto_kept` (the plan's direction kept, nobody answered), and tasks are blocked
on no decision. The driver never edits the plan, writes no `gate.json` and never
approves, so `gstack-artifact validate` accepts the run as it stands.
- The flag goes on the first `next` only. It refuses beside `--ui`,
`--developer-tool` or `--agent-primary`, and in a plan's scope marker
(`CEO_ONLY_CONFLICT`); a directory that holds a full run refuses it
(`CEO_ONLY_AFTER_INIT`).
- `answer`, `recommend`, `revise`, `close` and `budget` refuse a CEO-only run,
and so does `gstack-approvals import` (`CEO_ONLY_NO_GATE`): there is no gate to
answer and nothing was approved.
## Owner brief
`gstack-owner-brief render --out <dir>` is what the owner sees at the final
+6 -1
View File
@@ -254,7 +254,12 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
if (!line.trim()) continue;
try { rows.push(JSON.parse(line)); } catch { return { code: 'ARTIFACT_MALFORMED_JSONL', message: `decisions.jsonl has a line that is not JSON` }; }
}
const run = (() => { try { return JSON.parse(fs.readFileSync(path.join(runDir, 'run.json'), 'utf8')); } catch { return {}; } })();
const runFile = path.join(runDir, 'run.json');
let run: Record<string, any> = {};
if (fs.existsSync(runFile)) {
try { run = JSON.parse(fs.readFileSync(runFile, 'utf8')); } catch (e: any) { return { code: 'ARTIFACT_INVALID_JSON', message: `run.json in ${runDir} is unreadable (${e?.message ?? e}); an approval names the run it came from` }; }
}
if (run.ceo_only || run.status === 'reviewed') return { code: 'CEO_ONLY_NO_GATE', message: `${runDir} is a CEO-only run (status ${run.status}): it has no gate and was never approved` };
const plan = reviewedPlan(runDir, run);
const planTitle = firstTitle(plan.text) ?? 'the plan';
const planSha = plan.sha;
+90
View File
@@ -0,0 +1,90 @@
/**
* autoplan-ceo-only — the driver's CEO-only run (`gstack-autoplan next
* --ceo-only`, GST-537), the twin of `/autoplan --ceo-only` (the skill's
* CEO-only section, docs/unattended.md#ceo-only-runs). The flag is a command-line choice for one run:
* it conflicts with the flags that add a phase, a plan's scope marker may not
* name it, and a run directory keeps the mode it started with. The run opens
* only the CEO phase. `finishRun` is the one finish path next, phase close,
* resume and close share: a full run writes the gate (`finishGate`); a
* CEO-only run writes `ceo-review.md`, records each would-be gate item as a
* decision row of kind `auto` and status `auto_kept` (the plan's direction
* kept, nobody answered), and ends `reviewed`. It never writes gate.json,
* never calls approve() and never edits the plan, so the commands that need a
* gate or an approvable run refuse it (CEO_ONLY_NO_GATE).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { buildGate, finishGate } from './autoplan-gate';
import { carryLines } from './autoplan-carry';
import { aggregateFindings, exportRun } from './autoplan-export';
import { decisionRows, keepDirection, type GateItem } from './gate-list';
import { RunError, journal, voiceRows, writeDecisions, type RunState } from './autoplan-state';
import type { DecisionRow, FindingRow } from './headless-artifacts';
import { sha256File } from './headless-artifacts';
export const CEO_ONLY_FLAG = '--ceo-only';
export const CEO_ONLY_CONFLICTS = ['--ui', '--developer-tool', '--agent-primary'] as const;
export const CEO_ONLY_REPORT = 'ceo-review.md';
const MARKER_RE = /^<!--\s*gstack-autoplan-scope:(.*?)-->\s*$/;
/**
* The first next's checks: true when this next asks for a CEO-only run. A marker naming the flag refuses whether or not
* the flag was passed; the flag refuses beside a phase-adding flag or on a directory that holds a full run.
*/
export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefined, existing: RunState | undefined): boolean {
const marker = plan && fs.existsSync(plan) ? MARKER_RE.exec(fs.readFileSync(plan, 'utf8').split('\n', 1)[0]!.replace(/^\uFEFF/, '').trimEnd()) : null;
if (marker?.[1]!.trim().split(/\s+/).includes(CEO_ONLY_FLAG)) throw new RunError('CEO_ONLY_CONFLICT', `the scope marker in ${plan} names ${CEO_ONLY_FLAG}`, 3);
if (!args.includes(CEO_ONLY_FLAG)) return false;
const conflicts = CEO_ONLY_CONFLICTS.filter(f => args.includes(f));
if (conflicts.length) throw new RunError('CEO_ONLY_CONFLICT', `${CEO_ONLY_FLAG} with ${conflicts.join(', ')}`, 3);
if (existing && !existing.ceo_only) throw new RunError('CEO_ONLY_AFTER_INIT', `${existing.out} holds full run ${existing.run} (status ${existing.status})`, 3);
return true;
}
/** answer, recommend, revise, close and budget need a gate or an approvable run: a CEO-only run has neither. */
export function refuseCeoOnly(state: RunState, verb: string): void {
if (state.ceo_only) throw new RunError('CEO_ONLY_NO_GATE', `${verb} on CEO-only run ${state.run} (status ${state.status}); report: ${path.join(state.out, CEO_ONLY_REPORT)}`, 3);
}
/** Every phase closed: a full run writes its gate (carry lines follow); a CEO-only run writes its report and ends reviewed. */
export function finishRun(state: RunState): string[] {
if (!state.ceo_only) { finishGate(state); return carryLines(state); }
const list = buildGate(state, 1);
const items = list.items.filter(i => i.id !== 'p1');
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: keepDirection(items[i]!), default_if_unanswered: keepDirection(items[i]!), source: 'ceo-only' }));
writeDecisions(state.out, rows);
state.status = 'reviewed';
exportRun(state);
const findings = aggregateFindings(state);
const report = path.join(state.out, CEO_ONLY_REPORT);
fs.writeFileSync(report, renderReport(state, items, rows, findings));
state.artifacts[CEO_ONLY_REPORT] = { path: CEO_ONLY_REPORT, sha256: sha256File(report) };
journal(state.out, { event: 'ceo_only_reviewed', decisions: rows.length, findings: findings.length, report: CEO_ONLY_REPORT });
return [`REVIEWED: ceo-only run=${state.run} decisions=${rows.length} (auto_kept) findings=${findings.length} report=${report} (not approved; no gate)`];
}
type Challenge = FindingRow & { user_challenge?: boolean };
const none = (lines: string[]) => (lines.length ? lines : ['None.']);
/** The report both paths write: user challenges (direction kept), scope added, conservative choices, findings and the audit trail. */
function renderReport(state: RunState, items: GateItem[], rows: DecisionRow[], findings: FindingRow[]): string {
const plan = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : '';
const title = /^#\s+(.+)$/m.exec(plan)?.[1]?.trim() ?? path.basename(state.plan_input ?? state.plan_source);
const outside = state.phases.ceo?.outside;
const outsideLine = outside?.status === 'completed' ? `completed (${outside.model})` : `${outside?.status ?? 'disabled'}${outside?.reason ? ` (${outside.reason})` : outside?.detail ? ` (${outside.detail})` : ''}`;
const challenges = [...voiceRows(state, 'ceo', 'native'), ...voiceRows(state, 'ceo', 'outside')].filter(f => (f as Challenge).user_challenge);
const where = (f: FindingRow) => (f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : 'plan');
return [
`# CEO-only review: ${title}`, '',
`Mode: gstack-autoplan driver, CEO phase only (run ${state.run}). Outside voice: ${outsideLine}. Not approved.`, '',
'## User challenges (direction kept)', '',
...none(challenges.map(f => `- [${f.severity}] ${f.title} (${f.id}, ${f.voice}): proposal: ${f.resolution ?? 'unstated'}; the plan's direction is kept`)), '',
'## Scope added', '', 'None. The driver never edits the plan; scope proposals are listed under Findings.', '',
'## Conservative choices', '',
...none(rows.map((r, i) => `- ${r.label}: ${r.title}: kept ${r.chosen} (${items[i]!.options.find(o => o.key === r.chosen)?.text ?? r.chosen}); the reviewers recommended ${r.recommended}`)), '',
'## Findings', '',
...none(findings.map(f => `- [${f.severity}] ${f.title} (${f.id}; ${where(f)}): fix: ${f.resolution ?? 'unstated'}; disposition: ${f.disposition ?? 'open'}${(f as { reconciliation?: string }).reconciliation ? `; voices: ${(f as { reconciliation?: string }).reconciliation}` : ''}`)), '',
'## Decision Audit Trail', '',
...none(rows.map(r => `- ${r.id}: ${r.kind} ${r.status}, chosen ${r.chosen}, recommended ${r.recommended}${r.recommended_basis ? ` (${r.recommended_basis})` : ''}`)), '',
].join('\n');
}
+5 -4
View File
@@ -24,21 +24,22 @@ export const PLAN_WARN_BYTES = 30_000;
export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] {
const tasks: TaskRow[] = [];
const covered = new Set<string>();
const blockedBy = state.ceo_only ? [] : [decisionId(state.run, state.gate_rev ?? 1, 'p1')];
let n = 0;
for (const f of findings as Array<FindingRow & { settled_by?: string }>) {
if (f.settled_by || covered.has(f.id)) continue;
if (f.disposition === 'deferred') {
covered.add(f.id);
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: `deferred from ${f.phase}: ${f.title}`, status: 'pending', depends_on: [], findings: [f.id],
blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `the build resolves: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
blocked_by: blockedBy, acceptance: f.resolution ? f.resolution : `the build resolves: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
continue;
}
if (!['Critical', 'High'].includes(f.severity) || f.disposition === 'rejected') continue;
const twin = typeof f.native_counterpart === 'string' ? f.native_counterpart : findings.find(x => x.native_counterpart === f.id)?.id;
const ids = twin ? [f.id, twin] : [f.id];
ids.forEach(id => covered.add(id));
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: f.title, status: 'blocked_on_decision', depends_on: [], findings: ids,
blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: f.title, status: state.ceo_only ? 'pending' : 'blocked_on_decision', depends_on: [], findings: ids,
blocked_by: blockedBy, acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
}
return tasks;
}
@@ -100,7 +101,7 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t
...[...(state.stops ?? []), ...(state.stop ? [state.stop] : [])].map(s => `Stopped ${s.reason} at ${s.at}: cap ${s.cap_usd ?? 'none'}, spent ${s.spent}, reserved ${s.reserved}, unknown ${s.unknown}${s === state.stop ? '' : ' (lifted)'}`),
];
if (budget.length) lines.push(`Budget: max_rounds ${state.max_rounds ?? 'none'}${state.round_grants ? `, grants ${JSON.stringify(state.round_grants)}` : ''}.`, ...budget, '');
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"${typeof (state.gate as any).answered_by === 'string' ? ` answered by ${(state.gate as any).answered_by}` : ''}` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
lines.push(`Gate: ${state.ceo_only ? 'none (CEO-only run: ceo-review.md, never approved)' : state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"${typeof (state.gate as any).answered_by === 'string' ? ` answered by ${(state.gate as any).answered_by}` : ''}` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
return lines.join('\n') + '\n';
}
+17 -16
View File
@@ -19,8 +19,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate';
import { carryLines } from './autoplan-carry';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase } from './autoplan-gate';
import { finishRun } from './autoplan-ceo-only';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -72,7 +72,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
export function initRun(o: InitOptions, lines: string[] = []): RunState {
@@ -103,14 +103,14 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
const restore = path.join(out, FILES.restore);
const init = snapshotTool(['init', source, active, restore], o.cwd);
const scope = detectScope(active, o.cwd, o.scope ?? {}, path.resolve(o.plan));
const order = phaseOrder(scope);
const order: Phase[] = o.ceoOnly ? ['ceo'] : phaseOrder(scope);
const run = o.runId ?? `${path.basename(out)}-${new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14)}`;
// codex_reviews gates the outside voice here, before any dispatch: a refusal is a terminal not_run outcome per phase.
const consent = (o.outsideEnabled ?? true) ? outsideConsent({ userInvoked: false }) : undefined;
const consentMissing = consent && !consent.allowed ? consent.lines[0] : undefined;
const sessionKind = o.sessionKind ?? process.env.GSTACK_SESSION_KIND ?? 'unattended';
const phases: Record<string, PhaseState> = {};
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: o.ceoOnly ? 'ceo-only run' : scope.docs_only ? 'docs-only plan' : p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } };
const state: RunState = {
schema_version: 1, run, status: 'running', required_phases: order, phases, artifacts: {}, revision: 0, out,
plan_source: init.sourcePlan, active_plan: init.activePlan, restore: init.restorePath, scope, phase_order: order,
@@ -120,12 +120,12 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
...(o.light ? { light: true } : {}),
gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.ceoOnly ? { ceo_only: true } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
};
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
writeState(state);
return state;
}
@@ -194,7 +194,7 @@ export function nextStep(o: NextOptions): NextResult {
const lines: string[] = [];
const result: NextResult = { state, attempts: [], lines };
if (state.status === 'incomplete' && state.stop) { lines.push(...stopLines(state)); return result; }
if (state.status === 'gate_pending' || state.status === 'complete' || state.status === 'incomplete' || state.status === 'refused') return result;
if (['gate_pending', 'complete', 'reviewed', 'incomplete', 'refused'].includes(state.status)) return result;
if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; }
const pending = pendingAmendments(state).map(a => a.decision);
if (pending.length) {
@@ -206,7 +206,7 @@ export function nextStep(o: NextOptions): NextResult {
lines.push(...closeDeferred(state, phase, { reason: `round cap: round ${roundOf(state, phase)} would pass max ${roundCap(state, phase)}`, by: 'runner', cause: 'cap' }).lines);
phase = currentPhase(state);
}
if (!phase) { finishGate(state); lines.push(...carryLines(state)); writeState(state); return result; }
if (!phase) { lines.push(...finishRun(state)); writeState(state); return result; }
const first = !state.phase_order.some(p => state.phases[p]?.snapshot);
openPhase(state, phase, o.cwd);
const ps = state.phases[phase]!;
@@ -384,8 +384,9 @@ export function submitResult(o: SubmitOptions): SubmitResult {
function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] {
const close = closePhase(state, phase, { overridesFile: reconcile });
const gated = !currentPhase(state);
if (gated) finishGate(state); else state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...(gated ? carryLines(state) : [])];
const finished = gated ? finishRun(state) : [];
if (!gated) state.status = 'running';
return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`, ...finished];
}
/**
@@ -466,15 +467,15 @@ export function resumeRun(out: string): ResumeResult {
if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {});
}
const stopped = resumeStop(state);
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status);
if (gated) finishGate(state);
else if (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result') {
const gated = !currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'reviewed', 'incomplete', 'refused'].includes(state.status);
const finished = gated ? finishRun(state) : [];
if (!gated && (state.status === 'interrupted' || state.status === 'running' || state.status === 'awaiting_result')) {
const anyOpen = state.phase_order.some(p => (['native', 'outside'] as const).some(v => openAttempt(voiceState(state, p, v))));
state.status = anyOpen ? 'awaiting_result' : 'running';
}
journal(out, { event: 'resumed', unknown, rebound, status: state.status });
writeState(state);
const lines = [...recovered, ...stopped, ...(gated ? carryLines(state) : []), `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
const lines = [...recovered, ...stopped, ...finished, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`];
for (const a of unknown) lines.push(`EXECUTION_UNKNOWN: ${a}`);
return { state, unknown, rebound, lines };
}
@@ -498,6 +499,6 @@ export function statusLines(state: RunState): string[] {
for (const d of state.capped_deltas ?? []) lines.push(`CAPPED_DELTA: v${d.k} ${d.phase} +${d.added} -${d.removed} ${d.status}`);
return lines;
}
export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted'];
export const TERMINAL: readonly RunStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted'];
export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal };
export type { RunState, Voice, VoiceState, AttemptState };
+2
View File
@@ -72,6 +72,8 @@ export interface RunState extends RunManifest {
dispatches?: DispatchCounts; est_input_tokens?: number;
/** Carry-forward (lib/autoplan-carry.ts): false with --no-carry-forward; the per-gate cap on the delta check; the latest carry decision. */
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
/** `next --ceo-only` (lib/autoplan-ceo-only.ts): the CEO phase alone, no gate, ends `reviewed`; never set by a scope marker. */
ceo_only?: boolean;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+4 -4
View File
@@ -20,7 +20,7 @@ export type Severity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational';
export type Disposition = 'open' | 'accepted' | 'partially_accepted' | 'rejected' | 'deferred' | 'fixed';
export type Voice = 'native' | 'outside';
export type DecisionKind = 'auto' | 'approval' | 'user_challenge';
export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected';
export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected' | 'auto_kept';
export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done';
export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running';
export type ResultStatus = 'complete' | 'reviewed' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused';
@@ -118,7 +118,7 @@ export interface ShipReceipt {
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
const TERMINAL_RUN: readonly string[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed'];
const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'];
@@ -172,7 +172,7 @@ export const ARTIFACT_SCHEMAS = {
required: ['schema_version', 'run', 'id', 'title', 'options', 'recommended', 'kind', 'status', 'gate_rev'],
properties: {
schema_version: { const: 1 }, run: str, id: str, label: str, title: str, options: strList, recommended: nullableStr,
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected'] },
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected', 'auto_kept'] },
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
@@ -203,7 +203,7 @@ export const ARTIFACT_SCHEMAS = {
required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'],
properties: {
schema_version: { const: 1 }, run: str,
status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
status: { enum: ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] },
base: str, host: str, session_kind: str, required_phases: strList,
phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} },
counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' },
+26
View File
@@ -0,0 +1,26 @@
/**
* Result codes for the CEO-only driver run (`gstack-autoplan next --ceo-only`,
* lib/autoplan-ceo-only.ts). Kept beside lib/result-codes.ts, which spreads
* this table into RESULT_CODES; the same rules apply: one stable
* docs/troubleshooting.md anchor, a summary and a fix clause each. The run is
* documented in docs/unattended.md#ceo-only-runs.
*/
import type { ResultCode } from './result-codes';
export const AUTOPLAN_RESULT_CODES = {
CEO_ONLY_CONFLICT: {
anchor: 'ceo-only-conflict',
summary: '--ceo-only was combined with a flag that adds a phase (--ui, --developer-tool, --agent-primary), or a plan\'s scope marker names it',
fix: 'drop the conflicting flag, or run without --ceo-only for the full review; --ceo-only goes on the command line of the first next only, never in a gstack-autoplan-scope marker',
},
CEO_ONLY_AFTER_INIT: {
anchor: 'ceo-only-after-init',
summary: 'next --ceo-only named a run directory that already holds a full review run',
fix: 'a run keeps the mode it started with: pass --ceo-only on the first next with a new --out directory',
},
CEO_ONLY_NO_GATE: {
anchor: 'ceo-only-no-gate',
summary: 'the command needs a gate or an approvable run, and a CEO-only run has neither: it ends status=reviewed and is never approved',
fix: 'read <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
},
} as const satisfies Record<string, ResultCode>;
+2
View File
@@ -14,6 +14,7 @@ import { RELEASE_RESULT_CODES } from './result-codes-release';
import { CAPY_RESULT_CODES } from './result-codes-capy';
import { BUDGET_RESULT_CODES } from './result-codes-budget';
import { EVAL_RESULT_CODES } from './result-codes-eval';
import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
export interface ResultCode {
/** Stable `<a id>` in docs/troubleshooting.md. */
@@ -763,6 +764,7 @@ export const RESULT_CODES = {
...CAPY_RESULT_CODES,
...BUDGET_RESULT_CODES,
...EVAL_RESULT_CODES,
...AUTOPLAN_RESULT_CODES,
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+1 -1
View File
@@ -17,7 +17,7 @@ import type { ResolverFn } from './types';
/** One entry per command line the loop runs, as the field-wave plan specifies them. */
export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended), and an unattended Capy run reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds` },
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold] [--ceo-only]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended), and an unattended Capy run reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends \`status=reviewed\` with \`<dir>/ceo-review.md\` (\`REVIEWED:\`): no gate, and the gate verbs refuse it (\`CEO_ONLY_NO_GATE\`)` },
{ cmd: 'gstack-autoplan next --out <dir> [--redispatch <attempt>]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
+141
View File
@@ -0,0 +1,141 @@
/**
* GST-537 PR 2: `gstack-autoplan next --ceo-only`, the driver twin of
* `/autoplan --ceo-only`. The run opens only the CEO phase, ends
* status=reviewed with ceo-review.md and auto_kept decision rows, never writes
* a gate or approves, keeps its artifacts schema-valid, and the verbs that need
* a gate refuse it with a coded line. The flag is command-line only.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { importAutoplan } from '../lib/approval-record';
import { ROOT, SMALL_PLAN, autoplan, env, journal, readRun, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-ceo-only-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
const dir = (name: string) => path.join(TMP, name);
const rows = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
describe('next --ceo-only runs the CEO phase alone and ends reviewed', () => {
const out = startSmallRun(dir('reviewed'), ['--ceo-only']);
const phase = runPhase(out, 'ceo');
const run = readRun(out);
test('init records the mode and skips every other phase', () => {
expect(run.ceo_only).toBe(true);
expect(run.phase_order).toEqual(['ceo']);
expect(run.required_phases).toEqual(['ceo']);
for (const p of ['design', 'eng', 'dx']) expect(run.phases[p].skipped).toBe('ceo-only run');
expect(journal(out).find(r => r.event === 'init').ceo_only).toBe(true);
});
test('closing CEO ends status=reviewed with the report, and no gate', () => {
expect(phase.outside.status).toBe(0);
expect(phase.outside.stdout).toContain('REVIEWED: ceo-only');
expect(phase.outside.stdout.trimEnd().split('\n').at(-1)).toBe(`GSTACK_RESULT: skill=autoplan status=reviewed run=${out}`);
expect(phase.outside.stdout).not.toContain('GATE:');
expect(run.status).toBe('reviewed');
expect(run.gate_rev).toBeUndefined();
expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
expect(journal(out).some(r => r.event === 'gate_written')).toBe(false);
});
test('ceo-review.md has the shared report sections and is hashed in run.json', () => {
const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
expect(report.split('\n')[0]).toBe('# CEO-only review: Retry backoff for the webhook worker');
expect(report).toContain('Not approved.');
for (const h of ['## User challenges (direction kept)', '## Scope added', '## Conservative choices', '## Findings', '## Decision Audit Trail']) expect(report).toContain(h);
expect(report).toContain('Docs link the quickstart');
const sha = crypto.createHash('sha256').update(fs.readFileSync(path.join(out, 'ceo-review.md'))).digest('hex');
expect(run.artifacts['ceo-review.md']).toEqual({ path: 'ceo-review.md', sha256: sha });
});
test('decision rows are auto, auto_kept, the direction kept and unanswered; tasks name no p1', () => {
const decisions = rows(path.join(out, 'decisions.jsonl'));
expect(decisions.length).toBeGreaterThan(0);
for (const d of decisions) {
expect(d.kind).toBe('auto');
expect(d.status).toBe('auto_kept');
expect(d.chosen).toBe('a');
expect(d.answered_at).toBeUndefined();
expect(d.id.endsWith('-p1')).toBe(false);
}
const tasks = rows(path.join(out, 'tasks.jsonl'));
expect(tasks.length).toBeGreaterThan(0);
for (const t of tasks) { expect(t.blocked_by).toEqual([]); expect(t.status).not.toBe('blocked_on_decision'); }
});
test('the artifact set validates and the plan is untouched', () => {
const v = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, env: env(), encoding: 'utf8', timeout: 60_000 });
expect(v.stdout).toContain('ARTIFACT_VALID: ');
expect(v.stdout).toContain('status=reviewed');
expect(v.status).toBe(0);
expect(fs.readFileSync(path.join(dir('reviewed'), 'plan.md'), 'utf8')).toBe(SMALL_PLAN);
});
test('a later next or resume keeps reviewed and writes no gate', () => {
const next = autoplan(['next', '--out', out]);
expect(next.status).toBe(0);
expect(next.stdout).toContain('status=reviewed');
const resume = autoplan(['resume', '--out', out]);
expect(resume.status).toBe(0);
expect(resume.stdout).toContain('status=reviewed');
expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
});
test('answer, recommend, revise, close and budget refuse CEO_ONLY_NO_GATE', () => {
const plan = path.join(dir('reviewed'), 'plan.md');
for (const args of [['answer', '--gate-rev', '1', '--reply', 'rec', '--by', 'human'], ['answer', '--reply', 'rec', '--by', 'human'],
['recommend', '--gate-rev', '1', '--item', 'd1', '--option', 'b', '--basis', 'x'], ['revise', '--plan', plan],
['close', '--phase', 'ceo', '--deferred', 'x', '--by', 'human'], ['budget', '--spend-cap', '5', '--by', 'human']]) {
const r = autoplan([args[0]!, '--out', out, ...args.slice(1)]);
expect(r.status, args.join(' ')).toBe(3);
expect(r.stderr).toContain('(CEO_ONLY_NO_GATE)');
}
expect(readRun(out).status).toBe('reviewed');
});
test('approvals import refuses a CEO-only run and an unreadable one', () => {
expect(importAutoplan(out, 'main')).toMatchObject({ code: 'CEO_ONLY_NO_GATE' });
const bad = dir('bad-run');
fs.mkdirSync(bad, { recursive: true });
fs.writeFileSync(path.join(bad, 'decisions.jsonl'), '');
fs.writeFileSync(path.join(bad, 'run.json'), '{not json');
expect(importAutoplan(bad, 'main')).toMatchObject({ code: 'ARTIFACT_INVALID_JSON' });
});
});
describe('--ceo-only is a command-line flag for one run', () => {
const plan = (name: string, text = SMALL_PLAN) => { fs.mkdirSync(dir(name), { recursive: true }); const f = path.join(dir(name), 'plan.md'); fs.writeFileSync(f, text); return f; };
test('a phase-adding flag beside it refuses before anything is written', () => {
for (const flag of ['--ui', '--developer-tool', '--agent-primary']) {
const out = path.join(dir(`conflict${flag}`), 'run');
const r = autoplan(['next', '--out', out, '--plan', plan(`conflict${flag}`), '--ceo-only', flag]);
expect(r.status, flag).toBe(3);
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
}
});
test('a scope marker naming it refuses, with or without the flag', () => {
const marked = plan('marker', `<!-- gstack-autoplan-scope: --no-ui --ceo-only -->\n${SMALL_PLAN}`);
for (const extra of [[], ['--ceo-only']]) {
const r = autoplan(['next', '--out', path.join(dir('marker'), `run${extra.length}`), '--plan', marked, ...extra]);
expect(r.status).toBe(3);
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
}
});
test('a directory that holds a full run refuses it after init', () => {
const out = startSmallRun(dir('full'));
const r = autoplan(['next', '--out', out, '--ceo-only']);
expect(r.status).toBe(3);
expect(r.stderr).toContain('(CEO_ONLY_AFTER_INIT)');
expect(readRun(out).ceo_only).toBeUndefined();
expect(readRun(out).phase_order).toContain('eng');
});
});
+2
View File
@@ -102,6 +102,7 @@
{ "file": "lib/result-codes-capy.ts", "movedFrom": "lib/result-codes.ts (the Capy rows: approval record, Captain brief, PR screenshots; GSTA-61)" },
{ "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" },
{ "file": "lib/result-codes-eval.ts", "movedFrom": "lib/result-codes.ts (the /eval-plan preregistration rows and the no-gstack arm; GSTA-168)" },
{ "file": "lib/result-codes-autoplan.ts", "movedFrom": "lib/result-codes.ts (the CEO-only driver run rows; GST-537)" },
{ "file": "lib/ship-budget.ts", "movedFrom": "(new: /ship token budget ledger, meters and admission, GSTA-152)" },
{ "file": "lib/ship-budget-cli.ts", "movedFrom": "(new: bin/gstack-ship-budget start/check/settle/close/resume, GSTA-152)" },
{ "file": "lib/ship-handoff.ts", "movedFrom": "(new: /ship fresh-context handoff, verify, return and reconcile, GSTA-152)" },
@@ -124,6 +125,7 @@
{ "file": "lib/autoplan-state.ts", "movedFrom": "(new: the unattended runner's on-disk state, journal and locked writes, B2)" },
{ "file": "lib/autoplan-run.ts", "movedFrom": "(new: next/submit/resume over lib/autoplan-state.ts, B2)" },
{ "file": "lib/autoplan-gate.ts", "movedFrom": "(new: phase close, gate build, answer and reopen, B2/B9)" },
{ "file": "lib/autoplan-ceo-only.ts", "movedFrom": "(new: the CEO-only driver run's finish, report and refusals, GST-537)" },
{ "file": "lib/autoplan-export.ts", "movedFrom": "(new: the unattended export file set, B2/B12)" },
{ "file": "lib/spec-review-cap.ts", "movedFrom": "(new: the spec-review loop at the cap: numbered gaps and deterministic fix verification, B8)" },
{ "file": "bin/gstack-autoplan", "movedFrom": "(new: thin entrypoint over lib/autoplan-run.ts, B2)" },