From 7cf44db6d84ce82e424a71093b42521ae75b60f6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:19:10 +0000 Subject: [PATCH 01/26] 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> --- autoplan/SKILL.md | 1 + autoplan/sections/ceo-only.md | 41 +++++++ autoplan/sections/ceo-only.md.tmpl | 39 +++++++ autoplan/sections/manifest.json | 6 ++ bin/gstack-ceo-mode-handoff | 10 +- changelog.d/gsta215-ceo-only-skill.md | 7 ++ docs/unattended.md | 39 ++++++- lib/headless-artifacts.ts | 4 +- test/autoplan-ceo-only.test.ts | 118 +++++++++++++++++++++ test/helpers/autoplan-ceo-only-evidence.ts | 95 +++++++++++++++++ test/helpers/carve-guards.ts | 2 +- test/helpers/touchfiles-data.ts | 7 ++ test/skill-e2e-autoplan-ceo-only.test.ts | 108 +++++++++++++++++++ 13 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 autoplan/sections/ceo-only.md create mode 100644 autoplan/sections/ceo-only.md.tmpl create mode 100644 changelog.d/gsta215-ceo-only-skill.md create mode 100644 test/autoplan-ceo-only.test.ts create mode 100644 test/helpers/autoplan-ceo-only-evidence.ts create mode 100644 test/skill-e2e-autoplan-ceo-only.test.ts diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 1614c60ec..9dcdf7751 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -269,6 +269,7 @@ sections. Read a section in full before doing its step; do not work from memory. | closing a review phase, after its reviews finish and before announcing completion or loading the next phase (read afresh at each exit) | `sections/phase-close.md` | | presenting the Final Approval Gate (Phase 4) — the aggregator computes $AGGREGATED_TASKS that the gate message substitutes | `sections/tasks-aggregator.md` | | the preamble echoed `SESSION_KIND: unattended` (read before Phase 1 and at the Phase 4 gate) | `sections/unattended.md` | +| the invocation names `--ceo-only` (read before any preamble instruction block) | `sections/ceo-only.md` | --- diff --git a/autoplan/sections/ceo-only.md b/autoplan/sections/ceo-only.md new file mode 100644 index 000000000..b5df3e232 --- /dev/null +++ b/autoplan/sections/ceo-only.md @@ -0,0 +1,41 @@ + + +## CEO-only run (`--ceo-only`) + +This section governs the whole run and wins over the skeleton. The run reviews +the plan; it never approves it. + +**Zero questions.** Never call AskUserQuestion (native or `mcp__*`) or +ExitPlanMode, never ask the user in prose, never wait for a reply, whatever +`SESSION_KIND` says. A preamble instruction block that would ask takes its +non-consenting default (off, not now, skip). Each decision takes its +`(recommended)` option, else the 6 principles; consent, destructive or +irreversible options take the conservative choice. Log each as a Decision Audit +Trail row. + +**Scope.** Run Phases 0, 0.5 and 1 only; send "Phases 2, 2.5, 3 and 4 skipped +(CEO-only run)" and record it in ACTIVE_PLAN. With `--no-outside`, skip every +outside dispatch: `outside_status: disabled (--no-outside)`. + +**Mode.** Instead of SELECTIVE EXPANSION, use plan-ceo-review 0E step 2's rule +(an explicit mode in the request wins), skip step 3, run +`~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "" --ceo-only --decisions ""` +and send its line verbatim. + +**Never applied:** a user challenge (keep the direction; list it below). +**Skipped and logged `skipped (ceo-only)`:** office-hours and BENEFITS_FROM +offers, 0E's question, plan archive cleanup, Next Steps chaining and CEO plan +promotion; the row satisfies its checklist entry. + +**State.** Write only in `$_AP`, ACTIVE_PLAN and `GSTACK_STATE_ROOT` +(the override; docs/state-root.md). + +**Finish.** After Phase 1 closes, write `$_AP/ceo-review.md`: `# CEO-only +review: `; a line with the mode, outside-voice status and "Not +approved"; then `## User challenges (direction kept)` (plan says, proposal, +why, cost if wrong), `## Scope added`, `## Conservative choices`, +`## Findings` (severity, section, fix, disposition) and `## Decision Audit +Trail` (copied), each "None." when empty. Send the CEO Completion Summary and +both paths, skip Completion's logs, approval and `/ship`, and end with: + +`GSTACK_RESULT: skill=autoplan status=reviewed run=` diff --git a/autoplan/sections/ceo-only.md.tmpl b/autoplan/sections/ceo-only.md.tmpl new file mode 100644 index 000000000..636943ed9 --- /dev/null +++ b/autoplan/sections/ceo-only.md.tmpl @@ -0,0 +1,39 @@ +## CEO-only run (`--ceo-only`) + +This section governs the whole run and wins over the skeleton. The run reviews +the plan; it never approves it. + +**Zero questions.** Never call AskUserQuestion (native or `mcp__*`) or +ExitPlanMode, never ask the user in prose, never wait for a reply, whatever +`SESSION_KIND` says. A preamble instruction block that would ask takes its +non-consenting default (off, not now, skip). Each decision takes its +`(recommended)` option, else the 6 principles; consent, destructive or +irreversible options take the conservative choice. Log each as a Decision Audit +Trail row. + +**Scope.** Run Phases 0, 0.5 and 1 only; send "Phases 2, 2.5, 3 and 4 skipped +(CEO-only run)" and record it in ACTIVE_PLAN. With `--no-outside`, skip every +outside dispatch: `outside_status: disabled (--no-outside)`. + +**Mode.** Instead of SELECTIVE EXPANSION, use plan-ceo-review 0E step 2's rule +(an explicit mode in the request wins), skip step 3, run +`~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "" --ceo-only --decisions ""` +and send its line verbatim. + +**Never applied:** a user challenge (keep the direction; list it below). +**Skipped and logged `skipped (ceo-only)`:** office-hours and BENEFITS_FROM +offers, 0E's question, plan archive cleanup, Next Steps chaining and CEO plan +promotion; the row satisfies its checklist entry. + +**State.** Write only in `$_AP`, ACTIVE_PLAN and `GSTACK_STATE_ROOT` +(the override; docs/state-root.md). + +**Finish.** After Phase 1 closes, write `$_AP/ceo-review.md`: `# CEO-only +review: `; a line with the mode, outside-voice status and "Not +approved"; then `## User challenges (direction kept)` (plan says, proposal, +why, cost if wrong), `## Scope added`, `## Conservative choices`, +`## Findings` (severity, section, fix, disposition) and `## Decision Audit +Trail` (copied), each "None." when empty. Send the CEO Completion Summary and +both paths, skip Completion's logs, approval and `/ship`, and end with: + +`GSTACK_RESULT: skill=autoplan status=reviewed run=` diff --git a/autoplan/sections/manifest.json b/autoplan/sections/manifest.json index 0afec4240..74071ed66 100644 --- a/autoplan/sections/manifest.json +++ b/autoplan/sections/manifest.json @@ -45,6 +45,12 @@ "file": "unattended.md", "title": "Unattended profile: parent-driven next/submit loop, gate answers, export, terminal line", "trigger": "the preamble echoed `SESSION_KIND: unattended` (read before Phase 1 and at the Phase 4 gate)" + }, + { + "id": "ceo-only", + "file": "ceo-only.md", + "title": "CEO-only run: zero questions, CEO phase only, review report, no gate", + "trigger": "the invocation names `--ceo-only` (read before any preamble instruction block)" } ] } diff --git a/bin/gstack-ceo-mode-handoff b/bin/gstack-ceo-mode-handoff index 393f4886a..ccb4c0a52 100755 --- a/bin/gstack-ceo-mode-handoff +++ b/bin/gstack-ceo-mode-handoff @@ -1,10 +1,11 @@ #!/usr/bin/env bash # gstack-ceo-mode-handoff — print /plan-ceo-review's Step 0E mode handoff line. # -# Usage: gstack-ceo-mode-handoff [--auto] [--decisions ""] +# Usage: gstack-ceo-mode-handoff [--auto | --ceo-only] [--decisions ""] # MODE: HOLD SCOPE | SCOPE EXPANSION | SELECTIVE EXPANSION | SCOPE REDUCTION # (underscores accepted for spaces) # --auto the mode came from a saved AUTO_DECIDE preference +# --ceo-only the mode came from 0E's rule in an /autoplan --ceo-only run # --decisions governing approved rows; defaults to "none" # # The handoff used to be chat-only, and the model often composed it in its @@ -15,10 +16,12 @@ set -euo pipefail mode="${1:-}" [ "$#" -gt 0 ] && shift auto=0 +ceo_only=0 decisions="none" while [ "$#" -gt 0 ]; do case "$1" in --auto) auto=1; shift ;; + --ceo-only) ceo_only=1; shift ;; --decisions) [ "$#" -ge 2 ] || { echo "gstack-ceo-mode-handoff: --decisions needs a value" >&2; exit 2; } decisions="$2"; shift 2 ;; @@ -31,11 +34,14 @@ case "$mode" in "HOLD SCOPE"|"SCOPE EXPANSION"|"SELECTIVE EXPANSION"|"SCOPE REDUCTION") ;; *) echo "gstack-ceo-mode-handoff: unknown mode: ${mode:-}" >&2; exit 2 ;; esac +[ "$auto$ceo_only" != 11 ] || { echo "gstack-ceo-mode-handoff: --auto and --ceo-only conflict" >&2; exit 2; } decisions="${decisions//$'\n'/ }" decisions="${decisions%.}" [ -n "${decisions// /}" ] || decisions="none" -if [ "$auto" = 1 ]; then +if [ "$ceo_only" = 1 ]; then + echo "Auto-selected review mode → $mode (CEO-only run: Step 0E's rule). Approved decisions: $decisions." +elif [ "$auto" = 1 ]; then echo "Auto-decided review mode → $mode (your preference). Change with /plan-tune. Approved decisions: $decisions." else echo "Mode: $mode; approved decisions: $decisions." diff --git a/changelog.d/gsta215-ceo-only-skill.md b/changelog.d/gsta215-ceo-only-skill.md new file mode 100644 index 000000000..d1cb14e59 --- /dev/null +++ b/changelog.d/gsta215-ceo-only-skill.md @@ -0,0 +1,7 @@ +**`/autoplan --ceo-only` runs the CEO review by itself and asks nothing from start to finish (GST-537).** Garry asked for a CEO review that "just automatically does the right thing", and the planning eval needed a gstack CEO arm that a harness can run without a simulated user. Before, /autoplan always ran the Eng review too and stopped at a final approval question, and standalone /plan-ceo-review asks for the mode, offers to chain to the next skill and adds a Codex voice. + +### Added +- **`/autoplan --ceo-only`** (`autoplan/sections/ceo-only.md`): runs Phase 0, the outside-reviewer preflight and the CEO phase only. Every question takes its recommendation and gets an audit-trail row; consent and destructive options take the conservative choice. The review mode comes from plan-ceo-review's 0E rule. User challenges are never applied; each one is listed at the top of `/ceo-review.md`, with scope added, conservative choices, findings and the audit trail. The run ends with `GSTACK_RESULT: skill=autoplan status=reviewed run=` and approves nothing. +- **`--no-outside`** with `--ceo-only` skips the outside voice, so one model does the whole review. +- **`gstack-ceo-mode-handoff --ceo-only`** prints `Auto-selected review mode → (CEO-only run: Step 0E's rule)` instead of crediting a saved preference. +- **`status=reviewed`** joins the `GSTACK_RESULT` statuses (`docs/unattended.md`, "CEO-only runs"). diff --git a/docs/unattended.md b/docs/unattended.md index d450cf63e..2abe188ed 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -65,10 +65,12 @@ A run that writes a record ends with one line the parent greps: GSTACK_RESULT: skill= status= run= ``` -`status` is one of `complete`, `gate_pending`, `incomplete`, `interrupted` or -`refused`; `run` is where the run's record is. `/autoplan` +`status` is one of `complete`, `reviewed`, `gate_pending`, `incomplete`, +`interrupted` or `refused`; `run` is where the run's record is. `/autoplan` always ends `gate_pending` when it reaches the final gate, because the gate is -written and never approved. Pure queries (`gstack-artifact schema`, +written and never approved. `/autoplan --ceo-only` ends `reviewed`: it runs the +CEO phase alone, asks nothing, writes no gate and approves nothing (see +[CEO-only runs](#ceo-only-runs)). Pure queries (`gstack-artifact schema`, `gstack-gate render`) print no result line. Not every workflow writes this line or a run directory today: @@ -555,6 +557,37 @@ and `gstack-owner-brief render --captain` opens with "Nothing pending. Carried forward from gate_rev ", followed by any items taken under "do it" without being shown to the owner. +## CEO-only runs + +`/autoplan --ceo-only` runs the CEO review by itself and asks nothing, in any +session kind. 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. +- **How it decides:** every question takes its recommendation and is logged in + the Decision Audit Trail. The review mode comes from plan-ceo-review's 0E + rule (file count, then greenfield, added capability or fix), not autoplan's + fixed SELECTIVE EXPANSION, and the mode line says `CEO-only run`. Consent, + destructive and irreversible options take the conservative choice. +- **What it never does:** apply a user challenge (the plan's direction stays; + each challenge is listed), chain to another skill, promote the CEO plan, or + approve anything. +- **One model:** add `--no-outside` to skip the outside voice, so every + reviewer runs on the host model. +- **Where it writes:** the run directory (`.gstack/tmp/autoplan/` in the + repository), the reviewed plan, and CEO plans and task files under + `GSTACK_STATE_ROOT`. Point that variable inside a trial folder to keep every + write there. +- **What it leaves:** the reviewed plan (its original is kept at the restore + point), `/ceo-review.md` (user challenges, scope added, + conservative choices, findings and the audit trail), and the last line + `GSTACK_RESULT: skill=autoplan status=reviewed run=`. + +`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. + ## Owner brief `gstack-owner-brief render --out ` is what the owner sees at the final diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index dda257f25..c286e294b 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -23,7 +23,7 @@ export type DecisionKind = 'auto' | 'approval' | 'user_challenge'; export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected'; 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' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused'; +export type ResultStatus = 'complete' | 'reviewed' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused'; export type RunStatus = ResultStatus | 'awaiting_result' | 'running'; export interface FindingRow { @@ -117,7 +117,7 @@ export interface ShipReceipt { base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string }; } -export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; +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_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed']; const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed']; diff --git a/test/autoplan-ceo-only.test.ts b/test/autoplan-ceo-only.test.ts new file mode 100644 index 000000000..43c53a57c --- /dev/null +++ b/test/autoplan-ceo-only.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseResult } from '../lib/headless-artifacts'; +import { CEO_REVIEW_HEADINGS, ceoOnlyEvidence, listFiles } from './helpers/autoplan-ceo-only-evidence'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const handoff = (...args: string[]) => spawnSync('bash', [path.join(ROOT, 'bin/gstack-ceo-mode-handoff'), ...args], { encoding: 'utf8', timeout: 10_000 }); + +describe('/autoplan --ceo-only skill text', () => { + const skill = read('autoplan/SKILL.md'); + const section = read('autoplan/sections/ceo-only.md'); + + test('the section index routes --ceo-only to its section before any preamble block', () => { + expect(skill).toContain('| the invocation names `--ceo-only` (read before any preamble instruction block) | `sections/ceo-only.md` |'); + }); + + test('the section forbids every question channel and names the skipped offers', () => { + for (const s of ['Never call AskUserQuestion (native or `mcp__*`) or\nExitPlanMode', 'never ask the user in prose', 'never wait for a reply', + 'office-hours and BENEFITS_FROM', "0E's question", 'Next Steps chaining and CEO plan\npromotion', '**Never applied:** a user challenge']) + expect(section).toContain(s); + }); + + test('the section runs CEO only, honors --no-outside and the state root, and ends status=reviewed', () => { + expect(section).toContain('Run Phases 0, 0.5 and 1 only'); + expect(section).toContain("`outside_status: disabled (--no-outside)`"); + expect(section).toContain('`GSTACK_STATE_ROOT`\n(the override'); + expect(section).toContain('gstack-ceo-mode-handoff "" --ceo-only'); + expect(section).toContain('`GSTACK_RESULT: skill=autoplan status=reviewed run=`'); + for (const h of CEO_REVIEW_HEADINGS) expect(section.replace(/\`\n\`/g, '` `').replace(/\n/g, ' ')).toContain(h.replace(/\n/g, ' ')); + }); +}); + +test('a normal /autoplan run never reads sections/ceo-only.md (the parity raise in carve-guards.ts rests on it)', () => { + const skill = read('autoplan/SKILL.md'); + expect(skill.split('ceo-only.md').length - 1).toBe(1); + expect(skill).toContain('| the invocation names `--ceo-only` (read before any preamble instruction block) | `sections/ceo-only.md` |'); + const others = fs.readdirSync(path.join(ROOT, 'autoplan/sections')).filter(f => f.endsWith('.md') && f !== 'ceo-only.md') + .map(f => `autoplan/sections/${f}`); + const runner = ['bin/gstack-autoplan', 'autoplan/DRIVER.md', ...fs.readdirSync(path.join(ROOT, 'lib')).filter(f => f.startsWith('autoplan-')).map(f => `lib/${f}`)]; + for (const rel of [...others, ...runner]) expect(read(rel), rel).not.toContain('ceo-only.md'); +}); + +describe('gstack-ceo-mode-handoff --ceo-only', () => { + test('names the CEO-only rule, not a saved preference', () => { + const r = handoff('SELECTIVE_EXPANSION', '--ceo-only'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("Auto-selected review mode → SELECTIVE EXPANSION (CEO-only run: Step 0E's rule). Approved decisions: none."); + }); + test('refuses --auto with --ceo-only', () => { + const r = handoff('HOLD SCOPE', '--auto', '--ceo-only'); + expect(r.status).toBe(2); + expect(r.stderr).toContain('--auto and --ceo-only conflict'); + }); +}); + +test('GSTACK_RESULT parses status=reviewed', () => { + expect(parseResult('x\nGSTACK_RESULT: skill=autoplan status=reviewed run=/r')).toEqual({ skill: 'autoplan', status: 'reviewed', run: '/r' }); +}); + +describe('ceo-only evidence: a passing run and each negative control', () => { + const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-evidence-')); + const review = ['# CEO-only review: Pricing tier', '', 'Mode: HOLD SCOPE (ceo-only). Not approved.', '', + '## User challenges (direction kept)', 'None.', '## Scope added', 'None.', '## Conservative choices', 'None.', + '## Findings', '- High: the premise was never checked with developers; interview them first.', + '## Decision Audit Trail', '| # | Phase | Decision |', '|---|---|---|', '| 1 | CEO | Mode HOLD SCOPE |', ''].join('\n'); + fs.writeFileSync(path.join(runDir, 'ceo-review.md'), review); + const assistant = (...content: any[]) => ({ type: 'assistant', message: { content } }); + const user = (...content: any[]) => ({ type: 'user', message: { content } }); + const good = [ + assistant({ type: 'tool_use', id: 't1', name: 'Bash', input: { command: '~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "HOLD SCOPE" --ceo-only --decisions "none"' } }), + user({ type: 'tool_result', tool_use_id: 't1', content: "Auto-selected review mode → HOLD SCOPE (CEO-only run: Step 0E's rule). Approved decisions: none." }), + assistant({ type: 'tool_use', id: 't2', name: 'Agent', input: { prompt: 'native CEO review' } }), + assistant({ type: 'text', text: 'Phases 2, 2.5, 3 and 4 skipped (CEO-only run).' }), + ]; + const output = `Review done.\nGSTACK_RESULT: skill=autoplan status=reviewed run=${runDir}`; + const base = { transcript: good, output, strayWrites: [] as string[], mustMention: [/developer/i, /interview|talk|validat/i] }; + + test('a complete run passes', () => { + const e = ceoOnlyEvidence(base); + expect(e.reasons).toEqual([]); + expect(e.runDir).toBe(runDir); + }); + const fails = (name: string, patch: Partial, reason: RegExp) => test(name, () => { + const e = ceoOnlyEvidence({ ...base, ...patch }); + expect(e.passed).toBe(false); + expect(e.reasons.join('\n')).toMatch(reason); + }); + fails('an AskUserQuestion call fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'q', name: 'AskUserQuestion', input: {} })] }, /asked 1 question/); + fails('an MCP AskUserQuestion variant fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'q', name: 'mcp__conductor__AskUserQuestion', input: {} })] }, /asked 1 question/); + fails('ExitPlanMode fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'q', name: 'ExitPlanMode', input: {} })] }, /tool ExitPlanMode/); + fails('a prose question fails', { output: `Which mode would you like me to use?\n${output}` }, /prose: Which mode would you like/); + fails('a wrong terminal status fails', { output: output.replace('status=reviewed', 'status=gate_pending') }, /status=gate_pending, expected reviewed/); + fails('a missing terminal line fails', { output: 'Review done.' }, /no GSTACK_RESULT line/); + fails('headings without a review fail', { transcript: [assistant({ type: 'text', text: 'Done.' })] }, /no gstack-ceo-mode-handoff[\s\S]*no CEO reviewer subagent/); + fails('loading a skipped phase fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'r', name: 'Read', input: { file_path: '/h/.claude/skills/gstack/autoplan/sections/eng-phase.md' } })] }, /loaded a skipped phase/); + fails('a write outside its roots fails', { strayWrites: ['.config/x.json'] }, /wrote 1 file\(s\) outside its roots, under \.config\/x\.json/); + fails('a review that misses the seeded flaw fails', { mustMention: [/churn/i] }, /never names the seeded flaw/); + test('a missing ceo-review.md fails', () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-empty-')); + const e = ceoOnlyEvidence({ ...base, output: `GSTACK_RESULT: skill=autoplan status=reviewed run=${empty}` }); + expect(e.reasons.join('\n')).toContain('ceo-review.md is missing'); + }); + test('listFiles skips the declared roots', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-home-')); + fs.mkdirSync(path.join(home, 'state')); + fs.writeFileSync(path.join(home, 'state', 'a'), ''); + fs.writeFileSync(path.join(home, 'b'), ''); + fs.mkdirSync(path.join(home, '.bun', 'install', 'cache'), { recursive: true }); + fs.writeFileSync(path.join(home, '.bun', 'install', 'cache', 'c'), ''); + fs.mkdirSync(path.join(home, '.bun', 'bin'), { recursive: true }); + fs.writeFileSync(path.join(home, '.bun', 'bin', 'd'), ''); + expect(listFiles(home, ['state', '.bun/install/cache'])).toEqual(['.bun/bin/d', 'b']); + }); +}); diff --git a/test/helpers/autoplan-ceo-only-evidence.ts b/test/helpers/autoplan-ceo-only-evidence.ts new file mode 100644 index 000000000..3f9bfa9a7 --- /dev/null +++ b/test/helpers/autoplan-ceo-only-evidence.ts @@ -0,0 +1,95 @@ +/** + * Evidence for the /autoplan --ceo-only paid case (test/skill-e2e-autoplan-ceo-only.test.ts): + * the run asked nothing, reviewed the plan through the CEO phase, skipped every + * later phase, wrote ceo-review.md, ended status=reviewed, and wrote nothing in + * HOME outside its declared roots. Pure over the transcript and files the test + * collects, so test/autoplan-ceo-only.test.ts proves each check fails on a + * synthetic negative control before any paid run. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const CEO_REVIEW_HEADINGS = [ + '## User challenges (direction kept)', '## Scope added', '## Conservative choices', '## Findings', '## Decision Audit Trail', +] as const; + +const QUESTION_TOOL = /(^|__)(AskUserQuestion|ExitPlanMode)$/; +const PROSE_QUESTION = /\b(would you like|do you want|should i\b|want me to|shall i\b|which (?:option|mode|one) (?:do|would|should) you|reply with|let me know (?:if|which|whether))/i; +const LATER_PHASE = /autoplan\/sections\/(?:design-phase|dx-phase|eng-phase|tasks-aggregator)\.md$/; +const DISPATCH_TOOL = /^(Agent|Task)$/; + +export interface CeoOnlyEvidenceInput { + transcript: any[]; + /** The session's final result text. */ + output: string; + /** New files under HOME outside the declared roots (the test's before/after diff). */ + strayWrites: string[]; + /** Patterns the reviewed plan or ceo-review.md must contain for the seeded flaw. */ + mustMention: RegExp[]; +} +export interface CeoOnlyEvidence { passed: boolean; reasons: string[]; runDir?: string; questions: string[] } + +function blocks(transcript: any[]): Array<{ role: string; block: any }> { + return transcript.flatMap(event => { + const role = event?.type === 'assistant' ? 'assistant' : event?.type === 'user' ? 'user' : ''; + const content = event?.message?.content; + return role && Array.isArray(content) ? content.map((block: any) => ({ role, block })) : []; + }); +} + +export function ceoOnlyEvidence(input: CeoOnlyEvidenceInput): CeoOnlyEvidence { + const reasons: string[] = []; + const all = blocks(input.transcript); + const uses = all.filter(b => b.role === 'assistant' && b.block?.type === 'tool_use').map(b => b.block); + const texts = all.filter(b => b.role === 'assistant' && b.block?.type === 'text').map(b => String(b.block.text ?? '')); + const results = all.filter(b => b.role === 'user' && b.block?.type === 'tool_result') + .map(b => (typeof b.block.content === 'string' ? b.block.content : JSON.stringify(b.block.content ?? ''))); + + const questions = [ + ...uses.filter(u => QUESTION_TOOL.test(String(u.name))).map(u => `tool ${u.name}`), + ...[...texts, input.output].flatMap(t => t.split('\n').filter(line => line.trim().endsWith('?') && PROSE_QUESTION.test(line)).map(line => `prose: ${line.trim().slice(0, 160)}`)), + ]; + if (questions.length) reasons.push(`asked ${questions.length} question(s): ${questions.join(' | ')}`); + + const handoff = uses.some(u => u.name === 'Bash' && /gstack-ceo-mode-handoff\b/.test(String(u.input?.command)) && /--ceo-only\b/.test(String(u.input?.command))) + && results.some(r => r.includes('(CEO-only run: Step 0E')); + if (!handoff) reasons.push('no gstack-ceo-mode-handoff --ceo-only call with its CEO-only line'); + if (!uses.some(u => DISPATCH_TOOL.test(String(u.name)))) reasons.push('no CEO reviewer subagent was dispatched'); + const later = uses.filter(u => u.name === 'Read' && LATER_PHASE.test(String(u.input?.file_path ?? ''))); + if (later.length) reasons.push(`loaded a skipped phase: ${later.map(u => u.input.file_path).join(', ')}`); + + const result = /^GSTACK_RESULT: skill=autoplan status=(\S+) run=(.+)$/m.exec(input.output.split('\n').filter(l => l.startsWith('GSTACK_RESULT: ')).pop() ?? ''); + if (!result) reasons.push('no GSTACK_RESULT line for autoplan in the final output'); + else if (result[1] !== 'reviewed') reasons.push(`GSTACK_RESULT status=${result[1]}, expected reviewed`); + const runDir = result?.[2]?.trim(); + const reviewFile = runDir ? path.join(runDir, 'ceo-review.md') : ''; + const review = reviewFile && fs.existsSync(reviewFile) ? fs.readFileSync(reviewFile, 'utf8') : undefined; + if (runDir && review === undefined) reasons.push(`${reviewFile} is missing`); + if (review !== undefined) { + const missing = CEO_REVIEW_HEADINGS.filter(h => !review.includes(h)); + if (missing.length) reasons.push(`ceo-review.md lacks ${missing.join(', ')}`); + if (!/\|\s*\d+\s*\|/.test(review.slice(review.indexOf('## Decision Audit Trail')))) reasons.push('ceo-review.md has no Decision Audit Trail rows'); + const unmet = input.mustMention.filter(re => !re.test(review)); + if (unmet.length) reasons.push(`ceo-review.md never names the seeded flaw (${unmet.map(String).join(', ')})`); + } + if (input.strayWrites.length) { + const dirs = [...new Set(input.strayWrites.map(f => f.split('/').slice(0, 2).join('/')))]; + reasons.push(`wrote ${input.strayWrites.length} file(s) outside its roots, under ${dirs.join(', ')}: ${input.strayWrites.slice(0, 5).join(', ')}`); + } + return { passed: reasons.length === 0, reasons, runDir, questions }; +} + +/** Every file under root, relative, skipping the given relative prefixes. */ +export function listFiles(root: string, skip: string[] = []): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + const rel = path.relative(root, abs); + if (skip.some(s => rel === s || rel.startsWith(s + path.sep))) continue; + if (entry.isDirectory()) walk(abs); else out.push(rel); + } + }; + if (fs.existsSync(root)) walk(root); + return out.sort(); +} diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index cc4ee6140..bd0b464b2 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -583,7 +583,7 @@ do not launch the downstream skill or open a browser.`, maxSkeletonBytes: 48_175, // GSTA-96 hoist (2026-10-26): lowered from 70_000 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 4,888 bytes; measured 43,287. Before: Phase-specific outside coverage, native fallback, and harness guard. minUnionBytes: 85_000, // measured union 86,926 mustContain: ['6 Decision Principles', 'TASTE DECISION', 'USER CHALLENGE', 'consensus', 'Restore Point'], - maxSizeRatio: 1.1511, // Four validated outside invocations replace raw CLI calls; phases keep independent coverage. + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 114,297 / 101,979 = 1.1208 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 114,533 / 101,979 = 1.1231 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site, and phase-close publishing each report with the `true autoplan-published ` no-op before the next phase entry in a later message (CEO-1); measured 114,320 / 101,979 = 1.1210 (2026-10-07). Merged with the guard area: measured 115,149 / 101,979 = 1.1291 (2026-10-07). + Oct 8 wave (permission cards after a background reviewer): Step 1 puts RESTORE_PATH, and so every phase artifact, in the project's git-excluded `.gstack/tmp/autoplan/`, which Claude Code reads without a card after the skill turn ends; measured 115,270 / 101,979 = 1.1303 (2026-10-08). + fix wave PR D: the prerequisite offer is skipped in spawned/headless sessions (#1958); measured 115,267 / 101,979 = 1.1303 (2026-10-07). Both merged: measured 115,126 / 101,979 = 1.1289 (2026-10-08). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave B9/B10/B12: Phase 0 ESTIMATE and the phase-close timing record, the final gate as a gate list (`gstack-gate render/parse`, stable ids, gate_rev) and the URGENT block above the summary, offset by tightening the Phase 4 prose and the tasks-section; measured 114,980 / 101,979 = 1.1275 (2026-10-10). + Oct 10 multi-agent wave PR D1 (reality rows): the reality-rows check line in the CEO and Eng phase files, the Pre-Gate rule (unattended miss is `incomplete`, never a warning) and the two Pre-Gate cells; measured 117,042 / 101,979 = 1.1477 (2026-10-10). + GSTA-96 shared-preamble hoist (owner-approved raise, Garry 2026-10-25: "do it"): the union now holds sections/preamble.md, so a fresh invocation reads the pointer plus the shared file; measured 117,388 / 101,979 = 1.1511 (2026-10-25). Before: 1.149. + maxSizeRatio: 1.1723, // GST-537 /autoplan --ceo-only (GStack Minder approved A1, 2026-10-31): sections/ceo-only.md (2,168 B) plus its Section index row, read only when the invocation names --ceo-only (test/autoplan-ceo-only.test.ts pins that a normal run never reads it); measured 119,545 / 101,979 = 1.1723 (2026-10-31), +2,276 B over main's 117,269 = 1.1499. Before: 1.1511. Four validated outside invocations replace raw CLI calls; phases keep independent coverage. + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 114,297 / 101,979 = 1.1208 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 114,533 / 101,979 = 1.1231 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site, and phase-close publishing each report with the `true autoplan-published ` no-op before the next phase entry in a later message (CEO-1); measured 114,320 / 101,979 = 1.1210 (2026-10-07). Merged with the guard area: measured 115,149 / 101,979 = 1.1291 (2026-10-07). + Oct 8 wave (permission cards after a background reviewer): Step 1 puts RESTORE_PATH, and so every phase artifact, in the project's git-excluded `.gstack/tmp/autoplan/`, which Claude Code reads without a card after the skill turn ends; measured 115,270 / 101,979 = 1.1303 (2026-10-08). + fix wave PR D: the prerequisite offer is skipped in spawned/headless sessions (#1958); measured 115,267 / 101,979 = 1.1303 (2026-10-07). Both merged: measured 115,126 / 101,979 = 1.1289 (2026-10-08). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave B9/B10/B12: Phase 0 ESTIMATE and the phase-close timing record, the final gate as a gate list (`gstack-gate render/parse`, stable ids, gate_rev) and the URGENT block above the summary, offset by tightening the Phase 4 prose and the tasks-section; measured 114,980 / 101,979 = 1.1275 (2026-10-10). + Oct 10 multi-agent wave PR D1 (reality rows): the reality-rows check line in the CEO and Eng phase files, the Pre-Gate rule (unattended miss is `incomplete`, never a warning) and the two Pre-Gate cells; measured 117,042 / 101,979 = 1.1477 (2026-10-10). + GSTA-96 shared-preamble hoist (owner-approved raise, Garry 2026-10-25: "do it"): the union now holds sections/preamble.md, so a fresh invocation reads the pointer plus the shared file; measured 117,388 / 101,979 = 1.1511 (2026-10-25). Before: 1.149. }, spec: { skill: 'spec', diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 3079ace6a..2469b7d1f 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -913,6 +913,11 @@ export const E2E_TOUCHFILES: Record = { 'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'lib/claude-journal-records.ts', 'lib/claude-owned-journal.ts', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json', 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'bin/gstack-ceo-plan-find', 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], + // /autoplan --ceo-only: zero questions through the CEO phase (GST-537) + 'autoplan-ceo-only': ['autoplan/SKILL.md.tmpl', 'autoplan/sections/ceo-only.md.tmpl', 'autoplan/sections/ceo-phase.md.tmpl', + 'autoplan/sections/manifest.json', 'bin/gstack-ceo-mode-handoff', 'test/helpers/autoplan-ceo-only-evidence.ts', + 'test/skill-e2e-autoplan-ceo-only.test.ts', 'test/fixtures/forcing-finding-seeds.ts'], + // Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs 'benchmark-providers-live': ['bin/gstack-model-benchmark', 'test/helpers/providers/**', 'test/helpers/benchmark-runner.ts', 'test/helpers/pricing.ts', 'test/skill-e2e-benchmark-providers.test.ts'], @@ -1568,6 +1573,7 @@ export const E2E_TIERS: Record = { // Autoplan — periodic (not yet implemented) 'autoplan-dual-voice': 'periodic', + 'autoplan-ceo-only': 'gate', // Multi-provider benchmark — periodic (requires external CLIs + auth, paid) 'benchmark-providers-live': 'periodic', @@ -1977,6 +1983,7 @@ export const E2E_KINDS: Record = { 'benchmark-workflow': 'rule', 'setup-deploy-workflow': 'rule', 'autoplan-dual-voice': 'rule', + 'autoplan-ceo-only': 'rule', 'benchmark-providers-live': 'rule', 'scrape-match-path': 'behavior', 'scrape-prototype-path': 'behavior', diff --git a/test/skill-e2e-autoplan-ceo-only.test.ts b/test/skill-e2e-autoplan-ceo-only.test.ts new file mode 100644 index 000000000..f0d251529 --- /dev/null +++ b/test/skill-e2e-autoplan-ceo-only.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect, beforeEach, afterEach, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ceoOnlyEvidence, listFiles } from './helpers/autoplan-ceo-only-evidence'; +import { PTY_LONG_MS } from './helpers/eval-budgets'; +import { runSkillTest } from './helpers/session-runner'; +import { buildSeedConfig, seedHermeticGstackHome, seedHermeticRuntimeView } from './helpers/hermetic-env'; +import { FORCING_FLOOR_CEO } from './fixtures/forcing-finding-seeds'; +import { + ROOT, runId, evalsEnabled, describeIfSelected, logCost, recordE2E, copyDirSync, createEvalCollector, finalizeEvalCollector, +} from './helpers/e2e-helpers'; + +// /autoplan --ceo-only must finish a CEO review without asking anything (GST-537). +// The seed is the plan that makes interactive /plan-ceo-review ask a question +// (test/skill-e2e-plan-ceo-finding-floor.test.ts is the control on the same seed). +// Every check is a pure function over the transcript and files, +// proven against negative controls in test/autoplan-ceo-only.test.ts. + +const evalCollector = createEvalCollector('e2e-autoplan-ceo-only'); +// Declared roots in the isolated HOME: the state root, the host's own config and temp, and +// Bun's package cache (the runtime writes it under HOME on first use; run gsta215-e2e-2). +const OWNED_HOME_ROOTS = ['gstack-home', '.claude', 'tmp', '.bun/install/cache', '.capy/git-shim.log']; + +describeIfSelected('Autoplan CEO-only E2E', ['autoplan-ceo-only'], () => { + let workDir = ''; + let stateDir = ''; + let env: Record = {}; + let homeBefore: string[] = []; + + const cleanup = () => { + for (const dir of [workDir, stateDir]) if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }; + + beforeEach(() => { + try { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-autoplan-ceo-only-')); + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-autoplan-ceo-only-state-')); + const configDir = path.join(stateDir, '.claude'); + const gstackHome = path.join(stateDir, 'gstack-home'); + const tempDir = path.join(stateDir, 'tmp'); + fs.mkdirSync(configDir); + fs.mkdirSync(gstackHome); + fs.mkdirSync(tempDir, { mode: 0o700 }); + fs.writeFileSync(path.join(configDir, '.claude.json'), JSON.stringify(buildSeedConfig({ + apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY, trustedDirs: [workDir], + })), { mode: 0o600 }); + seedHermeticGstackHome(gstackHome); + fs.mkdirSync(path.join(configDir, 'skills')); + seedHermeticRuntimeView(ROOT, path.join(configDir, 'skills', 'gstack')); + env = { HOME: stateDir, CLAUDE_CONFIG_DIR: configDir, GSTACK_HOME: gstackHome, GSTACK_STATE_ROOT: gstackHome, + TMPDIR: tempDir, TEMP: tempDir, TMP: tempDir }; + + const git = (...args: string[]) => { + const r = spawnSync('git', args, { cwd: workDir, encoding: 'utf8', timeout: 10_000 }); + if (r.error || r.status !== 0) throw new Error(`CEO-only fixture setup failed: ${r.error?.message ?? r.stderr}`); + }; + git('init', '-b', 'main'); + git('config', 'user.email', 'test@test.com'); + git('config', 'user.name', 'Test'); + // The seed's first line asks for a /tmp plan copy; the plan itself is the rest. + fs.writeFileSync(path.join(workDir, 'PLAN.md'), FORCING_FLOOR_CEO.split('\n').slice(2).join('\n') + '\n'); + fs.writeFileSync(path.join(workDir, 'README.md'), '# Acme API\n\nA paid HTTP API with one pricing tier.\n'); + git('add', '.'); + git('-c', 'commit.gpgsign=false', 'commit', '--no-verify', '-m', 'Plan'); + for (const skill of ['autoplan', 'plan-ceo-review']) { + const dest = path.join(workDir, '.claude', 'skills', skill); + fs.mkdirSync(dest, { recursive: true }); + fs.copyFileSync(path.join(ROOT, skill, 'SKILL.md'), path.join(dest, 'SKILL.md')); + copyDirSync(path.join(ROOT, skill, 'sections'), path.join(dest, 'sections')); + } + homeBefore = listFiles(stateDir, OWNED_HOME_ROOTS); + } catch (error) { + cleanup(); + throw error; + } + }); + + afterEach(cleanup); + afterAll(() => finalizeEvalCollector(evalCollector)); + + // Titled by its E2E id so scripts/lib/paid-cases.ts needs no CASE_TEST_NAMES entry. + test.skipIf(!evalsEnabled)('autoplan-ceo-only', async () => { + const result = await runSkillTest({ + testName: 'autoplan-ceo-only', + workingDirectory: workDir, + env, + prompt: '/autoplan --ceo-only --no-outside\n\nReview the plan in PLAN.md.', + timeout: PTY_LONG_MS, + allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Agent', 'Skill'], + maxTurns: 200, + runId, + }); + const before = new Set(homeBefore); + const evidence = ceoOnlyEvidence({ + transcript: Array.isArray(result.transcript) ? result.transcript : [], + output: result.output, + strayWrites: listFiles(stateDir, OWNED_HOME_ROOTS).filter(f => !before.has(f)), + mustMention: [/developer/i, /talk|interview|validat|evidence|research/i], + }); + logCost('autoplan-ceo-only', result); + recordE2E(evalCollector, 'autoplan-ceo-only', 'Autoplan CEO-only E2E', result, { + passed: evidence.passed, ...(evidence.reasons.length ? { error: evidence.reasons.join('; ') } : {}), + }); + expect(evidence.reasons).toEqual([]); + }, PTY_LONG_MS + 60_000); +}); From 8f967037213dc688125a1fbe38746f8312c3d916 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:31:35 +0000 Subject: [PATCH 02/26] fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/unattended.md | 6 ++++-- test/autoplan-ceo-only.test.ts | 15 ++++++++++----- test/helpers/autoplan-ceo-only-evidence.ts | 7 ++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/unattended.md b/docs/unattended.md index 2abe188ed..ebf7fbf29 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -559,8 +559,10 @@ without being shown to the owner. ## CEO-only runs -`/autoplan --ceo-only` runs the CEO review by itself and asks nothing, in any -session kind. It is for a harness or a crew that wants the strategy pass +`/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 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. diff --git a/test/autoplan-ceo-only.test.ts b/test/autoplan-ceo-only.test.ts index 43c53a57c..c301089a1 100644 --- a/test/autoplan-ceo-only.test.ts +++ b/test/autoplan-ceo-only.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -62,7 +62,10 @@ test('GSTACK_RESULT parses status=reviewed', () => { }); describe('ceo-only evidence: a passing run and each negative control', () => { - const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-evidence-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-evidence-')); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + const runDir = path.join(root, 'run'); + fs.mkdirSync(runDir); const review = ['# CEO-only review: Pricing tier', '', 'Mode: HOLD SCOPE (ceo-only). Not approved.', '', '## User challenges (direction kept)', 'None.', '## Scope added', 'None.', '## Conservative choices', 'None.', '## Findings', '- High: the premise was never checked with developers; interview them first.', @@ -97,16 +100,18 @@ describe('ceo-only evidence: a passing run and each negative control', () => { fails('a missing terminal line fails', { output: 'Review done.' }, /no GSTACK_RESULT line/); fails('headings without a review fail', { transcript: [assistant({ type: 'text', text: 'Done.' })] }, /no gstack-ceo-mode-handoff[\s\S]*no CEO reviewer subagent/); fails('loading a skipped phase fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'r', name: 'Read', input: { file_path: '/h/.claude/skills/gstack/autoplan/sections/eng-phase.md' } })] }, /loaded a skipped phase/); + fails('loading a skipped phase through Bash fails', { transcript: [...good, assistant({ type: 'tool_use', id: 'b', name: 'Bash', input: { command: 'cat /h/.claude/skills/gstack/autoplan/sections/dx-phase.md' } })] }, /loaded a skipped phase: cat /); fails('a write outside its roots fails', { strayWrites: ['.config/x.json'] }, /wrote 1 file\(s\) outside its roots, under \.config\/x\.json/); fails('a review that misses the seeded flaw fails', { mustMention: [/churn/i] }, /never names the seeded flaw/); test('a missing ceo-review.md fails', () => { - const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-empty-')); + const empty = path.join(root, 'empty'); + fs.mkdirSync(empty); const e = ceoOnlyEvidence({ ...base, output: `GSTACK_RESULT: skill=autoplan status=reviewed run=${empty}` }); expect(e.reasons.join('\n')).toContain('ceo-review.md is missing'); }); test('listFiles skips the declared roots', () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-only-home-')); - fs.mkdirSync(path.join(home, 'state')); + const home = path.join(root, 'home'); + fs.mkdirSync(path.join(home, 'state'), { recursive: true }); fs.writeFileSync(path.join(home, 'state', 'a'), ''); fs.writeFileSync(path.join(home, 'b'), ''); fs.mkdirSync(path.join(home, '.bun', 'install', 'cache'), { recursive: true }); diff --git a/test/helpers/autoplan-ceo-only-evidence.ts b/test/helpers/autoplan-ceo-only-evidence.ts index 3f9bfa9a7..224cbac22 100644 --- a/test/helpers/autoplan-ceo-only-evidence.ts +++ b/test/helpers/autoplan-ceo-only-evidence.ts @@ -15,7 +15,7 @@ export const CEO_REVIEW_HEADINGS = [ const QUESTION_TOOL = /(^|__)(AskUserQuestion|ExitPlanMode)$/; const PROSE_QUESTION = /\b(would you like|do you want|should i\b|want me to|shall i\b|which (?:option|mode|one) (?:do|would|should) you|reply with|let me know (?:if|which|whether))/i; -const LATER_PHASE = /autoplan\/sections\/(?:design-phase|dx-phase|eng-phase|tasks-aggregator)\.md$/; +const LATER_PHASE = /autoplan\/sections\/(?:design-phase|dx-phase|eng-phase|tasks-aggregator)\.md/; const DISPATCH_TOOL = /^(Agent|Task)$/; export interface CeoOnlyEvidenceInput { @@ -55,8 +55,9 @@ export function ceoOnlyEvidence(input: CeoOnlyEvidenceInput): CeoOnlyEvidence { && results.some(r => r.includes('(CEO-only run: Step 0E')); if (!handoff) reasons.push('no gstack-ceo-mode-handoff --ceo-only call with its CEO-only line'); if (!uses.some(u => DISPATCH_TOOL.test(String(u.name)))) reasons.push('no CEO reviewer subagent was dispatched'); - const later = uses.filter(u => u.name === 'Read' && LATER_PHASE.test(String(u.input?.file_path ?? ''))); - if (later.length) reasons.push(`loaded a skipped phase: ${later.map(u => u.input.file_path).join(', ')}`); + const later = uses.map(u => String(u.name === 'Bash' ? u.input?.command : (u.input?.file_path ?? u.input?.path ?? u.input?.pattern ?? ''))) + .filter(arg => LATER_PHASE.test(arg)); + if (later.length) reasons.push(`loaded a skipped phase: ${later.map(arg => arg.slice(0, 160)).join(', ')}`); const result = /^GSTACK_RESULT: skill=autoplan status=(\S+) run=(.+)$/m.exec(input.output.split('\n').filter(l => l.startsWith('GSTACK_RESULT: ')).pop() ?? ''); if (!result) reasons.push('no GSTACK_RESULT line for autoplan in the final output'); From 45352544dfa87ce5f86bc5f574c6228a3729a4c1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:33:38 +0000 Subject: [PATCH 03/26] test: classify autoplan-ceo-only in the Codex host metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/codex-host.test.ts | 4 ++-- test/helpers/e2e-hosts.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/codex-host.test.ts b/test/codex-host.test.ts index e41e55ef3..c25f0aea3 100644 --- a/test/codex-host.test.ts +++ b/test/codex-host.test.ts @@ -128,13 +128,13 @@ describe('per-case host metadata', () => { for (const id of Object.keys(E2E_HOSTS)) expect(Object.hasOwn(E2E_TOUCHFILES, id), id).toBe(true); }); - test('classifies 52 adapter-only, 34 Claude-specific, 26 rework and 1 host-neutral case', () => { + test('classifies 52 adapter-only, 35 Claude-specific, 26 rework and 1 host-neutral case', () => { const counts: Record = {}; for (const support of Object.values(E2E_HOSTS)) counts[support] = (counts[support] ?? 0) + 1; expect(counts.both).toBe(52); expect(counts.rework).toBe(26); expect(counts['host-neutral']).toBe(1); - expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(34); + expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(35); }); }); diff --git a/test/helpers/e2e-hosts.ts b/test/helpers/e2e-hosts.ts index ed2997e46..f188f0f72 100644 --- a/test/helpers/e2e-hosts.ts +++ b/test/helpers/e2e-hosts.ts @@ -97,7 +97,7 @@ const CLAUDE_ONLY: Readonly> = { 'shared-libs-review-index-flags', 'shared-libs-review-lifecycle', 'shared-libs-review-path-eligibility', 'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-frozen-scope-convergence', 'ship-local-hook-preservation', 'ship-managed-hook-refresh', 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent'], - subagents: ['docsync-spawned', 'review-army-perf-n-plus-one'], + subagents: ['autoplan-ceo-only', 'docsync-spawned', 'review-army-perf-n-plus-one'], 'skill-tool': ['router-failure-contradicted-cause', 'router-failure-triage-resolves', 'router-failure-triage-unclear', 'router-failure-verified-cause', 'skillify-approval-reject', 'skillify-happy-path', 'skillify-provenance-refusal'], hermetic: ['hermetic-canary', 'hermetic-sentinel'], From 81863772a7a6d15302f67af0632605be834753a3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:41:32 +0000 Subject: [PATCH 04/26] 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> --- docs/unattended.md | 3 ++- test/helpers/touchfiles-data.ts | 5 +++-- test/touchfiles.test.ts | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/unattended.md b/docs/unattended.md index ebf7fbf29..765d86527 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -562,7 +562,8 @@ without being shown to the owner. `/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 is for a harness or a crew that wants the strategy pass +`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. diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 2469b7d1f..edc1f883d 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -914,8 +914,9 @@ export const E2E_TOUCHFILES: Record = { 'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'bin/gstack-ceo-plan-find', 'plan-ceo-review/**', 'ETHOS.md', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'], // /autoplan --ceo-only: zero questions through the CEO phase (GST-537) - 'autoplan-ceo-only': ['autoplan/SKILL.md.tmpl', 'autoplan/sections/ceo-only.md.tmpl', 'autoplan/sections/ceo-phase.md.tmpl', - 'autoplan/sections/manifest.json', 'bin/gstack-ceo-mode-handoff', 'test/helpers/autoplan-ceo-only-evidence.ts', + 'autoplan-ceo-only': ['autoplan/**', 'plan-ceo-review/**', 'ETHOS.md', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble-hoist.ts', + 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-state-root.sh', 'lib/state-root.ts', + 'lib/headless-artifacts.ts', 'bin/gstack-ceo-mode-handoff', 'test/helpers/autoplan-ceo-only-evidence.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-autoplan-ceo-only.test.ts', 'test/fixtures/forcing-finding-seeds.ts'], // Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs diff --git a/test/touchfiles.test.ts b/test/touchfiles.test.ts index 2b4751774..7bd042652 100644 --- a/test/touchfiles.test.ts +++ b/test/touchfiles.test.ts @@ -310,8 +310,10 @@ describe('selectTests', () => { // The formerly keyless periodic AUQ probes drive the CEO skill too. expect(result.selected).toContain('auq-consistency'); expect(result.selected).toContain('auq-verbose-vs-carved-ab'); - expect(result.selected.length).toBe(21); - expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 21); + // /autoplan --ceo-only runs plan-ceo-review's Phase 1 and its 0E mode rule. + expect(result.selected).toContain('autoplan-ceo-only'); + expect(result.selected.length).toBe(22); + expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 22); }); test('global touchfile triggers ALL tests', () => { From 303bdc68a3f640844fe14acdf707641ecbc6403b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:57:30 +0000 Subject: [PATCH 05/26] test: run autoplan-ceo-only in the marathon lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/codex-host.test.ts | 4 ++-- test/helpers/e2e-hosts.ts | 2 +- test/helpers/touchfiles-data.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/codex-host.test.ts b/test/codex-host.test.ts index c25f0aea3..e41e55ef3 100644 --- a/test/codex-host.test.ts +++ b/test/codex-host.test.ts @@ -128,13 +128,13 @@ describe('per-case host metadata', () => { for (const id of Object.keys(E2E_HOSTS)) expect(Object.hasOwn(E2E_TOUCHFILES, id), id).toBe(true); }); - test('classifies 52 adapter-only, 35 Claude-specific, 26 rework and 1 host-neutral case', () => { + test('classifies 52 adapter-only, 34 Claude-specific, 26 rework and 1 host-neutral case', () => { const counts: Record = {}; for (const support of Object.values(E2E_HOSTS)) counts[support] = (counts[support] ?? 0) + 1; expect(counts.both).toBe(52); expect(counts.rework).toBe(26); expect(counts['host-neutral']).toBe(1); - expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(35); + expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(34); }); }); diff --git a/test/helpers/e2e-hosts.ts b/test/helpers/e2e-hosts.ts index f188f0f72..ed2997e46 100644 --- a/test/helpers/e2e-hosts.ts +++ b/test/helpers/e2e-hosts.ts @@ -97,7 +97,7 @@ const CLAUDE_ONLY: Readonly> = { 'shared-libs-review-index-flags', 'shared-libs-review-lifecycle', 'shared-libs-review-path-eligibility', 'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-frozen-scope-convergence', 'ship-local-hook-preservation', 'ship-managed-hook-refresh', 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent'], - subagents: ['autoplan-ceo-only', 'docsync-spawned', 'review-army-perf-n-plus-one'], + subagents: ['docsync-spawned', 'review-army-perf-n-plus-one'], 'skill-tool': ['router-failure-contradicted-cause', 'router-failure-triage-resolves', 'router-failure-triage-unclear', 'router-failure-verified-cause', 'skillify-approval-reject', 'skillify-happy-path', 'skillify-provenance-refusal'], hermetic: ['hermetic-canary', 'hermetic-sentinel'], diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index edc1f883d..86eed2186 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -1574,7 +1574,7 @@ export const E2E_TIERS: Record = { // Autoplan — periodic (not yet implemented) 'autoplan-dual-voice': 'periodic', - 'autoplan-ceo-only': 'gate', + 'autoplan-ceo-only': 'marathon', // Full /autoplan --ceo-only CEO review (349–984 s over 6 runs, GST-537); the gate (I5) and periodic lanes are at their caps // Multi-provider benchmark — periodic (requires external CLIs + auth, paid) 'benchmark-providers-live': 'periodic', From ce100cbbe7b72cc7bc083f85cf02e0183e9bf6a4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 20:06:43 +0000 Subject: [PATCH 06/26] docs: changelog entry for /autoplan --ceo-only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta215-ceo-only-skill.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog.d/gsta215-ceo-only-skill.md b/changelog.d/gsta215-ceo-only-skill.md index d1cb14e59..f111cd8fc 100644 --- a/changelog.d/gsta215-ceo-only-skill.md +++ b/changelog.d/gsta215-ceo-only-skill.md @@ -5,3 +5,6 @@ - **`--no-outside`** with `--ceo-only` skips the outside voice, so one model does the whole review. - **`gstack-ceo-mode-handoff --ceo-only`** prints `Auto-selected review mode → (CEO-only run: Step 0E's rule)` instead of crediting a saved preference. - **`status=reviewed`** joins the `GSTACK_RESULT` statuses (`docs/unattended.md`, "CEO-only runs"). + +### For contributors +- The paid case `autoplan-ceo-only` fails if the mode asks anything, skips a later phase's file, or leaves new files in HOME outside its folders. It takes 6 to 16 minutes, so it runs in the weekly marathon lane, not on autoplan PRs. The mode needs Claude Code for now. From e56602051aa74dba4523e28b3d741d8469d57770 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 20:54:53 +0000 Subject: [PATCH 07/26] 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> --- test/eng-finding-retry-budget.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/eng-finding-retry-budget.test.ts b/test/eng-finding-retry-budget.test.ts index c390233f7..174ace0db 100644 --- a/test/eng-finding-retry-budget.test.ts +++ b/test/eng-finding-retry-budget.test.ts @@ -141,7 +141,7 @@ test('live periodic census fits the declared CI wall including setup', () => { test('registered allocation is deterministic and preserves every discovered file', () => { const files = collectPaidTestFiles(); - expect(files).toHaveLength(113); // + skill-e2e-model-panel (GSTA-173); + skill-e2e-write-skill (GSTA-133); + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); + expect(files).toHaveLength(114); // + skill-e2e-autoplan-ceo-only (GST-537); + skill-e2e-model-panel (GSTA-173); + skill-e2e-write-skill (GSTA-133); + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); expect(files).toContain('test/skill-e2e-ship-skip.test.ts'); const m = livePlan(files); expect(livePlan([...files].reverse())).toEqual(m); From 483d84c5bd2b0e3fa36495150af494834589e3c6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 22:12:04 +0000 Subject: [PATCH 08/26] 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> --- autoplan/DRIVER.md | 4 +- bin/gstack-autoplan | 23 ++-- bin/gstack-doctor-components.sh | 3 + changelog.d/gsta215-ceo-only-driver.md | 9 ++ docs/troubleshooting.md | 33 ++++++ docs/unattended.md | 30 ++++-- lib/approval-record.ts | 7 +- lib/autoplan-ceo-only.ts | 90 ++++++++++++++++ lib/autoplan-export.ts | 9 +- lib/autoplan-run.ts | 33 +++--- lib/autoplan-state.ts | 2 + lib/headless-artifacts.ts | 8 +- lib/result-codes-autoplan.ts | 26 +++++ lib/result-codes.ts | 2 + scripts/resolvers/autoplan-driver.ts | 2 +- test/autoplan-ceo-only-driver.test.ts | 141 +++++++++++++++++++++++++ test/fixtures/module-size-ratchet.json | 2 + 17 files changed, 383 insertions(+), 41 deletions(-) create mode 100644 changelog.d/gsta215-ceo-only-driver.md 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/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)" }, From 42dbaed9e08161a1d4b9bac8873481cd11aa1a4c Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 22:24:19 +0000 Subject: [PATCH 09/26] 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> --- bin/gstack-autoplan | 2 - changelog.d/gsta215-ceo-only-driver.md | 2 +- docs/troubleshooting.md | 7 ++-- docs/unattended.md | 8 ++-- lib/autoplan-ceo-only.ts | 5 ++- lib/autoplan-checkpoint.ts | 4 +- lib/result-codes-autoplan.ts | 2 +- test/autoplan-ceo-only-driver.test.ts | 51 ++++++++++++++++++++++++-- 8 files changed, 64 insertions(+), 17 deletions(-) diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 39873dbb9..61f992347 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -266,7 +266,6 @@ 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; @@ -297,7 +296,6 @@ 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 }); diff --git a/changelog.d/gsta215-ceo-only-driver.md b/changelog.d/gsta215-ceo-only-driver.md index 888e531a8..42f84621e 100644 --- a/changelog.d/gsta215-ceo-only-driver.md +++ b/changelog.d/gsta215-ceo-only-driver.md @@ -3,7 +3,7 @@ ### 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`. +- **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` 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7914ed890..c140a0333 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3126,10 +3126,11 @@ review run. A run keeps the mode it started with. ### `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 +**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. +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 diff --git a/docs/unattended.md b/docs/unattended.md index 6d01d6faa..ab850ef2f 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -605,9 +605,11 @@ approves, so `gstack-artifact validate` accepts the run as it stands. `--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. +- `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 diff --git a/lib/autoplan-ceo-only.ts b/lib/autoplan-ceo-only.ts index 9adc592f8..77fd103f5 100644 --- a/lib/autoplan-ceo-only.ts +++ b/lib/autoplan-ceo-only.ts @@ -10,7 +10,8 @@ * 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). + * 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 fs from 'node:fs'; import * as path from 'node:path'; @@ -41,7 +42,7 @@ export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefi return true; } -/** answer, recommend, revise, close and budget need a gate or an approvable run: a CEO-only run has neither. */ +/** 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); } 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/result-codes-autoplan.ts b/lib/result-codes-autoplan.ts index 3e12c3a6c..b03d5f835 100644 --- a/lib/result-codes-autoplan.ts +++ b/lib/result-codes-autoplan.ts @@ -20,7 +20,7 @@ export const AUTOPLAN_RESULT_CODES = { }, 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', + 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/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts index ccda4eef5..533f68ecc 100644 --- a/test/autoplan-ceo-only-driver.test.ts +++ b/test/autoplan-ceo-only-driver.test.ts @@ -86,11 +86,10 @@ describe('next --ceo-only runs the CEO phase alone and ends reviewed', () => { expect(fs.existsSync(path.join(out, 'gate.json'))).toBe(false); }); - test('answer, recommend, revise, close and budget refuse CEO_ONLY_NO_GATE', () => { + 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], - ['close', '--phase', 'ceo', '--deferred', 'x', '--by', 'human'], ['budget', '--spend-cap', '5', '--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)'); @@ -108,6 +107,52 @@ describe('next --ceo-only runs the CEO phase alone and ends reviewed', () => { }); }); +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; }; From ba50ed9b1d2e2d5b1a31cbd91a3d4d1f5cccd15b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 22:53:29 +0000 Subject: [PATCH 10/26] 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> --- bin/gstack-autoplan | 2 +- docs/troubleshooting.md | 13 +++--- docs/unattended.md | 8 ++-- lib/autoplan-ceo-only.ts | 30 ++++++++------ lib/autoplan-scope.ts | 2 +- test/autoplan-ceo-only-driver.test.ts | 57 ++++++++++++++++++++++++++- 6 files changed, 89 insertions(+), 23 deletions(-) diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 61f992347..a4e43b520 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -188,7 +188,7 @@ function doNext(out: string, rest: string[], json: boolean): number { 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 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); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c140a0333..88570e0d0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3106,13 +3106,14 @@ 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. +`--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 sits at +`/ceo-review.md`, the report the run would write over it. -**Fix.** Drop the conflicting flag (or the marker token), or run the full -review without `--ceo-only`. +**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` diff --git a/docs/unattended.md b/docs/unattended.md index ab850ef2f..460c6803d 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -597,12 +597,14 @@ 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 +`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. 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 + `--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 diff --git a/lib/autoplan-ceo-only.ts b/lib/autoplan-ceo-only.ts index 77fd103f5..afcb2f1a8 100644 --- a/lib/autoplan-ceo-only.ts +++ b/lib/autoplan-ceo-only.ts @@ -7,18 +7,21 @@ * 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, + * 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 { 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'; @@ -26,18 +29,20 @@ 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. + * 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): boolean { +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; - 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); + 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)); + 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); + if (plan && out && path.resolve(plan) === path.resolve(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; } @@ -52,13 +57,16 @@ 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' })); + 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 report = path.join(state.out, CEO_ONLY_REPORT); - fs.writeFileSync(report, renderReport(state, items, rows, findings)); + 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)`]; @@ -82,10 +90,10 @@ function renderReport(state: RunState, items: GateItem[], rows: DecisionRow[], f ...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}`)), '', + ...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}, recommended ${r.recommended}${r.recommended_basis ? ` (${r.recommended_basis})` : ''}`)), '', + ...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-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/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts index 533f68ecc..1f90bf2ab 100644 --- a/test/autoplan-ceo-only-driver.test.ts +++ b/test/autoplan-ceo-only-driver.test.ts @@ -12,7 +12,7 @@ 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'; +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 })); @@ -175,6 +175,18 @@ describe('--ceo-only is a command-line flag for one run', () => { } }); + 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']); @@ -184,3 +196,46 @@ describe('--ceo-only is a command-line flag for one run', () => { 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 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); + }); +}); + From bce4b77fa16a1df6a0e9cdb0ccbf8bcabcbd3ef5 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:01:57 +0000 Subject: [PATCH 11/26] 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> --- lib/autoplan-ceo-only.ts | 8 ++++++-- test/autoplan-ceo-only-driver.test.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/autoplan-ceo-only.ts b/lib/autoplan-ceo-only.ts index afcb2f1a8..ad572bff3 100644 --- a/lib/autoplan-ceo-only.ts +++ b/lib/autoplan-ceo-only.ts @@ -42,7 +42,8 @@ export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefi 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); - if (plan && out && path.resolve(plan) === path.resolve(out, CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3); + const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; + if (plan && out && 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; } @@ -55,6 +56,10 @@ export function refuseCeoOnly(state: RunState, verb: string): void { /** 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 at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? fs.statSync(report) : undefined; + const plan = [state.plan_source, state.plan_input, state.active_plan].find(p => p && at && fs.existsSync(p) && fs.statSync(p).ino === at.ino && fs.statSync(p).dev === at.dev); + 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'); const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item)); @@ -63,7 +68,6 @@ export function finishRun(state: RunState): string[] { state.status = 'reviewed'; exportRun(state); const findings = aggregateFindings(state); - const report = path.join(state.out, CEO_ONLY_REPORT); const tmp = `${report}.${crypto.randomBytes(4).toString('hex')}.tmp`; fs.writeFileSync(tmp, renderReport(state, items, rows, findings), { flag: 'wx' }); fs.renameSync(tmp, report); diff --git a/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts index 1f90bf2ab..e7cc390f8 100644 --- a/test/autoplan-ceo-only-driver.test.ts +++ b/test/autoplan-ceo-only-driver.test.ts @@ -227,6 +227,35 @@ describe('the CEO-only finish records no answer nobody gave and writes only its 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 plan that became the report file after init (a hard link) refuses at the finish and stays intact', () => { + const out = startSmallRun(dir('hardlink'), ['--ceo-only']); + const plan = path.join(dir('hardlink'), 'plan.md'); + fs.linkSync(plan, path.join(out, 'ceo-review.md')); + const submit = runPhase(out, 'ceo').outside; + expect(submit.status).toBe(3); + expect(submit.stderr).toContain('(CEO_ONLY_CONFLICT)'); + expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN); + expect(readRun(out).status).not.toBe('reviewed'); + }); + 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'); From e24eab7fb31e5d429539dcc38d2ecbad1a8471bd Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:03:37 +0000 Subject: [PATCH 12/26] 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> --- changelog.d/gsta215-ceo-only-driver.md | 4 ++-- docs/troubleshooting.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog.d/gsta215-ceo-only-driver.md b/changelog.d/gsta215-ceo-only-driver.md index 42f84621e..1caf7cfb3 100644 --- a/changelog.d/gsta215-ceo-only-driver.md +++ b/changelog.d/gsta215-ceo-only-driver.md @@ -2,8 +2,8 @@ ### 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` 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`. +- **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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 88570e0d0..77dc50311 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3109,8 +3109,9 @@ the last `next` printed (`ATTEMPT: ... phase=

