mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
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>
This commit is contained in:
1 parent
e56602051a
commit
483d84c5bd
17 files changed
+383
-41
No files matched your search
+2
-2
@@ -57,8 +57,8 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
### Commands
|
||||
|
||||
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds.
|
||||
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `<dir>/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`).
|
||||
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
|
||||
later calls: open the next phase or reprint open attempts; never redispatches on its own.
|
||||
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
|
||||
|
||||
+16
-7
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
|
||||
*
|
||||
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
|
||||
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
|
||||
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
|
||||
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
|
||||
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--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 <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
|
||||
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
|
||||
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--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 <out>/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 <out>/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 <file> 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 <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
|
||||
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
|
||||
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 <p> --deferred "<reason>" --by <human|agent:<id>>');
|
||||
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:<id> (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<number> {
|
||||
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 "<text>"');
|
||||
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<number> {
|
||||
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
|
||||
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 });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 `<dir>/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=<dir>`. 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.
|
||||
@@ -3102,6 +3102,39 @@ result.
|
||||
**Fix.** Run `gstack-autoplan status --out <dir>` and submit for the attempt
|
||||
the last `next` printed (`ATTEMPT: ... phase=<p> voice=<v>`).
|
||||
|
||||
<a id="ceo-only-conflict"></a>
|
||||
### `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`.
|
||||
|
||||
<a id="ceo-only-after-init"></a>
|
||||
### `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`.
|
||||
|
||||
<a id="ceo-only-no-gate"></a>
|
||||
### `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 `<run dir>/ceo-review.md`. For a gate and an approval, start a
|
||||
full review in a new directory: `gstack-autoplan next --out <new dir> --plan
|
||||
<file>` without `--ceo-only`.
|
||||
|
||||
<a id="attempt-mismatch"></a>
|
||||
### `ATTEMPT_MISMATCH`
|
||||
|
||||
|
||||
+24
-6
@@ -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 <dir> --plan <file>
|
||||
--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=<dir>`, with
|
||||
`<dir>/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 <dir>` is what the owner sees at the final
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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;
|
||||
|
||||
@@ -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*gstack-autoplan-scope:(.*?)-->\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');
|
||||
}
|
||||
@@ -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<string>();
|
||||
const blockedBy = state.ceo_only ? [] : [decisionId(state.run, state.gate_rev ?? 1, 'p1')];
|
||||
let n = 0;
|
||||
for (const f of findings as Array<FindingRow & { settled_by?: string }>) {
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
+17
-16
@@ -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<T>(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<string, PhaseState> = {};
|
||||
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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
@@ -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 `<a id>` 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<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
@@ -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 <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds` },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--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 <n> says otherwise, so --spend-cap binds; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends \`status=reviewed\` with \`<dir>/ceo-review.md\` (\`REVIEWED:\`): no gate, and the gate verbs refuse it (\`CEO_ONLY_NO_GATE\`)` },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> [--redispatch <attempt>]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
|
||||
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
|
||||
|
||||
@@ -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', `<!-- gstack-autoplan-scope: --no-ui --ceo-only -->\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');
|
||||
});
|
||||
});
|
||||
+2
@@ -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)" },
|
||||
|
||||
Reference in new issue
Block a user