--phase --voice native|outside --record --model [--attempt ]`
diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan
index c42062867..39873dbb9 100755
--- a/bin/gstack-autoplan
+++ b/bin/gstack-autoplan
@@ -2,7 +2,7 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
- * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
+ * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json]
* gstack-autoplan submit --out --phase --voice native|outside --result | --record [--attest ]
* --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--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 [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
+ next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--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 /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 /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 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 (and optionally --incorporates ), --withdraw or --settle ');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan ');
+ 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 --deferred "" --by >');
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: (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 {
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 ""');
- 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 {
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev --item --option --basis ""');
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 });
diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh
index 4e70a2be6..fb778d3e9 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -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
}
diff --git a/changelog.d/gsta215-ceo-only-driver.md b/changelog.d/gsta215-ceo-only-driver.md
new file mode 100644
index 000000000..888e531a8
--- /dev/null
+++ b/changelog.d/gsta215-ceo-only-driver.md
@@ -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 `/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=`. 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.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 61886e363..7914ed890 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -3102,6 +3102,39 @@ result.
**Fix.** Run `gstack-autoplan status --out ` and submit for the attempt
the last `next` printed (`ATTEMPT: ... phase= voice=`).
+
+### `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`.
+
+
+### `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`.
+
+
+### `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 `/ceo-review.md`. For a gate and an approval, start a
+full review in a new directory: `gstack-autoplan next --out --plan
+` without `--ceo-only`.
+
### `ATTEMPT_MISMATCH`
diff --git a/docs/unattended.md b/docs/unattended.md
index 765d86527..6d01d6faa 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -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 --plan
+--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=`, with
+`/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 ` is what the owner sees at the final
diff --git a/lib/approval-record.ts b/lib/approval-record.ts
index 000605b96..1bfd5e1d5 100644
--- a/lib/approval-record.ts
+++ b/lib/approval-record.ts
@@ -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 = {};
+ 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;
diff --git a/lib/autoplan-ceo-only.ts b/lib/autoplan-ceo-only.ts
new file mode 100644
index 000000000..9adc592f8
--- /dev/null
+++ b/lib/autoplan-ceo-only.ts
@@ -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*$/;
+
+/**
+ * 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');
+}
diff --git a/lib/autoplan-export.ts b/lib/autoplan-export.ts
index 81bb8a276..30b58a725 100644
--- a/lib/autoplan-export.ts
+++ b/lib/autoplan-export.ts
@@ -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();
+ const blockedBy = state.ceo_only ? [] : [decisionId(state.run, state.gate_rev ?? 1, 'p1')];
let n = 0;
for (const f of findings as Array) {
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';
}
diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts
index d6a4fbdbd..dacb9f176 100644
--- a/lib/autoplan-run.ts
+++ b/lib/autoplan-run.ts
@@ -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(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 = {};
- 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 };
diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts
index 01b345688..e1426a935 100644
--- a/lib/autoplan-state.ts
+++ b/lib/autoplan-state.ts
@@ -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;
diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts
index c286e294b..3a59f746e 100644
--- a/lib/headless-artifacts.ts
+++ b/lib/headless-artifacts.ts
@@ -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' },
diff --git a/lib/result-codes-autoplan.ts b/lib/result-codes-autoplan.ts
new file mode 100644
index 000000000..3e12c3a6c
--- /dev/null
+++ b/lib/result-codes-autoplan.ts
@@ -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 /ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out --plan (no --ceo-only)',
+ },
+} as const satisfies Record;
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index beaeb779b..4769fe42c 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -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 `` 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;
export type ResultCodeName = keyof typeof RESULT_CODES;
diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts
index d84d63b95..0c955d8d6 100644
--- a/scripts/resolvers/autoplan-driver.ts
+++ b/scripts/resolvers/autoplan-driver.ts
@@ -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 --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--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 says otherwise, so --spend-cap binds` },
+ { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--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 says otherwise, so --spend-cap binds; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends \`status=reviewed\` with \`/ceo-review.md\` (\`REVIEWED:\`): no gate, and the gate verbs refuse it (\`CEO_ONLY_NO_GATE\`)` },
{ cmd: 'gstack-autoplan next --out [--redispatch ]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
{ cmd: 'gstack-autoplan submit --out --phase --voice native|outside --record --model [--attempt ]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
{ cmd: 'gstack-autoplan submit ... --record --attest "" --attempt ', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
diff --git a/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts
new file mode 100644
index 000000000..ccda4eef5
--- /dev/null
+++ b/test/autoplan-ceo-only-driver.test.ts
@@ -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', `\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');
+ });
+});
diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json
index d56450722..a86aae541 100644
--- a/test/fixtures/module-size-ratchet.json
+++ b/test/fixtures/module-size-ratchet.json
@@ -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)" },