voice=`). `--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 sits at -`/ceo-review.md`, the report the run would write over it. +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`. From 767865d789bd4d89ee0a8bc77e3e029d9a2d5aa0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:09:38 +0000 Subject: [PATCH 13/26] 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> --- lib/autoplan-ceo-only.ts | 7 +++--- test/autoplan-ceo-only-driver.test.ts | 32 ++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/autoplan-ceo-only.ts b/lib/autoplan-ceo-only.ts index ad572bff3..db55d3836 100644 --- a/lib/autoplan-ceo-only.ts +++ b/lib/autoplan-ceo-only.ts @@ -43,7 +43,7 @@ export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefi 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 && real(plan) === path.join(real(out), CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3); + 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; } @@ -57,8 +57,9 @@ export function refuseCeoOnly(state: RunState, verb: string): void { 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 at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? fs.statSync(report) : undefined; - const plan = [state.plan_source, state.plan_input, state.active_plan].find(p => p && at && fs.existsSync(p) && fs.statSync(p).ino === at.ino && fs.statSync(p).dev === at.dev); + 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'); diff --git a/test/autoplan-ceo-only-driver.test.ts b/test/autoplan-ceo-only-driver.test.ts index e7cc390f8..48f76cf2b 100644 --- a/test/autoplan-ceo-only-driver.test.ts +++ b/test/autoplan-ceo-only-driver.test.ts @@ -245,17 +245,39 @@ describe('the CEO-only finish records no answer nobody gave and writes only its expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN); }); - test('a plan that became the report file after init (a hard link) refuses at the finish and stays intact', () => { - const out = startSmallRun(dir('hardlink'), ['--ceo-only']); - const plan = path.join(dir('hardlink'), 'plan.md'); - fs.linkSync(plan, path.join(out, 'ceo-review.md')); + 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(plan, 'utf8')).toBe(SMALL_PLAN); + 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'); From 75327a83c9fbcc7ecacf9a6002eb585f41ab23ba Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:23:03 +0000 Subject: [PATCH 14/26] 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> --- scripts/pr-dependencies.ts | 2 +- test/gstack-doctor.test.ts | 2 +- test/plan-tune-cathedral.test.ts | 2 +- test/timeline-stop-hook-slug.test.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index e40ae2c35..cb341bc92 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.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.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/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 1becf6c50..bbf9459d8 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -27,7 +27,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', // 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-autoplan.ts', // the windows bash row ]; const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index 20109dc42..839875b2b 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']) { + 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-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 857df2304..26d848111 100644 --- a/test/timeline-stop-hook-slug.test.ts +++ b/test/timeline-stop-hook-slug.test.ts @@ -34,6 +34,7 @@ const HOOK_FILES = [ 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', + 'lib/result-codes-autoplan.ts', ]; const SLUG = 'stop-hook-slug-test'; const BUDGET_MS = 2000; From 83037a29ecc7221c8d74a51520326e4d51b1b1b0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:37:22 +0000 Subject: [PATCH 15/26] docs: the decisions.jsonl status list names auto_kept Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/unattended.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unattended.md b/docs/unattended.md index 460c6803d..bcd49e073 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -254,7 +254,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` (`-

. It writes /port, + * appends every request to /requests.jsonl, and answers the n-th request + * with step n of /script.json (the last step repeats; default valid): + * { "mode": valid | missing | duplicate | foreign | bad_option | bad_cite | bad_conf | garbage | truncated | send_back | http500, + * "delay_ms"?: n }. A valid reply picks each question's first option with the + * question's first finding as its cite; usage is 1,000 input and 500 output tokens. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const dir = process.argv[2]!; +interface Step { mode: string; delay_ms?: number } +interface Question { id: string; options: Array<{ key: string }>; findings: Array<{ id: string }> } +let served = 0; + +function questions(prompt: string): Question[] { + const at = prompt.indexOf('## The questions'); + const block = /```json\n([\s\S]*?)\n```/.exec(prompt.slice(at))?.[1]; + return block ? JSON.parse(block) : []; +} + +function answer(mode: string, qs: Question[]): string { + const rows: Array> = qs.map(q => ({ id: q.id, action: mode === 'send_back' ? 'send_back' : 'decide', pick: q.options[0]!.key, confidence: 0.75, cites: q.findings.slice(0, 1).map(f => f.id), reason: 'fake prediction' })); + if (mode === 'missing') rows.pop(); + if (mode === 'duplicate') rows.push({ ...rows[0]! }); + if (mode === 'foreign') rows.push({ ...rows[0]!, id: 'zz9' }); + if (mode === 'bad_option') rows[0]!.pick = 'z'; + if (mode === 'bad_cite') rows[0]!.cites = ['not-a-finding']; + if (mode === 'bad_conf') rows[0]!.confidence = 1.5; + if (mode === 'garbage') return 'I would approve it.'; + if (mode === 'truncated') return '```json\n[{"id": "' + (qs[0]?.id ?? 'p1') + '", "act'; + return 'Here is my prediction.\n```json\n' + JSON.stringify(rows, null, 2) + '\n```\n'; +} + +const server = Bun.serve({ + port: 0, hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url); + const body = await req.json() as Record; + let steps: Step[] = [{ mode: 'valid' }]; + try { steps = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); } catch { /* default */ } + const step = steps[Math.min(served, steps.length - 1)]!; + served++; + fs.appendFileSync(path.join(dir, 'requests.jsonl'), JSON.stringify({ n: served, path: url.pathname, mode: step.mode, body, at: Date.now() }) + '\n'); + if (step.delay_ms) await Bun.sleep(step.delay_ms); + if (step.mode === 'http500') return Response.json({ error: { message: 'fake overloaded' } }, { status: 500 }); + const text = answer(step.mode, questions(String(body.messages?.[0]?.content ?? ''))); + const cut = step.mode === 'truncated'; + if (url.pathname.endsWith('/v1/messages')) return Response.json({ model: body.model, content: [{ type: 'text', text }], stop_reason: cut ? 'max_tokens' : 'end_turn', usage: { input_tokens: 1000, output_tokens: 500 } }); + return Response.json({ model: body.model, choices: [{ message: { content: text }, finish_reason: cut ? 'length' : 'stop' }], usage: { prompt_tokens: 1000, completion_tokens: 500 } }); + }, +}); +fs.writeFileSync(path.join(dir, 'port.tmp'), String(server.port)); +fs.renameSync(path.join(dir, 'port.tmp'), path.join(dir, 'port')); From aec80b10a9a3d579a85578a55113d3b5208d803a Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 18:31:33 +0000 Subject: [PATCH 20/26] gstack-autoplan: shadow every published gate, the shadow export, config and docs Every driver command that prints a gate records the shadow first and checkpoints after it; answer waits for a live lease and records a dead one retrospectively; gstack-autoplan shadow exports answered revisions for taste-replay R2 (SHADOW_BEFORE_ANSWER otherwise); gstack-config autoplan_shadow / autoplan_shadow_model; gstack-artifact validate checks the record's hash (GST-537 PR 3). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-artifact | 5 +- bin/gstack-autoplan | 171 +++++++---- bin/gstack-config | 23 +- bin/gstack-doctor-components.sh | 1 + docs/troubleshooting.md | 13 + docs/unattended.md | 86 +++++- lib/autoplan-shadow-reader.ts | 122 ++++++++ lib/result-codes-autoplan.ts | 8 +- test/autoplan-shadow-driver.test.ts | 402 +++++++++++++++++++++++++ test/autoplan-shadow.test.ts | 1 + test/fixtures/module-size-ratchet.json | 1 + 11 files changed, 769 insertions(+), 64 deletions(-) create mode 100644 lib/autoplan-shadow-reader.ts create mode 100644 test/autoplan-shadow-driver.test.ts diff --git a/bin/gstack-artifact b/bin/gstack-artifact index 9cef77cfb..2a8f77af5 100755 --- a/bin/gstack-artifact +++ b/bin/gstack-artifact @@ -22,6 +22,7 @@ import { import { resultLine } from '../lib/result-codes'; import { resolveStateRoot } from '../lib/state-root'; import { validatePublic } from '../lib/autoplan-public'; +import { validateShadow } from '../lib/autoplan-shadow-reader'; const HELP = `gstack-artifact — the unattended artifact contract (docs/unattended.md) @@ -86,7 +87,9 @@ function main(argv: string[]): number { else { printErrors(errors); process.stdout.write(errors.length ? `ARTIFACT_INVALID: ${errors.length} error(s)\n` : `ARTIFACT_VALID: ${as} ${path.basename(file)}\n`); } return errors.length ? EXIT.fail : EXIT.ok; } - const result = validateRun(file); + const run = validateRun(file); + const shadow = validateShadow(run.runDir); + const result = { ...run, valid: run.valid && shadow.length === 0, errors: [...run.errors, ...shadow] }; if (json) { process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, attested: result.attested ?? 0, errors: result.errors }) + '\n'); } else { diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index a4e43b520..0cf6428ca 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -21,6 +21,7 @@ * gstack-autoplan budget --out [--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change * gstack-autoplan check --out --attempt --result --model | --unavailable the delta check before a carry * gstack-autoplan checkpoints --from list checkpoint generations, newest first, with hash ok|bad + * gstack-autoplan shadow --out | --runs [--json] export answered gates beside their shadow predictions (read-only) * gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2) * * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), @@ -52,6 +53,8 @@ import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voic import { resultLine } from '../lib/result-codes'; import { release } from '../lib/spend-ledger'; import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract'; +import { claimShadow, runShadow, shadowBeforeAnswer, shadowSwitch, type ShadowClaim, type ShadowOutcome } from '../lib/autoplan-shadow'; +import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader'; const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md) DRIVER: ${driverFile()} @@ -95,6 +98,14 @@ DRIVER: ${driverFile()} bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before checkpoints --from list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current + shadow --out | --runs [--json] + read-only export for taste-replay R2: one row per item of each answered gate revision, its shadow prediction + (recorded before the answer; status recorded, malformed, failed, missing, retrospective or off) beside the + owner's answer and how he gave it; counts by status, item class, owner_mode and ceo_eligible. An unanswered or + interrogated revision refuses SHADOW_BEFORE_ANSWER, a CEO-only run CEO_ONLY_NO_GATE. --runs: one run dir per line. + Shadow mode itself is off by default: gstack-config set autoplan_shadow on (or GSTACK_AUTOPLAN_SHADOW=1) at the + first next; each gate then gets one hidden model call (autoplan_shadow_model, default claude-opus-5-5) counted + against --spend-cap, and every command that prints a gate prints SHADOW: first close --out --phase

--deferred "" --by > close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their reservation), open findings into tasks.jsonl; Critical, urgent and user-challenge findings become gate items @@ -138,7 +149,7 @@ function nextLine(state: RunState, out: string): string { * Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids) * and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json). */ -function mutating(out: string, json: boolean, fn: () => number): number { +function mutating(out: string, json: boolean, fn: () => number | Report): number | Report { const admission = admit(process.env); touchInstallLease(process.env); return withRunLock(out, () => { @@ -155,13 +166,48 @@ function mutating(out: string, json: boolean, fn: () => number): number { notices.push(...migrated.lines); } if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n'); - const code = fn(); + const result = fn(); if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); } - if (code === EXIT.ok) checkpointQuietly(out); - return code; + if (typeof result !== 'number') return claimed(out, result); + if (result === EXIT.ok) checkpointQuietly(out); + return result; }); } +/** + * What a command that can leave a gate reports: the lines before the gate, the lines after it, and its JSON payload. + * `publish` puts the SHADOW line and the gate lines between them once the shadow has run (lib/autoplan-shadow.ts). + */ +interface Report { head: string[]; tail: string[]; payload: Record; gate: boolean; claim?: ShadowClaim; claimLine?: string } + +/** Still under the command's run lock: a report at a gate claims its shadow (the checkpoint waits for publish); any other checkpoints now. */ +function claimed(out: string, r: Report): Report { + if (!r.gate) { checkpointQuietly(out); return r; } + try { const c = claimShadow(out); return { ...r, claim: c.claim, claimLine: c.line }; } + catch (e: any) { return { ...r, claimLine: `SHADOW: failed internal (${e?.message ?? e}); the gate stands` }; } +} + +const shadowField = (o: ShadowOutcome | undefined, line: string | undefined) => (o ? { shadow: { status: o.status, ...(o.code ? { code: o.code } : {}), ...(o.model ? { model: o.model } : {}), ...(o.usd !== undefined ? { usd: o.usd } : {}) } } : line ? { shadow: { line } } : {}); + +/** + * The one gate-publication step every command that prints a gate shares (next, submit, close, resume, revise, run, check): + * after the locked step, run the claimed shadow with no lock held, then print SHADOW, the gate and its cost, then + * checkpoint, so a checkpoint taken at a gate holds its shadow record. A shadow failure never fails the command. + */ +async function publish(out: string, json: boolean, r: number | Report): Promise { + if (typeof r === 'number') return r; + const outcome = r.claim ? await runShadow(out, r.claim) : undefined; + const line = outcome?.line ?? r.claimLine; + const state = r.gate ? readState(out) : undefined; + emit(json, [...r.head, ...(line ? [line] : []), ...(state ? gateLines(state, out) : []), ...r.tail], { ...r.payload, ...(state ? gateCostField(state, out) : {}), ...shadowField(outcome, line) }); + if (r.gate) gateCheckpoint(out); + return EXIT.ok; +} +function gateCheckpoint(out: string): void { + try { withRunLock(out, () => checkpointQuietly(out), { waitMs: 30_000 }); } + catch (e: any) { process.stderr.write(`CHECKPOINT: skipped for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands\n`); } +} + /** Set by a command whose success is a checkpoint moment beyond rest: a bound voice, a phase transition, the first next. */ let moment: CheckpointMoment | undefined; @@ -180,7 +226,7 @@ function emit(json: boolean, lines: string[], payload: Record): else process.stdout.write(lines.join('\n') + '\n'); } -function doNext(out: string, rest: string[], json: boolean): number { +function doNext(out: string, rest: string[]): Report { const cwd = process.cwd(); const initLines: string[] = []; const fresh = !fs.existsSync(path.join(out, FILES.run)); @@ -194,20 +240,18 @@ function doNext(out: string, rest: string[], json: boolean): number { 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); + const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env); + if (shadow.line) initLines.push(shadow.line); 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, ceoOnly, + carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow, 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]!] : [])); const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch }); moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined; const status = r.state.status; - const lines = [...initLines, ...r.lines]; - if (status === 'gate_pending') lines.push(...gateLines(r.state, out)); - if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); - lines.push(resultLineFor(status, out)); - emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out }); - return EXIT.ok; + return { head: [...initLines, ...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending', + payload: { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out } }; } /** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */ @@ -228,7 +272,7 @@ function gateCostField(state: RunState, out: string): Record { return cost ? { gate_cost: gateCostPayload(cost) } : {}; } -function doSubmit(out: string, rest: string[], json: boolean): number { +function doSubmit(out: string, rest: string[]): number | Report { const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record'); const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed'); if (!phase || !voice) return usage('submit needs --phase and --voice'); @@ -240,28 +284,22 @@ function doSubmit(out: string, rest: string[], json: boolean): number { const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'), unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() }); const status = r.state.status; - const lines = [...r.lines]; - if (status === 'gate_pending') lines.push(...gateLines(r.state, out)); - if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`); - lines.push(resultLineFor(status, out)); - emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out }); - return EXIT.ok; + return { head: [...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending', + payload: { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out } }; } -function doRevise(out: string, rest: string[], json: boolean): number { +function doRevise(out: string, rest: string[]): number | Report { 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)); - lines.push(resultLineFor(r.state.status, out)); - emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out }); - return EXIT.ok; + const tail = [...(r.state.status === 'running' || r.state.status === 'amend_pending' ? [nextLine(r.state, out)] : []), resultLineFor(r.state.status, out)]; + return { head: [...r.lines], tail, gate: r.state.status === 'gate_pending', + payload: { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out } }; } -function doClose(out: string, rest: string[], json: boolean): number { +function doClose(out: string, rest: string[]): number | Report { const phase = flag(rest, '--phase'); const reason = flag(rest, '--deferred'); const by = flag(rest, '--by'); 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); @@ -273,25 +311,22 @@ 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)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); + if (!currentPhase(state)) lines.push(...finishRun(state)); else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); } writeState(state); - lines.push(resultLineFor(state.status, out)); - emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out }); - return EXIT.ok; + return { head: lines, tail: [resultLineFor(state.status, out)], gate: state.status === 'gate_pending', payload: { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out } }; } /** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */ -function doCheck(out: string, rest: string[], json: boolean): number { +function doCheck(out: string, rest: string[]): number | Report { const unavailable = flag(rest, '--unavailable'); const result = flag(rest, '--result'); if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt and --result --model , or --unavailable ""'); const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') }); moment = 'transition'; const status = r.state.status; - const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)]; - emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out }); - return EXIT.ok; + return { head: [...r.lines], tail: [...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)], gate: status === 'gate_pending', + payload: { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out } }; } function doBudget(out: string, rest: string[], json: boolean): number { @@ -313,13 +348,18 @@ async function doRun(out: string, rest: string[], json: boolean): Promise doNext(out, rest, true)); - if (code !== EXIT.ok) return code; + const step = withRunLock(out, () => { const r = doNext(out, rest); return r.gate ? claimed(out, r) : r; }); const state = readState(out); if (state.status !== 'awaiting_result') { - if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n'); - process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok; + const outcome = step.claim ? await runShadow(out, step.claim) : undefined; + const line = outcome?.line ?? step.claimLine; + const at = readState(out); + emit(true, [], { ...step.payload, ...gateCostField(at, out), ...shadowField(outcome, line) }); + if (!json) process.stdout.write([...statusLines(at), ...(line ? [line] : []), ...(at.status === 'gate_pending' ? gateLines(at, out) : [])].join('\n') + '\n'); + if (step.gate) gateCheckpoint(out); + process.stdout.write(resultLineFor(at.status, out) + '\n'); return EXIT.ok; } + emit(true, [], step.payload); const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!; for (const voice of ['native', 'outside'] as const) { const v = state.phases[phase]![voice]!; @@ -341,12 +381,24 @@ async function doRun(out: string, rest: string[], json: boolean): Promise doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner], true)); + const bound = withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner])); + if (typeof bound !== 'number') emit(true, [], bound.payload); } } return EXIT.fail; } +/** `shadow`: the read-only export for taste-replay R2 (lib/autoplan-shadow-reader.ts). */ +function doShadow(rest: string[], json: boolean): number { + const runs = flag(rest, '--runs'); const one = flag(rest, '--out'); + if (!runs === !one) return usage('shadow needs --out or --runs (one run directory per line)'); + const dirs = runs ? fs.readFileSync(runs, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(d => path.resolve(d)) : [path.resolve(one!)]; + const r = exportShadow(dirs); + for (const x of r.refused) process.stderr.write(resultLine('gstack-autoplan shadow', x.code, x.detail) + '\n'); + if (json) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderShadowExport(r).join('\n') + '\n'); + return r.rows.length === 0 && r.refused.length ? EXIT.refused : EXIT.ok; +} + async function main(argv: string[]): Promise { const [cmd, ...rest] = argv; const json = rest.includes('--json'); @@ -359,15 +411,16 @@ async function main(argv: string[]): Promise { catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; } } const outFlag = flag(rest, '--out'); - if (!outFlag) return usage(`${cmd} needs --out

