From 924ac4d406b09cda5ee98550bb15adf13a86545a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 21:13:19 -0800 Subject: [PATCH] v1.92.58.0 feat: gstack-autoplan next --ceo-only runs the CEO review alone (GST-537 PR 2) (#3767) * feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: classify autoplan-ceo-only in the Codex host metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: run autoplan-ceo-only in the marathon lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for /autoplan --ceo-only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: count the autoplan-ceo-only paid file in the shard inventory Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat: gstack-autoplan next --ceo-only runs the CEO review alone and ends reviewed (GST-537) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: budget and close recover a budget-stopped CEO-only run; reviewed is at rest for checkpoints Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: CEO-only finish leaves owner-only cap items undecided, refuses marker phase flags and a plan at the report path, and replaces ceo-review.md atomically Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CEO_ONLY_CONFLICT covers a plan linked to the report path Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: the report-path guard keeps the lexical check, skips safe hard links and compares bigint file ids Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: copy lib/result-codes-autoplan.ts into the fixtures that copy the result-code tables, and keep it out of derived closures Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: the decisions.jsonl status list names auto_kept Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (restore the lib/result-codes-plan.ts ratchet row the base merge dropped) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): CEO-only runs leave main's open Critical/High finding items to the owner (no decision row) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/DRIVER.md | 4 +- bin/gstack-autoplan | 21 +- bin/gstack-doctor-components.sh | 3 + docs/troubleshooting.md | 36 +++ docs/unattended.md | 38 +++- lib/approval-record.ts | 7 +- lib/autoplan-ceo-only.ts | 105 +++++++++ lib/autoplan-checkpoint.ts | 4 +- lib/autoplan-export.ts | 9 +- lib/autoplan-run.ts | 33 +-- lib/autoplan-scope.ts | 2 +- lib/autoplan-state.ts | 2 + lib/headless-artifacts.ts | 8 +- lib/result-codes-autoplan.ts | 26 +++ lib/result-codes.ts | 2 + package.json | 2 +- scripts/pr-dependencies.ts | 2 +- scripts/resolvers/autoplan-driver.ts | 2 +- test/autoplan-ceo-only-driver.test.ts | 303 +++++++++++++++++++++++++ test/fixtures/module-size-ratchet.json | 2 + test/helpers/doctor-fixture.ts | 2 +- test/plan-tune-cathedral.test.ts | 2 +- test/timeline-stop-hook-slug.test.ts | 1 + 26 files changed, 581 insertions(+), 51 deletions(-) create mode 100644 lib/autoplan-ceo-only.ts create mode 100644 lib/result-codes-autoplan.ts create mode 100644 test/autoplan-ceo-only-driver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e5d12f7..e6017509e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.92.58.0] - 2026-11-02 + +**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. An item only the owner can accept (a Critical finding left open when CEO closed deferred) is left undecided, with no chosen option, and the report lists it as left open for the owner. 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` (on the command line or in the plan's scope marker), a scope marker that names `--ceo-only`, or a plan that is the report file `/ceo-review.md` (directly, through a symlink or a hard link) refuses with `CEO_ONLY_CONFLICT`, so the report never overwrites the plan; on a directory that holds a full run, `CEO_ONLY_AFTER_INIT`. `answer`, `recommend`, `revise` and `gstack-approvals import` refuse a CEO-only run with `CEO_ONLY_NO_GATE`. A CEO-only run stopped by `--spend-cap` recovers the usual way: `budget` then `resume`, or `close --deferred`, which ends it `reviewed`. + +### 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. + ## [1.92.57.0] - 2026-11-02 **Hermes can start gstack skills again (GST-882).** On stock Hermes, the Tirith scanner flagged every gstack skill's first command as HIGH ("Nested executable body could not be resolved"), so `gstack-skill-start` was denied in 8 of 8 trials. Tirith-only findings can't go on Hermes's permanent allowlist, so there was no workaround. The start line Tirith couldn't follow was a grouped fallback that picked the script through a variable. diff --git a/VERSION b/VERSION index 49a9e011a..0922d3b4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.57.0 +1.92.58.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 87679d15d..2e1a5e229 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.57.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.58.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index ff551298e..89b7f2782 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 ] [--estimate-usd ] [--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); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--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); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 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`). - `gstack-autoplan run --out --plan --native-model --outside-model --native-runner api --runner api [--max-tokens ] [--spend-cap ]` 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. - `gstack-autoplan next --out [--redispatch ]` diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index faccea3fd..070aa3844 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'; @@ -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` (`-

--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;