--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';
@@ -57,10 +58,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, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the 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
@@ -187,16 +189,18 @@ function doNext(out: string, rest: string[], json: boolean, price?: NextOptions[
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, out);
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]!] : []));
@@ -253,6 +257,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));
@@ -273,7 +278,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));
@@ -415,7 +420,7 @@ 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 wordsBy = flag(rest, '--words-by') ?? 'owner';
@@ -423,6 +428,7 @@ async function main(argv: string[]): Promise {
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, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
writeState(state);
const lines = [...r.lines];
@@ -437,6 +443,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 5bda51ce5..960129724 100644
--- a/bin/gstack-doctor-components.sh
+++ b/bin/gstack-doctor-components.sh
@@ -378,6 +378,9 @@ gstack_result_anchor() {
OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;;
TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;;
REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;;
+ 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/docs/troubleshooting.md b/docs/troubleshooting.md
index 1e113eb92..d73967bb1 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -3281,6 +3281,42 @@ 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` (on the command line or in the plan's
+`gstack-autoplan-scope` marker), each of which adds a phase a CEO-only run
+never opens; or the marker names `--ceo-only` (the mode is a command-line
+choice for one run, so a plan can't pin it); or the plan is `/ceo-review.md`
+(directly, through a symlink or a hard link), the report the run would write
+over it. The finish checks that last case again before it writes the report.
+
+**Fix.** Drop the conflicting flag (or the marker token), move the plan out of
+the report path, 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` or `revise` 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. (`budget` and `close --deferred` still
+work: they are how a budget-stopped CEO-only run recovers.)
+
+**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 e1a5a865b..822f38e73 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -259,7 +259,7 @@ tree no review saw, so /ship runs again;
| File | Schema | Contents |
|------|--------|----------|
| `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. |
-| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`-` at gate revision 1, `-r-` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
+| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`-` at gate revision 1, `-r-` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
| `findings.jsonl` | `findings` v1 | One row per reviewer finding: `id`, `phase`, `voice` (`native`/`outside`), `model`, `severity`, `title`, `disposition`, `plan_items`, `native_counterpart` (string or null), `source`. Rows share the review-log row shape, so `gstack-review-log --findings` derives review status from this file. |
| `tasks.jsonl` | `tasks` v2 | Implementation tasks with `id`, `title`, `status`, `depends_on`, `findings` (ids in `findings.jsonl`), `blocked_by`, `acceptance`, optional `pr`/`item`/`tier`. |
| `timing.json` | `timing` v1 | Per-phase `started_at`, `ended_at`, `wall_s`, `outside_s`, `native_s`; `started_at` and `total_wall_s` for the run; the Phase 0 `estimate`. |
@@ -596,12 +596,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.
@@ -627,6 +627,30 @@ 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` (nobody answered: the plan's direction is kept, and a round-cap item
+only the owner can accept, such as a Critical left open, stays undecided with
+no chosen option), and tasks are blocked on no decision. An open Critical or
+High finding, which a full gate asks about, gets no row: nobody decides it, and
+it stays in the report's Findings and in `tasks.jsonl` for the owner. 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` on the command line or in the plan's
+ scope marker, when the marker names it, and on a plan at the report path
+ (`CEO_ONLY_CONFLICT`); a directory that holds a full run refuses it
+ (`CEO_ONLY_AFTER_INIT`).
+- `answer`, `recommend` and `revise` 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. A run stopped by `--spend-cap` recovers as a full run
+ does: `budget` then `resume`, or `close --phase ceo --deferred`, which ends
+ it `reviewed`.
+
## 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 74418d987..f26ef6d64 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..d2ec44826
--- /dev/null
+++ b/lib/autoplan-ceo-only.ts
@@ -0,0 +1,105 @@
+/**
+ * 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` (nobody answered: the
+ * plan's direction is kept, and an item only the owner can accept stays
+ * undecided), 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); `budget` and
+ * `close --deferred` still recover a run stopped by its spend cap.
+ */
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { buildGate, finishGate } from './autoplan-gate';
+import { FINDING_ORIGIN } from './autoplan-confirm';
+import { carryLines } from './autoplan-carry';
+import { aggregateFindings, exportRun } from './autoplan-export';
+import { decisionRows, keepDirection, type GateItem } from './gate-list';
+import { MARKER_RE } from './autoplan-scope';
+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';
+
+/**
+ * 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 (argument or marker), on a plan at the report path,
+ * or on a directory that holds a full run.
+ */
+export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefined, existing: RunState | undefined, out?: string): boolean {
+ const marker = plan && fs.existsSync(plan) ? MARKER_RE.exec(fs.readFileSync(plan, 'utf8').split('\n', 1)[0]!.replace(/^\uFEFF/, '').trimEnd()) : null;
+ const tokens = marker?.[1]!.trim().split(/\s+/) ?? [];
+ if (tokens.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) || tokens.includes(f));
+ if (conflicts.length) throw new RunError('CEO_ONLY_CONFLICT', `${CEO_ONLY_FLAG} with ${conflicts.join(', ')}`, 3);
+ const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
+ if (plan && out && (path.resolve(plan) === path.resolve(out, CEO_ONLY_REPORT) || real(plan) === path.join(real(out), CEO_ONLY_REPORT))) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 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 and revise need a gate or an approvable run: a CEO-only run has neither (budget and close stay: they recover a budget stop). */
+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 report = path.join(state.out, CEO_ONLY_REPORT);
+ const id = (p: string) => fs.statSync(p, { bigint: true });
+ const at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? id(report) : undefined;
+ const plan = at?.nlink === 1n ? [state.plan_source, state.plan_input, state.active_plan].find(p => p && fs.existsSync(p) && id(p).ino === at.ino && id(p).dev === at.dev) : undefined;
+ if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
+ const list = buildGate(state, 1);
+ const items = list.items.filter(i => i.id !== 'p1' && !i.origin?.endsWith(`|${FINDING_ORIGIN}`));
+ const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
+ const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: kept(items[i]!), default_if_unanswered: kept(items[i]!), source: 'ceo-only' }));
+ writeDecisions(state.out, rows);
+ state.status = 'reviewed';
+ exportRun(state);
+ const findings = aggregateFindings(state);
+ const tmp = `${report}.${crypto.randomBytes(4).toString('hex')}.tmp`;
+ fs.writeFileSync(tmp, renderReport(state, items, rows, findings), { flag: 'wx' });
+ fs.renameSync(tmp, report);
+ 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}: ${r.chosen === null ? 'left open for the owner (not accepted)' : `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 ?? 'none'}, recommended ${r.recommended}${r.recommended_basis ? ` (${r.recommended_basis})` : ''}`)), '',
+ ].join('\n');
+}
diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts
index e134d20c1..141144b86 100644
--- a/lib/autoplan-checkpoint.ts
+++ b/lib/autoplan-checkpoint.ts
@@ -1,7 +1,7 @@
/**
* autoplan-checkpoint — an opt-in durable copy of a run directory (next-wave
* B12). With GSTACK_AUTOPLAN_CHECKPOINT_DIR set, every mutating command that
- * leaves the run at rest (gate_pending, amend_pending, complete, incomplete)
+ * leaves the run at rest (gate_pending, amend_pending, complete, reviewed, incomplete)
* publishes one immutable generation under `//generations//`:
* `checkpoint.json` (every file's sha256), the run files under `run/`, the
* owner's plan file under `plan-input/` when it lives outside the run, and
@@ -34,7 +34,7 @@ import { readConfigKey } from './state-root';
const MANIFEST = 'checkpoint.json';
const CURRENT = 'current.json';
const GENERATIONS = 'generations';
-const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'incomplete'];
+const AT_REST = ['gate_pending', 'amend_pending', 'complete', 'reviewed', 'incomplete'];
const FOREIGN_LOCK_STALE_MS = 10 * 60_000;
interface CheckpointManifest {
schema_version: 1; run: string; status: string; gate_rev: number; revision: number; at: string; host: string; gstack_version?: string;
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 59833f2fc..0aec9c815 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;
}
@@ -198,7 +198,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) {
@@ -210,7 +210,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]!;
@@ -394,8 +394,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];
}
/**
@@ -514,15 +515,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 };
}
@@ -546,6 +547,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-scope.ts b/lib/autoplan-scope.ts
index 736bcf437..966810338 100644
--- a/lib/autoplan-scope.ts
+++ b/lib/autoplan-scope.ts
@@ -65,7 +65,7 @@ export function detectDocsOnly(implementationPlan: string, root: string): DocsOn
export interface ScopeFlags { ui?: boolean; developerTool?: boolean; agentPrimary?: boolean; dx?: boolean; docsOnly?: boolean }
export const SCOPE_FLAG_TOKENS = ['--ui', '--no-ui', '--no-dx', '--developer-tool', '--agent-primary', '--docs-only'] as const;
-const MARKER_RE = /^\s*$/;
+export const MARKER_RE = /^\s*$/;
export function scopeTokens(f: ScopeFlags): string[] {
return [...(f.ui === true ? ['--ui'] : f.ui === false ? ['--no-ui'] : []), ...(f.dx === false ? ['--no-dx'] : []),
diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts
index 1709a9097..39f352cf6 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 328295150..433bec17b 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';
@@ -122,7 +122,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'];
@@ -176,7 +176,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,
@@ -207,7 +207,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..b03d5f835
--- /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: 'answer, recommend, revise and approvals import need 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 2dece5f82..29217ed44 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -15,6 +15,7 @@ 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 { PLAN_RESULT_CODES } from './result-codes-plan';
+import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan';
export interface ResultCode {
/** Stable `` in docs/troubleshooting.md. */
@@ -770,6 +771,7 @@ export const RESULT_CODES = {
...BUDGET_RESULT_CODES,
...EVAL_RESULT_CODES,
...PLAN_RESULT_CODES,
+ ...AUTOPLAN_RESULT_CODES,
} as const satisfies Record;
export type ResultCodeName = keyof typeof RESULT_CODES;
diff --git a/package.json b/package.json
index fa24179e7..dbf39a65b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gstack",
- "version": "1.92.57",
+ "version": "1.92.58",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts
index b42926c9c..edde96373 100644
--- a/scripts/pr-dependencies.ts
+++ b/scripts/pr-dependencies.ts
@@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [
// The build entry list: every compiled binary, which skills reach through their `--outfile` path instead.
'scripts/build.sh',
// The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes.
- 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts',
+ 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts',
// The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case).
'test/helpers/exercised-commands.json',
];
diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts
index 260d7b2d4..d0cb97143 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 ] [--estimate-usd ] [--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); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases` },
+ { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--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); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases; --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 run --out --plan --native-model --outside-model --native-runner api --runner api [--max-tokens ] [--spend-cap ]', does: 'hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation' },
{ 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.`)' },
diff --git a/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts
new file mode 100644
index 000000000..db7b56bfc
--- /dev/null
+++ b/test/autoplan-ceo-only-driver.test.ts
@@ -0,0 +1,303 @@
+/**
+ * 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, attemptsOf, autoplan, env, journal, readRun, resultOf, review, 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('an open Critical or High finding is decided by nobody: no decision row, still in the report and the tasks for the owner', () => {
+ const decisions = rows(path.join(out, 'decisions.jsonl'));
+ expect(decisions.some(d => /^(Critical|High): /.test(d.title))).toBe(false);
+ const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
+ expect(report).toMatch(/\[Critical\] The install check lies by omission .*disposition: /);
+ expect(report).toMatch(/\[High\] Gate reuse on tree equality alone is unsound .*disposition: /);
+ expect(report).not.toMatch(/(Critical|High): .*kept/);
+ const tasks = rows(path.join(out, 'tasks.jsonl'));
+ expect(tasks.some(t => t.title === 'Gate reuse on tree equality alone is unsound' && t.priority === 'High')).toBe(true);
+ });
+
+ 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 and revise 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]]) {
+ 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('a budget-stopped CEO-only run recovers the way a full run does', () => {
+ const stopped = (name: string, extra: Record = {}) => {
+ fs.mkdirSync(dir(name), { recursive: true });
+ fs.writeFileSync(path.join(dir(name), 'plan.md'), SMALL_PLAN);
+ const out = path.join(dir(name), 'run');
+ const first = autoplan(['next', '--out', out, '--plan', path.join(dir(name), 'plan.md'), '--no-ui', '--ceo-only', '--spend-cap', '1', '--estimate-usd', '5'], { env: env(extra) });
+ expect(first.status).toBe(3);
+ expect(first.stderr).toContain('(SPEND_CAP_EXCEEDED)');
+ expect(readRun(out).status).toBe('incomplete');
+ return out;
+ };
+
+ test('budget then resume lifts the stop, and the run ends reviewed', () => {
+ const out = stopped('stopped-budget');
+ const budget = autoplan(['budget', '--out', out, '--spend-cap', '50', '--by', 'human']);
+ expect(budget.status, budget.stderr).toBe(0);
+ expect(autoplan(['resume', '--out', out]).stdout).toContain('STOP_LIFTED');
+ runPhase(out, 'ceo');
+ expect(readRun(out).status).toBe('reviewed');
+ expect(fs.existsSync(path.join(out, 'ceo-review.md'))).toBe(true);
+ });
+
+ test('close --deferred finishes it reviewed, with the report and no gate', () => {
+ const out = stopped('stopped-close');
+ const close = autoplan(['close', '--out', out, '--phase', 'ceo', '--deferred', 'out of budget', '--by', 'human']);
+ expect(close.status, close.stderr).toBe(0);
+ expect(close.stdout).toContain('REVIEWED: ceo-only');
+ expect(close.stdout.trimEnd().split('\n').at(-1)).toBe(`GSTACK_RESULT: skill=autoplan status=reviewed run=${out}`);
+ expect(fs.existsSync(path.join(out, 'ceo-review.md'))).toBe(true);
+ expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false);
+ });
+});
+
+describe('reviewed is a status at rest for checkpoints', () => {
+ test('resume on a reviewed run publishes a reviewed generation', () => {
+ const root = path.join(dir('ckpt'), 'drive');
+ const out = startSmallRun(dir('ckpt'), ['--ceo-only']);
+ runPhase(out, 'ceo');
+ expect(readRun(out).status).toBe('reviewed');
+ const resumed = autoplan(['resume', '--out', out], { env: env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }) });
+ expect(resumed.stderr).toContain('status=reviewed');
+ const current = JSON.parse(fs.readFileSync(path.join(root, readRun(out).run, 'current.json'), 'utf8'));
+ expect(current.status).toBe('reviewed');
+ });
+});
+
+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 scope marker that adds a phase conflicts with the flag', () => {
+ for (const token of ['--ui', '--developer-tool', '--agent-primary']) {
+ const marked = plan(`marker${token}`, `
+${SMALL_PLAN}`);
+ const out = path.join(dir(`marker${token}`), 'run');
+ const r = autoplan(['next', '--out', out, '--plan', marked, '--ceo-only']);
+ expect(r.status, token).toBe(3);
+ expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
+ expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
+ }
+ });
+
+ 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');
+ });
+});
+
+describe('the CEO-only finish records no answer nobody gave and writes only its own report', () => {
+ test('a Critical left open by close --deferred stays undecided: no chosen option, listed as needing the owner', () => {
+ const out = startSmallRun(dir('blocker'), ['--ceo-only']);
+ const r = review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Critical', title: 'Retries can double-charge', fix: 'idempotency key' }]) });
+ expect(r.status, r.stderr).toBe(0);
+ const ids = attemptsOf(out, 'ceo');
+ const bound = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', ids.native, '--runner', 'host-subagent']);
+ expect(bound.status, bound.stderr).toBe(0);
+ const close = autoplan(['close', '--out', out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']);
+ expect(close.status, close.stderr).toBe(0);
+ expect(close.stdout).toContain('REVIEWED: ceo-only');
+ const blocker = rows(path.join(out, 'decisions.jsonl')).find(d => d.title.includes('Retries can double-charge'));
+ expect(blocker).toMatchObject({ kind: 'auto', status: 'auto_kept', chosen: null, default_if_unanswered: null });
+ const report = fs.readFileSync(path.join(out, 'ceo-review.md'), 'utf8');
+ expect(report).not.toContain('you accept the risk');
+ expect(report).toMatch(/Retries can double-charge.*left open for the owner/);
+ });
+
+ test('a plan at the report path refuses before anything is written', () => {
+ const out = path.join(dir('collide'), 'run');
+ fs.mkdirSync(out, { recursive: true });
+ const plan = path.join(out, 'ceo-review.md');
+ fs.writeFileSync(plan, SMALL_PLAN);
+ const r = autoplan(['next', '--out', out, '--plan', plan, '--no-ui', '--ceo-only']);
+ expect(r.status).toBe(3);
+ expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
+ expect(fs.existsSync(path.join(out, 'run.json'))).toBe(false);
+ expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
+ });
+
+ test('a plan that reaches the report path through a symlink, or through a symlinked --out, refuses too', () => {
+ const real = path.join(dir('alias'), 'real-run');
+ fs.mkdirSync(real, { recursive: true });
+ const report = path.join(real, 'ceo-review.md');
+ fs.writeFileSync(report, SMALL_PLAN);
+ const link = path.join(dir('alias'), 'input.md');
+ fs.symlinkSync(report, link);
+ const linkedOut = path.join(dir('alias'), 'linked-run');
+ fs.symlinkSync(real, linkedOut);
+ for (const [plan, out] of [[link, real], [report, linkedOut]]) {
+ const r = autoplan(['next', '--out', out!, '--plan', plan!, '--no-ui', '--ceo-only']);
+ expect(r.status, plan).toBe(3);
+ expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
+ expect(fs.existsSync(path.join(real, 'run.json'))).toBe(false);
+ }
+ expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
+ });
+
+ test('a run whose plan entry is the report file refuses at the finish and leaves it intact', () => {
+ const out = startSmallRun(dir('same-entry'), ['--ceo-only']);
+ const report = path.join(out, 'ceo-review.md');
+ fs.writeFileSync(report, SMALL_PLAN);
+ const run = readRun(out);
+ fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify({ ...run, plan_input: report }, null, 2) + '\n');
+ const submit = runPhase(out, 'ceo').outside;
+ expect(submit.status).toBe(3);
+ expect(submit.stderr).toContain('(CEO_ONLY_CONFLICT)');
+ expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
+ expect(readRun(out).status).not.toBe('reviewed');
+ });
+
+ test('a plan hard-linked to the report path finishes: the rename replaces the link, never the plan', () => {
+ const out = startSmallRun(dir('hardlink'), ['--ceo-only']);
+ const plan = path.join(dir('hardlink'), 'plan.md');
+ fs.linkSync(plan, path.join(out, 'ceo-review.md'));
+ runPhase(out, 'ceo');
+ expect(readRun(out).status).toBe('reviewed');
+ expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
+ });
+
+ test('a --plan given as a symlink at the report path refuses', () => {
+ const out = path.join(dir('report-link'), 'run');
+ fs.mkdirSync(out, { recursive: true });
+ const plan = path.join(dir('report-link'), 'plan.md');
+ fs.writeFileSync(plan, SMALL_PLAN);
+ fs.symlinkSync(plan, path.join(out, 'ceo-review.md'));
+ const r = autoplan(['next', '--out', out, '--plan', path.join(out, 'ceo-review.md'), '--no-ui', '--ceo-only']);
+ expect(r.status).toBe(3);
+ expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
+ });
+
+ test('a symlink at the report path is replaced, never written through', () => {
+ const out = startSmallRun(dir('symlink'), ['--ceo-only']);
+ const victim = path.join(dir('symlink'), 'victim.txt');
+ fs.writeFileSync(victim, 'keep me');
+ fs.symlinkSync(victim, path.join(out, 'ceo-review.md'));
+ runPhase(out, 'ceo');
+ expect(readRun(out).status).toBe('reviewed');
+ expect(fs.readFileSync(victim, 'utf8')).toBe('keep me');
+ expect(fs.lstatSync(path.join(out, 'ceo-review.md')).isSymbolicLink()).toBe(false);
+ });
+});
+
diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json
index 74e49bbc7..babd4367e 100644
--- a/test/fixtures/module-size-ratchet.json
+++ b/test/fixtures/module-size-ratchet.json
@@ -105,6 +105,7 @@
{ "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-plan.ts", "movedFrom": "lib/result-codes.ts (the plan-review fact-check and task-list rows: REALITY receipts, the office-hours probe, gstack-tasks; GSTA-228)" },
+ { "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)" },
@@ -127,6 +128,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)" },
diff --git a/test/helpers/doctor-fixture.ts b/test/helpers/doctor-fixture.ts
index 24d7b00b2..13efda5e9 100644
--- a/test/helpers/doctor-fixture.ts
+++ b/test/helpers/doctor-fixture.ts
@@ -23,7 +23,7 @@ const COPIED = [
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
'bin/gstack-launch-probe.sh',
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
- 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', // the windows bash row
+ 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', // the windows bash row
];
const bases: string[] = [];
/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */
diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts
index adf184d84..1291c94c8 100644
--- a/test/plan-tune-cathedral.test.ts
+++ b/test/plan-tune-cathedral.test.ts
@@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
// These imports are resolved relative to the copied install, not the source checkout.
const libDir = path.join(workDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
- for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts']) {
+ for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts']) {
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
}
diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts
index 7a85dc6dc..379e4d16c 100644
--- a/test/timeline-stop-hook-slug.test.ts
+++ b/test/timeline-stop-hook-slug.test.ts
@@ -35,6 +35,7 @@ const HOOK_FILES = [
'lib/result-codes-budget.ts',
'lib/result-codes-eval.ts',
'lib/result-codes-plan.ts',
+ 'lib/result-codes-autoplan.ts',
];
const SLUG = 'stop-hook-slug-test';
const BUDGET_MS = 2000;