`); - const out = path.resolve(outFlag); + if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out `); + const out = path.resolve(outFlag ?? '.'); try { - if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json)); - if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json)); - if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json)); - if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json)); - if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json)); - if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json)); + if (cmd === 'shadow') return doShadow(rest, json); + if (cmd === 'next') return await publish(out, json, mutating(out, json, () => doNext(out, rest))); + if (cmd === 'submit') return await publish(out, json, mutating(out, json, () => doSubmit(out, rest))); + if (cmd === 'revise') return await publish(out, json, mutating(out, json, () => doRevise(out, rest))); + if (cmd === 'close') return await publish(out, json, mutating(out, json, () => doClose(out, rest))); + if (cmd === 'budget') return await publish(out, json, mutating(out, json, () => doBudget(out, rest, json))); + if (cmd === 'check') return await publish(out, json, mutating(out, json, () => doCheck(out, rest))); if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); } if (cmd === 'status') { const state = readState(out); @@ -377,13 +430,11 @@ async function main(argv: string[]): Promise { if (cmd === 'resume') { const from = flag(rest, '--from'); const restored = from !== undefined ? restoreCheckpoint(from, out) : []; - return withRunLock(out, () => { + return await publish(out, json, withRunLock(out, () => { const r = resumeRun(out); - checkpointQuietly(out); - const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)]; - emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out }); - return EXIT.ok; - }, { steal: true }); + return claimed(out, { head: [...restored, ...r.lines], tail: [resultLineFor(r.state.status, out)], gate: r.state.status === 'gate_pending', + payload: { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out } }); + }, { steal: true })); } if (cmd === 'answer') { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by'); @@ -392,7 +443,9 @@ async function main(argv: string[]): Promise { 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 shadowLines = await shadowBeforeAnswer(out); + if (shadowLines.length) (json ? process.stderr : process.stdout).write(shadowLines.join('\n') + '\n'); + return await publish(out, json, mutating(out, json, () => { const state = readState(out); refuseCeoOnly(state, 'answer'); const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply }); @@ -402,19 +455,19 @@ async function main(argv: string[]): Promise { lines.push(resultLineFor(state.status, out)); emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out }); return EXIT.ok; - }); + })); } if (cmd === 'recommend') { const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis'); if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev --item --option --basis ""'); - return mutating(out, json, () => { + return await publish(out, json, 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 }); return EXIT.ok; - }); + })); } if (cmd === 'export' && flag(rest, '--public') !== undefined) { return withRunLock(out, () => { @@ -425,7 +478,7 @@ async function main(argv: string[]): Promise { }); } if (cmd === 'export') { - return mutating(out, json, () => { + return await publish(out, json, mutating(out, json, () => { const state = readState(out); const pending = pendingAmendments(state).map(a => a.decision); if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3); @@ -434,7 +487,7 @@ async function main(argv: string[]): Promise { for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`); emit(json, [...r.files.map(f => `EXPORTED: ${f}`), dispatchLine(state)], { run: state.run, files: r.files, warnings: r.warnings, dispatches: state.dispatches, est_input_tokens: state.est_input_tokens }); return EXIT.ok; - }); + })); } if (cmd === 'release') { const attempt = flag(rest, '--attempt'); diff --git a/bin/gstack-config b/bin/gstack-config index 8992806e2..afd908913 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -221,6 +221,17 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # /codex and /claude-code still run when you ask by name. # # An unreadable config counts as disabled. # # Invalid values preserve the old value. +# autoplan_shadow: off # Shadow mode for the CEO-only decider (gstack-autoplan +# # driver runs): on gives every gate one hidden model +# # prediction, recorded before the owner answers, for +# # taste-replay R2. Sends the reviewed plan and the gate +# # to that model on your own API key, about $0.07 per +# # gate, counted against --spend-cap. Read at the first +# # next of each run only. GSTACK_AUTOPLAN_SHADOW=1|0 +# # overrides. +# # Invalid values are rejected, the old value kept. +# autoplan_shadow_model: claude-opus-5-5 # The model the shadow decider runs on. +# # Docs: docs/unattended.md#shadow-mode # browse_extension_id: # Forks and self-built extensions: the 32-letter (a-p) # # Chrome extension ID the browse sidebar terminal accepts. # # Empty = the gstack extension. "" clears it. @@ -309,6 +320,8 @@ lookup_default() { explain_level) echo "default" ;; plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168) codex_reviews) echo "enabled" ;; + autoplan_shadow) echo "off" ;; # on | off — autoplan shadow mode, fixed at each run's first next (lib/autoplan-shadow.ts) + autoplan_shadow_model) echo "claude-opus-5-5" ;; # the shadow decider's model; needs a lib/pricing.ts row and its family's API key design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again" gstack_contributor) echo "false" ;; @@ -961,6 +974,12 @@ $_gstack_legacy_root" echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 exit 1 fi + # autoplan_shadow is a CONSENT key: `on` sends each gate's plan to a model + # API. Reject like memorable_recall -- a typo must never flip it. + if [ "$KEY" = "autoplan_shadow" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then + echo "Error: autoplan_shadow '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 + exit 1 + fi # browse_extension_id (D1) decides which Chrome extension may reach the # browse daemon's token route and terminal socket. Reject anything that is # not a Chrome extension ID; "" clears the override. @@ -1093,7 +1112,7 @@ $_gstack_legacy_root" report_root_disagreement for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \ skill_prefix explain_level plan_mode_preamble \ - codex_reviews gstack_contributor skip_eng_review workspace_root \ + codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \ browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \ @@ -1118,7 +1137,7 @@ $_gstack_legacy_root" echo "# gstack-config defaults" for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \ skill_prefix explain_level plan_mode_preamble \ - codex_reviews gstack_contributor skip_eng_review workspace_root \ + codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \ timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \ browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \ diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 09a4bd780..1e462a625 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -376,6 +376,7 @@ gstack_result_anchor() { 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' ;; + SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;; *) return 1 ;; esac } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 276543d9e..cbee76ee2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3216,6 +3216,19 @@ work: they are how a budget-stopped CEO-only run recovers.) full review in a new directory: `gstack-autoplan next --out --plan ` without `--ceo-only`. + +### `SHADOW_BEFORE_ANSWER` + +**Meaning.** `gstack-autoplan shadow` skipped a gate revision whose plan +approval (`p1`) is unanswered or was answered `c` (interrogate). The export +pairs each hidden prediction with the owner's answer, so a revision he has not +answered has nothing to pair it with. Answered revisions of the same run still +export; the command exits 3 only when nothing was exported. + +**Fix.** Answer the gate (`gstack-autoplan answer --out --gate-rev +...`), then run `gstack-autoplan shadow` again. See +[Shadow mode](unattended.md#shadow-mode). + ### `ATTEMPT_MISMATCH` diff --git a/docs/unattended.md b/docs/unattended.md index fd70bf033..c27663a41 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -258,7 +258,8 @@ 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` (`-