diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 875ea1068..b6d9d4d69 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -57,8 +57,8 @@ line yourself; gstack never writes the plan. ### Commands -- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]` - first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd says otherwise, so --spend-cap binds. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold] [--ceo-only]` + first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd 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`). - `gstack-autoplan next --out [--redispatch ]` later calls: open the next phase or reprint open attempts; never redispatches on its own. - `gstack-autoplan submit --out --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)" },