--phase --voice native|outside --record --model [--attempt ]`
bind a free-form review record (canonical fence, one findings table or list, or `No findings.`).
- `gstack-autoplan submit ... --record --attest "" --attempt `
bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses).
-- `gstack-autoplan submit --out --phase --voice native|outside --result --model --attempt `
- bind a canonical result (the RESULT FORMAT below).
+- `gstack-autoplan submit --out --phase --voice native|outside --result --model --attempt [--usage-usd ] [--reconcile ]`
+ bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one.
- `gstack-autoplan submit --out --phase --voice outside --unavailable|--failed ""`
close the phase native-only when no outside voice can run.
-- `gstack-autoplan answer --out --gate-rev --reply "" --by human|agent: [--owner-reply |-] [--rec-hash ]`
- answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim.
+- `gstack-autoplan answer --out --gate-rev --reply "" --by human|agent: [--owner-reply |-] [--words-by agent:] [--rec-hash ]`
+ answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim, and passes --words-by agent: whenever the words are its own (the default is owner).
- `gstack-autoplan recommend --out --gate-rev --item --option --basis ""`
change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`).
- `gstack-autoplan revise --out --plan --incorporates `
bind the amended plan; reopens every phase whose reviewed plan changed, Eng last.
-- `gstack-autoplan revise --out --settle `
- discharge a packaging or ordering change with no plan edit; reopens nothing.
-- `gstack-autoplan revise --out --withdraw `
- drop a pending amendment instead of applying it.
+- `gstack-autoplan revise --out --settle | --withdraw `
+ --settle discharges a packaging or ordering change with no plan edit and reopens nothing; --withdraw drops a pending amendment instead of applying it.
- `gstack-autoplan close --out --phase --deferred "" --by human|agent:`
close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`.
- `gstack-autoplan budget --out [--spend-cap ] [--max-rounds ] --by human|agent:`
@@ -111,7 +111,7 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa
- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases.
- `as-is`: keep every direction; no plan change.
-- `d3b uc1a`: one token per item you set; unanswered auto items take their default.
+- `d3b uc1a`: one token per item you set; unanswered auto items take their default (a finding item keeps the plan as written; `p1b` applies it).
- `rec except d3b`: recommendations, with the listed overrides.
## Run directory
diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan
index c42062867..a7b3194ea 100755
--- a/bin/gstack-autoplan
+++ b/bin/gstack-autoplan
@@ -9,8 +9,8 @@
* gstack-autoplan submit --out --phase --voice outside --unavailable | --failed [--json]
* gstack-autoplan revise --out --plan [--incorporates ] [--withdraw ] [--settle ] [--json] review a revised plan in this run
* gstack-autoplan run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api]
- * [--deadline ] [--spend-cap ] [--estimate-usd ] [--json] next in a loop; needs runners for both voices
- * gstack-autoplan answer --out --gate-rev --reply "" --by > [--owner-reply |-] [--rec-hash ] [--json]
+ * [--deadline ] [--spend-cap ] [--estimate-usd ] [--max-tokens ] [--json] next in a loop; needs runners for both voices
+ * gstack-autoplan answer --out --gate-rev --reply "" --by > [--owner-reply |-] [--words-by owner|agent:] [--rec-hash ] [--json]
* gstack-autoplan recommend --out --gate-rev --item --option --basis "" [--by ]
* gstack-autoplan resume --out [--from ] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan status --out [--json] query: the manifest, no GSTACK_RESULT line
@@ -43,11 +43,13 @@ import { capyRelayLine } from '../lib/captain-brief';
import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
-import { pendingAmendments, type RunState } from '../lib/autoplan-state';
-import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
+import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
+import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
+import { DEFAULT_MAX_TOKENS, apiTimeoutMs } from '../lib/outside-voice-api';
+import { estimateCostUsd, pricingRow } from '../lib/pricing';
import { resultLine } from '../lib/result-codes';
import { release } from '../lib/spend-ledger';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
@@ -59,7 +61,7 @@ DRIVER: ${driverFile()}
[--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
- --estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
+ --estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap (or on Capy) unattended runs reserve a default so the cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
--no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
@@ -80,9 +82,12 @@ DRIVER: ${driverFile()}
comes into scope, the edit applies without re-review (APPLIED:); --withdraw (alone or with --plan) drops one;
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
run --out --plan --native-model --outside-model [--runner codex-cli|api] [--native-runner api] ...
- next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
+ next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
+ --deadline cancels the children it started; --max-tokens caps each api call's output (Anthropic default 16384), and a
+ response cut off at that limit is reported truncated; submit's result checks judge every completed response
answer --out --gate-rev --reply "" --by > [--json]
--by agent: needs --owner-reply "" (- reads stdin; --owner-reply-file ), stored verbatim;
+ --words-by agent: (the same agent) when those words are its own: recorded as its words, never the owner's, never carried;
--rec-hash binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending
recommend --out --gate-rev --item --option --basis "" [--by ]
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
@@ -178,7 +183,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[], json: boolean, price?: NextOptions['price']): number {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
@@ -195,7 +200,7 @@ function doNext(out: string, rest: string[], json: boolean): number {
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 });
+ const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch, price });
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];
@@ -305,10 +310,19 @@ async function doRun(out: string, rest: string[], json: boolean): Promise (voice === 'native' ? nativeModel : outsideModel);
+ const isApi = (voice: Voice) => (voice === 'native' ? nativeRunner : outsideRunner) === 'api';
+ // An api call reserves its worst case (prompt bytes / 3 input tokens plus the output limit it is sent) and settles at its usage.
+ const price = (voice: Voice, promptFile: string) => {
+ const row = isApi(voice) ? pricingRow(modelOf(voice)) : undefined;
+ return row ? +((fs.statSync(promptFile).size / 3 * row.input_per_mtok + (maxTokens ?? DEFAULT_MAX_TOKENS) * row.output_per_mtok) / 1e6).toFixed(6) : undefined;
+ };
for (let guard = 0; guard < 40; guard++) {
- const code = withRunLock(out, () => doNext(out, rest, true));
+ const code = withRunLock(out, () => doNext(out, rest, true, price));
if (code !== EXIT.ok) return code;
const state = readState(out);
if (state.status !== 'awaiting_result') {
@@ -322,21 +336,27 @@ async function doRun(out: string, rest: string[], json: boolean): Promise { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
const timer = deadlineAt ? setTimeout(() => runner.cancel(), remaining) : undefined;
- const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, 600_000), cwd: process.cwd(), gate: 'review' });
+ const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, runnerId === 'api' ? apiTimeoutMs(maxTokens) : 600_000), cwd: process.cwd(), gate: 'execution', maxTokens: runnerId === 'api' ? maxTokens ?? DEFAULT_MAX_TOKENS : maxTokens });
if (timer) clearTimeout(timer);
if (!json) process.stdout.write(`VOICE: ${phase} ${voice} ${res.status} runner=${res.runner} model=${res.model ?? 'unknown'}\n`);
if (res.status === 'cancelled') { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
if (res.status !== 'completed' && res.status !== 'unverified') {
if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; }
- process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.detail ? `: ${res.detail}` : ''}\n`);
+ process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.reason ? `: ${res.reason}` : ''}${res.detail ? `: ${res.detail}` : ''}\n`);
withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); });
process.stdout.write(resultLineFor('interrupted', out) + '\n');
return EXIT.fail;
}
- 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], true));
+ const used = res.usage;
+ const usage = isApi(voice) && pricingRow(modelOf(voice)) && typeof used.input_tokens === 'number' && typeof used.output_tokens === 'number' ? ['--usage-usd', String(estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)))] : [];
+ try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
+ catch (e) {
+ if (voice !== 'outside' || !(e instanceof RunError)) throw e;
+ withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} result refused: ${e.code}`));
+ }
}
}
return EXIT.fail;
@@ -386,10 +406,12 @@ async function main(argv: string[]): Promise {
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; }
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
+ const wordsBy = flag(rest, '--words-by') ?? 'owner';
+ if (wordsBy !== 'owner' && (wordsBy !== by || !by.startsWith('agent:'))) return usage(`--words-by must be owner or the answering agent (--by ${by ?? ''}), got ${wordsBy}`);
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const state = readState(out);
- const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
+ const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
writeState(state);
const lines = [...r.lines];
if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out));
diff --git a/bin/gstack-autoplan-snapshot.ts b/bin/gstack-autoplan-snapshot.ts
index 93877f418..7cfbf958f 100644
--- a/bin/gstack-autoplan-snapshot.ts
+++ b/bin/gstack-autoplan-snapshot.ts
@@ -55,7 +55,7 @@ For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
reviewing this plan. You have NOT seen any prior review. Evaluate:
1. Getting started: how many steps from zero to hello world? What's the TTHW?
2. API/CLI ergonomics: naming consistency, sensible defaults, progressive disclosure?
-3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or the precise thing a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure that names no non-interactive alternative such as --yes) as a Critical finding titled "CAP4::- " (caps the Error Messages score at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6:
:- " (caps it at 6/10).
+3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or what a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure naming no non-interactive alternative such as --yes) as a Critical finding titled "CAP4:
:-
" (caps Error Messages at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6::-
" (caps it at 6/10).
4. Documentation: copy-paste examples? Information architecture? Interactive elements?
5. Escape hatches: can developers override every opinionated default?
For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
diff --git a/bin/gstack-outside-voice b/bin/gstack-outside-voice
index 28a03e3be..d2b2c56c1 100755
--- a/bin/gstack-outside-voice
+++ b/bin/gstack-outside-voice
@@ -4,7 +4,7 @@
*
* gstack-outside-voice run --runner codex-cli|api|host-subagent --prompt --out
* [--model ] [--result ] [--native-model ] [--fallback ]
- * [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd ] [--json]
+ * [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd ] [--json]
* gstack-outside-voice runners [--model ] [--json] the runner table: capabilities and availability here
* gstack-outside-voice family the model's family (same-family rule)
*
@@ -20,18 +20,21 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import type { OutsideGate } from '../lib/outside-review-result';
import { RUNNER_IDS, createRunner, familyConflict, modelFamily, outsideStatusLine, runnerTable, selectRunner, type RunnerId } from '../lib/outside-voice-runner';
import { outsideConsent } from '../lib/outside-consent';
+import { apiTimeoutMs } from '../lib/outside-voice-api';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-outside-voice — one outside voice, three runners (docs/unattended.md)
run --runner --prompt --out [--model ] [--result ] [--native-model ]
- [--fallback ] [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd ] [--json]
+ [--fallback ] [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd ] [--json]
runners [--model ] [--json] the runner table: id, capabilities, available here
family print the model family (anthropic, openai, google, ...)
Runners: codex-cli (repository review via codex exec), api (supplied-input review via
OPENAI_API_KEY/ANTHROPIC_API_KEY; needs --model), host-subagent (binds --result from a
-cross-family subagent the host ran; needs --model). --native-model enforces the same-family
+cross-family subagent the host ran; needs --model). --max-tokens caps the api runner's output
+(Anthropic default 16384; its default time limit grows with it); a response cut off at the limit
+is unavailable (truncated). --native-model enforces the same-family
rule. GSTACK_OUTSIDE_RUNNER names the fallback when --fallback is absent.
Prints OUTSIDE_STATUS: provider= model= family= and writes .meta.json.
codex_reviews=disabled (or an unreadable config) refuses every runner before it starts:
@@ -75,9 +78,12 @@ async function main(argv: string[]): Promise {
if (!fs.existsSync(promptFile)) return usage(`prompt file not found: ${promptFile}`);
const gate = (flag(rest, '--gate') ?? 'review') as OutsideGate;
if (!['review', 'structured', 'spec', 'proposal'].includes(gate)) return usage('--gate must be review, structured, spec or proposal');
+ const maxTokensText = flag(rest, '--max-tokens');
+ const maxTokens = maxTokensText === undefined ? undefined : Number(maxTokensText);
+ if (maxTokens !== undefined && (!Number.isInteger(maxTokens) || maxTokens <= 0)) return usage('--max-tokens needs a positive integer');
const timeoutText = flag(rest, '--timeout-ms');
- const timeoutMs = timeoutText === undefined ? 300_000 : Number(timeoutText);
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) return usage('--timeout-ms needs a positive integer');
+ const timeoutMs = timeoutText === undefined ? undefined : Number(timeoutText);
+ if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0)) return usage('--timeout-ms needs a positive integer');
const fallbackText = flag(rest, '--fallback') ?? process.env.GSTACK_OUTSIDE_RUNNER;
if (fallbackText !== undefined && !isRunner(fallbackText)) return usage(`--fallback must be one of ${RUNNER_IDS.join(', ')}`);
const model = flag(rest, '--model');
@@ -110,7 +116,7 @@ async function main(argv: string[]): Promise {
const stop = () => runner.cancel();
process.on('SIGTERM', stop);
process.on('SIGINT', stop);
- const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs, cwd, gate, resultFile: resultFile ? path.resolve(resultFile) : undefined });
+ const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs: timeoutMs ?? (choice.runner === 'api' ? apiTimeoutMs(maxTokens) : 300_000), cwd, gate, maxTokens, resultFile: resultFile ? path.resolve(resultFile) : undefined });
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
fs.writeFileSync(`${path.resolve(outFile)}.meta.json`, JSON.stringify(result, null, 2) + '\n');
if (json) process.stdout.write(JSON.stringify(result) + '\n');
diff --git a/changelog.d/gsta232-autoplan-wave.md b/changelog.d/gsta232-autoplan-wave.md
new file mode 100644
index 000000000..7f620c23d
--- /dev/null
+++ b/changelog.d/gsta232-autoplan-wave.md
@@ -0,0 +1,15 @@
+**/autoplan fixes from a field report (GBRA-223): findings without a line number pair across voices, open Critical and High findings reach the gate, a spend cap binds on any unattended host, `run` works with the API runner, and an earlier item-by-item answer carries forward.** OpenClaw's /autoplan run on a real plan hit six problems at once. We reproduced each on main with a failing test first (GST-672). A follow-up from GEVA-2 adds a record of whose words answered a gate.
+
+### Fixed
+- **Plan reviews that name a file but no line now pair across voices.** A same-file pair, with a line missing on either side, is associated as `unverified` instead of both findings counting as new. Title matching ignores a leading DX cap code (`CAP4::`) and never pairs findings on two different files. The same-file fallback runs only after every title and line match is placed, and a same-file pair never merges two findings into one gate item.
+- **DX cap findings carry real titles.** `CAP4` and `CAP6` findings are titled `CAP::-
`, so the gate shows the problem and not only the item name.
+- **Every open Critical or High finding becomes a gate item.** `a` applies the reviewer's fix to the plan and is recommended. `b` carries it into the build as a blocking task. `rec` and `p1b` apply the fix. An unanswered item keeps the plan as written unless the reply is `p1b`, and a partial reply never locks that choice early. A finding carried with `b` stays open, so a rerun that raises it again asks again.
+- **A spend cap binds on any unattended host.** With `--spend-cap` set and no `--estimate-usd`, each host-subagent attempt reserves the default $5 per voice, on Capy or off it. `next --estimate-usd ` sets the reservation for the rest of the run. `run` reserves each API call's priced worst case (prompt size plus the output limit) and settles it at its real token cost, so `--spend-cap 0` refuses before any call.
+- **`gstack-autoplan run` with `--native-runner api --runner api` reaches the gate.** Results are checked as autoplan results, so they bind. A response cut off at the output limit is named `truncated` and interrupts the run instead of binding a partial review. The Anthropic default output limit is 16384 tokens, `--max-tokens ` sets it, and an outside result refused at submit leaves the run native-only instead of aborting it.
+- **An earlier item-by-item answer carries forward when nothing changed.** When every item on the new gate is carried by origin, the owner's `p1` choice is replayed and their tokens are recorded verbatim as the basis. Any new or changed item still goes to the owner.
+
+### Added
+- **`gstack-autoplan answer --words-by agent:`** records that the words came from the answering agent itself, as `answer_words` and `words_by`, never as `owner_reply`. The default stays `owner`. A later gate never carries those words forward, and `gstack-approvals import` lists that row under the agent, while `--by` names only the rows that hold the owner's words.
+
+### Tests
+- New failing-first tests in test/autoplan-pairing.test.ts, test/autoplan-gate-judgment.test.ts, test/autoplan-budget.test.ts, test/autoplan-run-api.test.ts (a local fake provider, no key or network), test/autoplan-carry.test.ts and test/approval-record.test.ts.
diff --git a/docs/agent-ready-errors.md b/docs/agent-ready-errors.md
index 3da3f8140..3be4b3431 100644
--- a/docs/agent-ready-errors.md
+++ b/docs/agent-ready-errors.md
@@ -37,8 +37,8 @@ unknown is reserved for existing behavior you could not inspect. In a live audit
you never triggered are unknown, not extrapolated.
**Caps.** Name the path and item behind every cap and show the corrected error line.
-- **4/10 (Critical finding, title `CAP4::- `):** a path leaks a secret, signals failure as success, gives an unsafe fix, or blocks waiting for a terminal (hangs, or fails for lack of a terminal without naming a non-interactive alternative such as `--yes`).
-- **6/10 (High finding, title `CAP6:
:- `):** a path lacks an applicable fix, recovery, context or trace item.
+- **4/10 (Critical finding, title `CAP4:
:-
`):** a path leaks a secret, signals failure as success, gives an unsafe fix, or blocks waiting for a terminal (hangs, or fails for lack of a terminal without naming a non-interactive alternative such as `--yes`).
+- **6/10 (High finding, title `CAP6::-
`):** a path lacks an applicable fix, recovery, context or trace item.
Every mode, DX TRIAGE included, reports every CAP4 finding.
diff --git a/docs/captain.md b/docs/captain.md
index 313e006f6..3b0ee7d45 100644
--- a/docs/captain.md
+++ b/docs/captain.md
@@ -172,7 +172,7 @@ not a new ask ([carry-forward](unattended.md#carry-forward)).
**The record.** `gstack-approvals import --autoplan --by ""` (with
`--stack ` for a stacked PR set) writes the approval row (date, the ask, the owner's exact words, the lane that answered as relayer,
-what it covers); `gstack-approvals render` puts it in the report and the PR body. Before
+what it covers; a gate answered with `--words-by agent:` is listed under that agent as its own decision, not the owner's words); `gstack-approvals render` puts it in the report and the PR body. Before
training the PR the controller compares that row's words and `gate_rev` with the ask it
relayed and the reply it carried back; a row that does not match is not READY (shape A).
The brief's done line names the row. A plan approval is not a READY field; the
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 2fb72daf8..1b76b310d 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -314,6 +314,18 @@ network.
**Expected result.** `OUTSIDE_STATUS: completed`.
+
+### `... outside review unavailable: the reviewer hit its output token limit and the response is cut off (...)`
+
+**Meaning.** The api runner's provider stopped at the output token limit
+(Anthropic `stop_reason: max_tokens`, OpenAI `finish_reason: length`), so the
+review ends mid-sentence and its findings may be missing.
+
+**Fix.** Re-run with a larger `--max-tokens` (`gstack-outside-voice run` or
+`gstack-autoplan run`; Anthropic defaults to 16384), or shorten the prompt.
+
+**Expected result.** `OUTSIDE_STATUS: completed`.
+
### `... outside review unavailable: the response lacks the markers this gate requires (...)`
diff --git a/docs/unattended.md b/docs/unattended.md
index be924d334..360589bad 100644
--- a/docs/unattended.md
+++ b/docs/unattended.md
@@ -373,7 +373,14 @@ the plan.
refuses to open without one (`CONSENSUS_MISSING`).
3. **Loop `next` until `status=gate_pending`.** Eng always runs last. The gate
(`gate.json`, `gate_rev`) lists user challenges as approval items,
- disagreements as auto items and `p1` plan approval; nothing is approved.
+ disagreements as auto items, each open Critical or High finding as an
+ auto item (a twin pair is one item; `a` applies the reviewer's fix to the
+ plan and is recommended, `b` carries it into the build as a blocking task)
+ and `p1` plan approval; nothing is approved. An unanswered auto item whose
+ recommendation changes the plan keeps the plan's direction, so only `rec`,
+ `p1b` (approve with the recommended changes) or an explicit `a` applies
+ a fix; a finding carried with `b` stays
+ open, so a rerun that raises it again asks again.
4. **Answer the gate** with the reply grammar and the revision you read:
```bash
$AP answer --out --gate-rev 1 --reply "rec" --by human # every recommendation, plan changes included (APPLY: ...; amend_pending)
@@ -384,12 +391,18 @@ the plan.
means an agent relayed them, and then `--owner-reply` (or `-` for stdin,
`--owner-reply-file `) stores the owner's words verbatim
(`OWNER_REPLY_REQUIRED`); owner words that parse to different choices are
- refused (`GATE_OWNER_REPLY_MISMATCH`). An unattended run refuses an answer
+ refused (`GATE_OWNER_REPLY_MISMATCH`). The words default to the owner's: an
+ agent that answers in its own words (a Captain deciding on its own
+ authority) must pass `--words-by agent:` with its own `--by` id. They
+ are then recorded as `answer_words` with `words_by`, never as
+ `owner_reply`; a later gate never carries them (`CARRY: no`), and
+ `gstack-approvals import` lists that row under the agent, not the owner.
+ An unattended run refuses an answer
without `--by` (`ANSWER_BY_REQUIRED`, exit 3) and records the value as
`answered_by` on every decision row and in the review record's gate line,
so a gate an agent closed is distinguishable from one the owner closed.
`gstack-autoplan recommend` changes one recommendation before relay; it
- bumps the gate's `rec_rev`, and a later `rec` needs the `--rec-hash` from
+ bumps the gate's `rec_rev`, and a later `rec` or `p1b` needs the `--rec-hash` from
the render the owner saw (`GATE_REC_STALE`). Every `GATE:` line is followed
by `GATE_SPEND:` and `GATE_REC_COST:` (also `gate_cost` in `--json`; see
`autoplan/DRIVER.md`).
@@ -439,7 +452,12 @@ Concurrency and recovery: every state write is a locked read-modify-write
(`test/autoplan-run-recovery.test.ts`, `test/autoplan-run-concurrency.test.ts`,
`test/autoplan-answer-invalidation.test.ts`). `--spend-cap` admits each attempt
through `spend.json` (`SPEND_CAP_EXCEEDED` refuses, exit 3); host-subagent
-spend is `unknown` and counted in `worst_case`. `--deadline ` makes the
+spend is `unknown` and counted in `worst_case`. Under a cap (or on Capy) each
+unattended host-subagent attempt reserves the default per voice unless
+`next --estimate-usd ` sets the reservation, which the run keeps for later
+phases (`run` reserves an api attempt's priced worst case, prompt bytes / 3
+input tokens plus the output limit, and settles it at its token usage); `submit
+--usage-usd ` settles an attempt at its real cost. `--deadline ` makes the
`run` subcommand cancel the children it started and marks the run
`interrupted`.
@@ -519,9 +537,8 @@ A gate revision after the first (`gate_rev` 2 and later, written after a
it when nothing new needs their decision. `gstack-autoplan` prints
`CARRIED: gate_rev= <- gate_rev= words="" by= reason=`,
then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
-all of: the earlier gate approved (`p1a` or `p1b`) with `rec` or "do it"
-(not item tokens), no item there left pending, and the owner's words
-recorded; that gate was not itself carried; every accepted change incorporated
+all of: the earlier gate approved (`p1a` or `p1b`), no item there left
+pending, and the owner's words recorded; that gate was not itself carried; every accepted change incorporated
or settled; the plan file's bytes equal those recorded at the gate or at the
`revise` that took the changes (any later edit refuses, an ELI5-only one
included); no urgent or
@@ -530,6 +547,13 @@ Anything else prints `CARRY: no ()` (journal `carry_refused`) and the gate
goes to the owner as before. `--no-carry-forward` on the first `next` turns it
off for the run.
+An earlier answer given item by item (`` tokens, not `rec` or "do
+it") carries only what the owner already answered: every item on the new gate
+must be carried by origin (the same proposal, so the same choice), and only an
+unreviewed post-cap delta (below) may be new. The carry replays the owner's own
+`p1` choice and records their tokens verbatim as its basis; any new or changed
+item, "do it" cases included, goes to the owner.
+
- **"do it".** When the owner's words were "do it" (their words, not a
`captain:` relay of them), a new `auto` item that changes no scope, cost or
direction, whose recommended option changes no plan text, and is no round
diff --git a/lib/approval-record.ts b/lib/approval-record.ts
index 3f3cd82bd..74418d987 100644
--- a/lib/approval-record.ts
+++ b/lib/approval-record.ts
@@ -235,7 +235,7 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} ${APPROVALS_FOOTER_END}`, ''].join('\n');
}
-interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
+interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; answer_words?: string; words_by?: string; carried_from?: string; label?: string; chosen_text?: string;
carried_gate_from?: string; carried_gate_rev?: number; carried_answered_by?: string; carried_under?: string; carry_check?: string }
const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
@@ -278,7 +278,8 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
const answerer = first.answered_by ?? 'unknown';
const carried = p1?.carried_gate_from ? p1 : undefined;
const relayed = carried?.carried_answered_by && carried.carried_answered_by !== 'human' ? `, relayed by ${carried.carried_answered_by}` : '';
- const who = carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan`
+ const who = first.words_by ? `${first.words_by}, its own decision (not the owner's words)`
+ : carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan`
: answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`;
const unseen = items.filter(r => r.carried_under).map(r => stripRef(r.title ?? ''));
const checked = [...new Set(items.map(r => r.carry_check).filter((c): c is string => !!c))];
@@ -286,7 +287,7 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App
unseen.length ? `taken under "do it", not seen by the owner: ${unseen.join('; ')}` : '', checked.length ? `post-cap delta checked by ${checked.join(', ')}` : ''].filter(Boolean).join('; ');
const built = buildRow({
ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${carried ? `gate_rev ${rev} carried forward from gate_rev ${carried.carried_gate_rev}, ` : ''}${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask),
- words: first.owner_reply ?? first.reply ?? '(no words recorded)',
+ words: first.owner_reply ?? first.answer_words ?? first.reply ?? '(no words recorded)',
by: who.slice(0, CAPS.by),
scope: scope.slice(0, CAPS.scope),
pending: (stillPending.length ? `${stillPending.length} decision(s) unanswered: ${stillPending.map(r => stripRef(r.title ?? '')).join('; ')}` : outcome === 'pending' ? 'the plan approval itself' : 'none').slice(0, CAPS.pending),
diff --git a/lib/autoplan-budget.ts b/lib/autoplan-budget.ts
index acb10f2ec..5c3e2fd84 100644
--- a/lib/autoplan-budget.ts
+++ b/lib/autoplan-budget.ts
@@ -21,7 +21,7 @@ import { FILES, RunError, journal, now, readJournal, voiceRows, type RunState, t
import { readLedger, release, setCap, summarize } from './spend-ledger';
export const DEFAULT_MAX_ROUNDS = 2;
-/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended Capy run. */
+/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended run with a spend cap or on Capy. */
export const DEFAULT_HOST_ESTIMATE_USD = 5;
/** Round r of a phase: 1 plus the times it was reopened. */
@@ -35,17 +35,20 @@ export const atCap = (state: RunState, phase: string): boolean => { const cap =
/** The phase is about to open past its cap (a rerun-all reply reopened it). */
export const overCap = (state: RunState, phase: string): boolean => { const cap = roundCap(state, phase); return cap !== null && roundOf(state, phase) > cap; };
-/** Open High or Critical findings of a closed phase: the deferred counts, else bound rows not settled or answered, an outside row paired with a native one counted once. */
+/** Open High or Critical findings of a closed phase: the deferred counts, else bound rows not settled or answered, an outside row paired (by title or line) with a High or Critical native one counted once. */
function openHigh(state: RunState, phase: string): number {
const ps = state.phases[phase];
if (ps?.deferred) return (ps.deferred.counts.High ?? 0) + (ps.deferred.counts.Critical ?? 0);
const file = path.join(state.out, `${phase}-consensus.json`);
let rows: ConsensusRecord['rows'] = [];
try { rows = fs.existsSync(file) ? (JSON.parse(fs.readFileSync(file, 'utf8')) as ConsensusRecord).rows : []; } catch {}
- const paired = new Set(rows.filter(r => r.native_id).map(r => r.outside_id));
+ const high = (s: string | undefined) => s === 'High' || s === 'Critical';
+ const native = voiceRows(state, phase, 'native');
+ const nativeHigh = new Set(native.filter(r => high(r.severity)).map(r => r.id));
+ const paired = new Set(rows.filter(r => r.native_id && r.basis !== 'file' && nativeHigh.has(r.native_id)).map(r => r.outside_id));
const answered = settledIds(state);
- return [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside')]
- .filter(r => !paired.has(r.id) && !(r as { settled_by?: string }).settled_by && !answered.has(r.id) && (r.severity === 'High' || r.severity === 'Critical')).length;
+ return [...native, ...voiceRows(state, phase, 'outside')]
+ .filter(r => !paired.has(r.id) && !(r as { settled_by?: string }).settled_by && !answered.has(r.id) && high(r.severity)).length;
}
/** At the round cap with High or Critical findings still open, more rounds buy nothing: the owner decides. */
@@ -59,11 +62,16 @@ export function roundsLine(state: RunState): string {
return `ROUNDS: ${state.phase_order.map(cell).join(' ')} max_rounds=${state.max_rounds ?? 'none'} host_spend=unknown`;
}
-/** The estimate one new attempt reserves: the flag, else the Capy unattended default, else 0 (host spend unknown, not admitted). */
+/**
+ * The estimate one new attempt reserves: the flag, else the run's persisted `--estimate-usd`, else the unattended
+ * default when the run has a spend cap or runs on Capy, else 0 (host spend unknown, not admitted). `run` prices its
+ * api attempts from the price table instead (NextOptions.price).
+ */
export function attemptEstimate(state: RunState, flag: number | undefined, env: NodeJS.ProcessEnv): { usd: number; line?: string } {
- if (flag !== undefined) return { usd: flag };
- if (state.session_kind !== 'unattended' || env.GSTACK_CAPY !== '1') return { usd: 0 };
- return { usd: DEFAULT_HOST_ESTIMATE_USD, line: `ESTIMATE: ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice reserved for each host-subagent attempt (Capy unattended default; --estimate-usd sets it); actual host spend stays unknown` };
+ const set = flag ?? state.estimate_usd;
+ if (set !== undefined) return { usd: set };
+ if (state.session_kind !== 'unattended' || (env.GSTACK_CAPY !== '1' && state.spend_cap_usd === null)) return { usd: 0 };
+ return { usd: DEFAULT_HOST_ESTIMATE_USD, line: `ESTIMATE: ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice reserved for each host-subagent attempt (unattended default under a spend cap or on Capy; --estimate-usd sets it for the rest of the run); actual host spend stays unknown` };
}
/** Stop the run: terminal `incomplete` with the structured budget_exhausted stop, journaled. Returns the STOP and RECOVER lines. */
diff --git a/lib/autoplan-carry.ts b/lib/autoplan-carry.ts
index 0e6ae18ef..b5f2ab5bd 100644
--- a/lib/autoplan-carry.ts
+++ b/lib/autoplan-carry.ts
@@ -7,11 +7,13 @@
* carried answer is the owner's own earlier words, and every refusal says which condition failed.
*
* A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b)
- * with the owner's words recorded, and not itself a carry; every change accepted at N
+ * with the owner's words recorded, and not itself a carry; nothing left pending there; every change accepted at N
* incorporated or settled; the plan file unchanged since the gate or the revise that took those
* changes, and the reviewed plan equal to it; no open urgent finding; and every new item already
- * carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are
- * "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
+ * carried. An answer given item by item (tokens, not `rec`) carries only that: every item on
+ * N+1 already carried by origin, its p1 choice replayed and its tokens recorded verbatim as the
+ * basis; any other new or changed item goes to the owner (an unreviewed capped delta, below, is the one exception). After `rec` or "do it", two kinds of new
+ * item do not block: items a "do it" covers (the owner's words are "do it": an auto item that changes no scope, cost or direction and is no round grant), and a
* capped-delta item recommending "accept", which first gets one reviewer on just the delta
* (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there
* means no carry, and the gate goes to the owner as before.
@@ -92,7 +94,8 @@ function deltaSince(state: RunState, rev: number): { diff: string; sha: string }
return { diff, sha: sha256(diff) };
}
-interface Source { id: string; gate_rev: number; words: string; by?: string }
+/** `p1`: an item-token answer's own p1 choice, replayed as given. */
+interface Source { id: string; gate_rev: number; words: string; by?: string; p1?: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
/** Every condition but the delta check; returns why not, or what the carry takes. */
@@ -105,7 +108,8 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from };
if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from };
if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from };
- if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from };
+ const tokens = parseReply(p1.reply ?? '', prev).mode !== 'rec';
+ if (p1.words_by) return { reason: `gate_rev ${from} was answered in ${p1.words_by}'s own words, not the owner's`, from };
const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined);
if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from };
const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from);
@@ -125,10 +129,10 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
if (item.id === 'p1' || item.carried) continue;
const option = item.options.find(o => o.key === item.recommended);
if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; }
- if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
+ if (!tokens && isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
return { reason: `new item ${item.id}: ${item.title}`, from };
}
- return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas };
+ return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) }, doIt, deltas };
}
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
@@ -178,7 +182,7 @@ function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Ex
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
const rev = list.gate_rev;
const p1 = list.items.find(i => i.id === 'p1')!;
- const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
+ const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? source.p1 ?? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
diff --git a/lib/autoplan-confirm.ts b/lib/autoplan-confirm.ts
index 75d9102d1..427a9d502 100644
--- a/lib/autoplan-confirm.ts
+++ b/lib/autoplan-confirm.ts
@@ -108,11 +108,15 @@ export function reviewRows(state: RunState, prev: PhaseState | undefined): Findi
const earlierRows = (state: RunState, phase: string): FindingRow[] =>
state.history.filter(h => h.event === 'reopened' && h.phase === phase).flatMap(h => reviewRows(state, h.previous as PhaseState | undefined));
-/** Earlier finding id -> the answered decision whose gate item named it. */
+/** The origin tag of an open Critical/High finding's gate item: only `a` (its fix applied to the plan) settles it. */
+export const FINDING_ORIGIN = 'finding';
+
+/** Earlier finding id -> the answered decision whose gate item named it. A finding carried into the build stays open. */
export function settledIds(state: RunState): Map {
const out = new Map();
for (const d of readDecisions(state.out)) {
if (d.chosen == null || d.status === 'pending' || !d.origin) continue;
+ if (d.origin.endsWith(`|${FINDING_ORIGIN}`) && d.chosen !== 'a') continue;
for (const id of (d.origin.split('|')[1] ?? '').split('+')) if (id && id !== 'null') out.set(id, d.id);
}
return out;
diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts
index 1488dbc05..601ee0d75 100644
--- a/lib/autoplan-gate.ts
+++ b/lib/autoplan-gate.ts
@@ -3,7 +3,7 @@
* (plan B2, B9, B11). `closePhase` reconciles the outside pass against the
* native one from canonical ids (lib/autoplan-reconcile.ts), writes the
* consensus record and the timing row, and marks the phase closed.
- * `finishGate` turns disagreements and user challenges into one gate list
+ * `finishGate` turns disagreements, user challenges and open Critical/High findings into one gate list
* (lib/gate-list.ts: `d` auto items, `uc` approval items, `p1` the plan
* approval), writes decisions.jsonl, tasks.jsonl and the exported files, and
* leaves the run `gate_pending`: unattended never approves. `answerGate`
@@ -26,9 +26,9 @@ import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows,
import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget';
import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry';
import { buildEli5, capyRelayLine } from './captain-brief';
-import { previousReview, reviewRows, settledIds } from './autoplan-confirm';
+import { FINDING_ORIGIN, previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
-import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
+import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { readJsonl, sha256File } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
@@ -205,14 +205,15 @@ export function buildGate(state: RunState, gateRev: number): GateList {
const byId = new Map([...native, ...outside].map(f => [f.id, f]));
const reran = gateRev > 1 && state.history.some(h => h.event === 'reopened' && h.phase === phase);
const origin = (ids: string, options: GateOption[]) => [phase, ids, ps.closed_at, ps.snapshot, optionHash(options)].join('|');
- const push = (item: GateItem, ids: string) => items.push({ ...item, origin: origin(ids, item.options) });
+ const push = (item: GateItem, ids: string, tag?: string) => items.push({ ...item, origin: [origin(ids, item.options), ...(tag ? [tag] : [])].join('|') });
const challenged = new Set();
+ const twins = new Map(consensus.rows.filter(r => r.native_id && r.basis !== 'file' && (r.disposition === 'confirmed' || r.disposition === 'unverified')).map(r => [r.native_id!, r.outside_id]));
for (const row of consensus.rows) {
const o = byId.get(row.outside_id)! as FindingRow & { user_challenge?: boolean; changes?: string; settled_by?: string };
if (o.settled_by) continue;
const n = (row.native_id ? byId.get(row.native_id) : undefined) as (FindingRow & { user_challenge?: boolean; changes?: string }) | undefined;
const ids = `${row.native_id}+${row.outside_id}`;
- if (o.user_challenge && n?.user_challenge && row.disposition !== 'new') {
+ if (o.user_challenge && n?.user_challenge && row.disposition !== 'new' && row.basis !== 'file') {
challenged.add(o.id); challenged.add(n.id);
const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
@@ -223,6 +224,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
continue;
}
if (row.disposition === 'disagree') {
+ challenged.add(o.id); challenged.add(n!.id);
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
@@ -235,6 +237,20 @@ export function buildGate(state: RunState, gateRev: number): GateList {
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
+ // An open Critical or High finding the items above do not cover: rec applies its fix to the plan.
+ const pairedOutside = new Set(twins.values());
+ for (const f of [...native, ...outside] as Array) {
+ if (f.voice === 'outside' && pairedOutside.has(f.id)) continue;
+ const group = [f, ...(twins.has(f.id) ? [byId.get(twins.get(f.id)!)!] : [])] as Array;
+ if (group.some(g => challenged.has(g.id) || g.user_challenge || g.settled_by)) continue;
+ const severity = group.some(g => g.severity === 'Critical') ? 'Critical' : group.some(g => g.severity === 'High') ? 'High' : null;
+ if (!severity) continue;
+ const ids = group.map(g => g.id);
+ const fixes = [...new Set(group.map(g => g.resolution).filter((r): r is string => !!r))];
+ push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, finding: ids,
+ why: `an open ${severity} finding (${group.map(g => g.voice).join(' + ')}); rec applies its fix to the plan`, cost: `a reopens ${phase}${phase === 'eng' ? '' : ' and eng'} once the plan is revised`,
+ options: [{ key: 'a', text: `apply the fix to the plan: ${fixes.length ? fixes.join('; ') : `resolve "${f.title}"`}`, changes_plan: true }, { key: 'b', text: 'carry it into the build as a blocking task (no plan change)', changes_plan: false }] }, ids.join('+'), FINDING_ORIGIN);
+ }
}
items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` }));
items.push(planApproval(state, items));
@@ -277,7 +293,7 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
writeDecisions(state.out, readDecisions(state.out).map(r => {
const i = updated.get(r.id);
if (!i) return r;
- return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) };
+ return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: autoDefault(i) } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) };
}));
const hash = recHash(list);
journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash });
@@ -344,6 +360,8 @@ export interface AnswerOptions {
recHash?: string;
/** The owner's verbatim words; required for every `--by agent:` answer. */
ownerReply?: string;
+ /** `agent:` when those words are the answering agent's own: recorded as `answer_words`/`words_by`, never as `owner_reply`, and never carried. */
+ wordsBy?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision and the owner's words it reuses. Replaces ownerReply and its re-parse. */
carry?: { from: string; gate_rev: number; words: string; by?: string; reason: string };
}
@@ -396,22 +414,24 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
const currentHash = recHash(list);
if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3);
if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit tokens`, 3);
- if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
+ const takesRecs = parsed.mode === 'rec' || parsed.choices.some(c => c.id === 'p1' && c.chosen === 'b' && !c.prior);
+ if (takesRecs && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); \`rec\` and \`p1b\` take its recommendations, so pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
+ : o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
- const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => {
+ const record = (answered = parsed) => writeDecisions(state.out, applyReply(rows, answered, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
return c ? { ...r, chosen_text: c.text } : r;
}));
if (!plan || parsed.pending.length) {
- // Approvals unanswered: record what was answered, keep the gate pending; nothing reviewed changes.
- record();
+ // Approvals unanswered: record what was answered, but not an auto default that p1b would change (the final answer decides it).
+ record({ ...parsed, choices: parsed.choices.filter(c => c.how !== 'auto_default' || c.chosen === itemOf(c.id).recommended) });
const pending = parsed.pending.length ? parsed.pending : ['p1'];
state.gate = { ...(state.gate ?? {}), last_reply: reply, pending };
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (${pendingHint(list, pending)})`);
@@ -421,7 +441,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
const carried = (c: GateChoice) => itemOf(c.id).carried?.chosen === c.chosen;
const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : [];
const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`);
- const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1');
+ const overrides = parsed.choices.filter(c => c.id !== 'p1' && (c.how === 'override' || (c.how === 'auto_default' && c.chosen !== itemOf(c.id).recommended)));
const reopenSet = new Set(plan.chosen === 'd' ? state.phase_order : []);
record();
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts
index 48b64ff19..76cfa03aa 100644
--- a/lib/autoplan-reconcile.ts
+++ b/lib/autoplan-reconcile.ts
@@ -196,14 +196,15 @@ export interface ReconciliationRow {
outside_id: string; native_id: string | null; disposition: ReconciliationDisposition;
/** Why this disposition: the matched location or title, or the parent's override reason. */
resolution: string;
- basis: 'file_line' | 'title' | 'override' | 'none';
+ basis: 'file_line' | 'file' | 'title' | 'override' | 'none';
severity: { outside: Severity; native: Severity | null };
/** A parent override's one shared proposal for both voices' user challenge; the gate renders it as option b. */
proposal?: string;
}
export interface ReconciliationOverride { outside_id: string; native_id?: string | null; disposition: ReconciliationDisposition; resolution: string; proposal?: string }
-export const tokens = (title: string) => new Set(title.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(t => t.length > 2));
+/** A DX cap code (`CAP4::- `) names a location, not the problem, so title comparison skips it. */
+export const tokens = (title: string) => new Set(title.replace(/^\s*CAP[46]\s*:[^:]*:\s*/i, '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(t => t.length > 2));
export function jaccard(a: Set
, b: Set): number {
if (!a.size || !b.size) return 0;
let inter = 0;
@@ -225,7 +226,9 @@ function contradictory(a: string, b: string): boolean {
/**
* Structural reconciliation. Findings associate by a title overlap of at least
- * half the words, or by file:line (±3 lines). Association alone never
+ * half the words (a leading CAP code ignored; never across two different files),
+ * or by file:line (±3 lines), or, when either finding has no line, by the same
+ * file. Association alone never
* confirms: a title association is `confirmed` only when both voices state the
* same remedy, `disagree` when severities differ by two or more levels or the
* remedies contradict (one keeps, drops or negates what the other changes),
@@ -250,6 +253,8 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
}
const used = new Set();
const rows: ReconciliationRow[] = [];
+ const later: Array<[number, FindingRow]> = [];
+ const unmatched = (o: FindingRow): ReconciliationRow => ({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } });
for (const o of outside) {
const override = byOutside.get(o.id);
if (override) {
@@ -264,13 +269,14 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
let match: FindingRow | undefined;
let best = 0;
for (const n of native) {
- if (used.has(n.id)) continue;
+ if (used.has(n.id) || (o.file && n.file && o.file !== n.file)) continue;
const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0);
if (score >= 0.5 && score > best) { best = score; match = n; }
}
const titled = !!match;
match ??= native.find(n => !used.has(n.id) && near(n));
- if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; }
+ if (!match && o.file) { later.push([rows.length, o]); rows.push(unmatched(o)); continue; }
+ if (!match) { rows.push(unmatched(o)); continue; }
used.add(match.id);
const basis: ReconciliationRow['basis'] = near(match) ? 'file_line' : 'title';
const where = near(match) ? `${o.file}:${o.line}` : 'same title';
@@ -286,6 +292,15 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
if (remedyKey(match.resolution) !== remedyKey(o.resolution)) { pair('unverified', `same title, remedies differ (${where}): ${match.title}`); continue; }
pair('confirmed', `both voices, same remedy (${where}): ${match.title}`);
}
+ // Plan reviews name a file without a line: same file, a line missing on either side, once every title and line match is placed.
+ for (const [i, o] of later) {
+ const ot = tokens(o.title);
+ const match = native.filter(n => !used.has(n.id) && n.file === o.file && (o.line === undefined || n.line === undefined))
+ .reduce((b, n) => (!b || jaccard(ot, tokens(n.title)) > jaccard(ot, tokens(b.title)) ? n : b), undefined);
+ if (!match) continue;
+ used.add(match.id);
+ rows[i] = { outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same file, agreement unverified (${o.file}): ${match.title}`, basis: 'file', severity: { outside: o.severity, native: match.severity } };
+ }
return { rows, native_only: native.filter(n => !used.has(n.id)).map(n => n.id), errors };
}
diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts
index d6a4fbdbd..460f15dad 100644
--- a/lib/autoplan-run.ts
+++ b/lib/autoplan-run.ts
@@ -142,7 +142,11 @@ export function voiceState(state: RunState, phase: Phase, voice: Voice): VoiceSt
}
const openAttempt = (v: VoiceState) => v.attempts?.find(a => a.state === 'dispatched' || a.state === 'execution_unknown');
-export interface NextOptions { out: string; cwd: string; estimateUsd?: number; redispatch?: string[] }
+export interface NextOptions {
+ out: string; cwd: string; estimateUsd?: number; redispatch?: string[];
+ /** `run`: a voice's priced reservation from its prompt (undefined when unpriced); `--estimate-usd` wins over it. */
+ price?: (voice: Voice, promptFile: string) => number | undefined;
+}
export interface NextResult { state: RunState; phase?: Phase; attempts: Array<{ attempt: string; voice: Voice; prompt: string; result: string; model_family: string; new: boolean }>; lines: string[] }
function openPhase(state: RunState, phase: Phase, cwd: string): void {
@@ -219,15 +223,18 @@ export function nextStep(o: NextOptions): NextResult {
if (ps.outside?.reason === 'docs_only') lines.push(`OUTSIDE: ${phase} not_run reason=docs_only (native voice only)`);
if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(state.scope)}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
const prompts = ps.prompts!;
+ if (o.estimateUsd !== undefined) state.estimate_usd = o.estimateUsd;
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
- if (estimate.line) lines.push(estimate.line);
+ const usdFor = (voice: Voice) => (state.estimate_usd === undefined ? o.price?.(voice, prompts[voice]) : undefined) ?? estimate.usd;
+ if (estimate.line && (['native', 'outside'] as const).some(voice => usdFor(voice) === estimate.usd)) lines.push(estimate.line);
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
const before = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd));
- if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + fresh.length * estimate.usd > state.spend_cap_usd + 1e-9) {
- const stop = stopForBudget(state, phase, fresh.length * estimate.usd);
+ const asked = fresh.reduce((sum, voice) => sum + usdFor(voice), 0);
+ if (state.spend_cap_usd !== null && fresh.length && before.worst_case_usd + asked > state.spend_cap_usd + 1e-9) {
+ const stop = stopForBudget(state, phase, asked);
writeState(state);
- throw new RunError('SPEND_CAP_EXCEEDED', `spent ${before.spent_usd} + reserved ${before.reserved_usd} + unknown ${before.unknown_usd} + ${fresh.length} x estimate ${estimate.usd} > cap ${state.spend_cap_usd}; the run stopped incomplete (budget_exhausted)`, 3, { status: 'incomplete', lines: [...lines, ...stop] });
+ throw new RunError('SPEND_CAP_EXCEEDED', `spent ${before.spent_usd} + reserved ${before.reserved_usd} + unknown ${before.unknown_usd} + ${fresh.length} attempt(s) reserving ${+asked.toFixed(6)} > cap ${state.spend_cap_usd}; the run stopped incomplete (budget_exhausted)`, 3, { status: 'incomplete', lines: [...lines, ...stop] });
}
for (const voice of ['native', 'outside'] as const) {
const v = voiceState(state, phase, voice);
@@ -243,11 +250,12 @@ export function nextStep(o: NextOptions): NextResult {
continue;
}
const attempt = `${state.run}-a${++state.attempt_counter}`;
- try { reserve(path.join(state.out, FILES.spend), attempt, estimate.usd, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); }
+ const usd = usdFor(voice);
+ try { reserve(path.join(state.out, FILES.spend), attempt, usd, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); }
catch (e: any) { if (e.code === 'SPEND_CAP_EXCEEDED') { state.attempt_counter--; writeState(state); throw new RunError('SPEND_CAP_EXCEEDED', e.message, 3); } throw e; }
- const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: estimate.usd, dispatched_at: now() };
+ const row: AttemptState = { attempt, state: 'dispatched', prompt: prompts[voice], result: path.join(state.out, attemptFile(phase, voice, attempt.slice(state.run.length + 1), 'md')), estimate_usd: usd, dispatched_at: now() };
bindPromptResult(row.prompt, row.result);
- journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), prompt_bytes: fs.statSync(row.prompt).size, result: path.relative(state.out, row.result), estimate_usd: estimate.usd });
+ journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), prompt_bytes: fs.statSync(row.prompt).size, result: path.relative(state.out, row.result), estimate_usd: usd });
(v.attempts ??= []).push(row);
v.attempt = attempt; v.status = 'running';
result.attempts.push({ attempt, voice, prompt: row.prompt, result: row.result, model_family: familyHint(state, voice), new: true });
diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts
index 01b345688..d41b1953a 100644
--- a/lib/autoplan-state.ts
+++ b/lib/autoplan-state.ts
@@ -58,7 +58,7 @@ export interface DispatchCounts { total: number; by_phase: Record; native_model?: string; outside_model?: string; outside_enabled: boolean; attempt_counter: number;
- spend_cap_usd: number | null; deadline_at?: string; light?: boolean; history: Array>; created_at: string; updated_at: string;
+ spend_cap_usd: number | null; /** `next --estimate-usd`, kept for every later attempt. */ estimate_usd?: number; deadline_at?: string; light?: boolean; history: Array>; created_at: string; updated_at: string;
/** The gstack that last wrote this run (lib/autoplan-migrate.ts); absent on runs written before 1.91.96. */
gstack_version?: string;
/** The --plan file the owner edits (drift hints, revise), the --plan-heading it was cut from, and the scope flags revise re-detects with. */
diff --git a/lib/dx-error-contract.ts b/lib/dx-error-contract.ts
index d4c920110..03f871cea 100644
--- a/lib/dx-error-contract.ts
+++ b/lib/dx-error-contract.ts
@@ -14,9 +14,9 @@ export const DX_ERROR_ITEMS = ['what', 'fix', 'recovery', 'code', 'context', 'tr
export const DX_ERROR_DOC = 'https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md';
export const DX_ERROR_COMPACT =
- `Score every error path against the agent-ready error contract (${DX_ERROR_DOC}): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or the precise thing a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). ` +
+ `Score every error path against the agent-ready error contract (${DX_ERROR_DOC}): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or what a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). ` +
'Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. ' +
- 'Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure that names no non-interactive alternative such as --yes) as a Critical finding titled "CAP4: