diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 48cad0438..333eaaeba 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -42,13 +42,15 @@ line yourself; gstack never writes the plan. - Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by hand is not an autoplan run: do not present it as one. -- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest - survives machine replacement; restore with `resume --out --from /`. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder (or opt every run in + with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a + generation is published at rest, after each bound voice and after each phase transition; restore + with `resume --out --from /`, which never redispatches a bound voice. ### Commands -- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--scaffold]` - first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]` + first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 3 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd says otherwise, so --spend-cap binds. - `gstack-autoplan next --out [--redispatch ]` later calls: open the next phase or reprint open attempts; never redispatches on its own. - `gstack-autoplan submit --out --phase

--voice native|outside --record --model [--attempt ]` @@ -69,8 +71,14 @@ line yourself; gstack never writes the plan. 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 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:` + the owner's journaled cap raise; after a budget stop, `resume` continues the run. - `gstack-autoplan export --out ` write the artifact set; then `gstack-artifact validate /run.json`. +- `gstack-autoplan export --out --public ` + a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine. - `gstack-autoplan status|resume --out ` read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). - `gstack-autoplan resume --out --from ` @@ -137,8 +145,8 @@ Merge conditions. A lane's PR merges only when: the required gate lanes are gree Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under `## Behavior changes` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. -Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops with `status=budget_exhausted` and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero. +Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops `incomplete` (`STOP: reason=budget_exhausted`) and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero. -Before done. A lane runs `gstack-pregate run --allow-repo-commands` on its branch (the unit tier on the changed files plus the repo's guards) and reports done only after it passes. In a repo whose `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push, because a PR tool may not run local hooks. +Before done. A lane pushes a WIP commit to its branch after each step and gates a pushed SHA. It runs `gstack-pregate run --allow-repo-commands` on its branch and reports done only after it passes. Where `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push. Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, the `gstack: ok|fail ` line, the `PREGATE:` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be. diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 11b00c2ee..f74c2eb44 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -39,8 +39,10 @@ line yourself; gstack never writes the plan. - Always drive `gstack-autoplan`; never hand-adapt `autoplan/SKILL.md`. A plan you reviewed by hand is not an autoplan run: do not present it as one. -- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder so a run at rest - survives machine replacement; restore with `resume --out --from /`. +- Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to the private project-user Drive folder (or opt every run in + with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a + generation is published at rest, after each bound voice and after each phase transition; restore + with `resume --out --from /`, which never redispatches a bound voice. ### Commands diff --git a/bin/gstack-artifact b/bin/gstack-artifact index 88f7077ca..277b14d48 100755 --- a/bin/gstack-artifact +++ b/bin/gstack-artifact @@ -3,6 +3,8 @@ * gstack-artifact — validate, print and acknowledge the unattended artifact contract. * * gstack-artifact validate [--json] completion check for a run directory + * gstack-artifact validate --public [--private ] [--json] + * a public run copy: provenance, recorded machine roots * gstack-artifact validate --as [--run ] [--json] * one artifact against its schema * gstack-artifact schema | --list JSON Schema this version validates @@ -19,10 +21,14 @@ import { } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { resolveStateRoot } from '../lib/state-root'; +import { validatePublic } from '../lib/autoplan-public'; const HELP = `gstack-artifact — the unattended artifact contract (docs/unattended.md) validate [--json] run-directory completion check + validate --public [--private ] a public run copy (gstack-autoplan export --public): provenance digests; + with --private (on the exporting machine) any recorded root fails + (ARTIFACT_MACHINE_PATH, file:line); home-path patterns WARN validate --as [--run ] [--json] one artifact against its schema schema | --list JSON Schema for ${ARTIFACT_NAMES.join(', ')} ack --consumer [--json] set run.json.consumed_by (idempotent) and log the ack @@ -57,6 +63,18 @@ function main(argv: string[]): number { return EXIT.ok; } + if (cmd === 'validate' && flag(rest, '--public') !== undefined) { + const dir = flag(rest, '--public')!; + const result = validatePublic(dir, flag(rest, '--private')); + if (json) process.stdout.write(JSON.stringify({ valid: result.valid, run: result.run ?? null, public: dir, errors: result.errors, warnings: result.warnings }) + '\n'); + else { + printErrors(result.errors); + for (const w of result.warnings) process.stderr.write(w + '\n'); + process.stdout.write(result.valid ? `ARTIFACT_VALID: public run=${result.run} warnings=${result.warnings.length}${flag(rest, '--private') ? '' : ' (recorded roots unchecked: pass --private on the exporting machine)'}\n` : `ARTIFACT_INVALID: ${result.errors.length} error(s)\n`); + } + return result.valid ? EXIT.ok : EXIT.fail; + } + if (cmd === 'validate') { const file = rest.find(a => !a.startsWith('--') && a !== flag(rest, '--as') && a !== flag(rest, '--run')); if (!file) return usage('validate needs a file'); diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index becab9f1d..bf8d1d7a5 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -15,26 +15,32 @@ * 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 * gstack-autoplan export --out [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes + * gstack-autoplan export --out --public a machine-path-free copy in ; the private run is only read * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran + * gstack-autoplan close --out --phase

--deferred "" --by > close a phase accepted-with-findings + * gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change * gstack-autoplan checkpoints --from list checkpoint generations, newest first, with hash ok|bad * gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2) * * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), * lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts. * Workflow commands end with GSTACK_RESULT: skill=autoplan status= run=; the run is never approved here. - * Mutating verbs (next, submit, answer, recommend, revise, export, run) pass admission first (lib/autoplan-admission.ts) + * Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts) * and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock. */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { amendmentSteps, answerGate, recommendGate } from '../lib/autoplan-gate'; -import { checkpointLines, checkpointRun, restoreCheckpoint } from '../lib/autoplan-checkpoint'; +import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate'; +import { changeBudget, liftStop } from '../lib/autoplan-budget'; +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'; import { exportRun } from '../lib/autoplan-export'; +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, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run'; +import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } 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'; @@ -46,7 +52,9 @@ const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer DRIVER: ${driverFile()} next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] - [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] + [--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] + --max-rounds (first next): review rounds per phase (default 3 unattended); at the cap a phase closes accepted-with-findings + --estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds --scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added --light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result @@ -73,6 +81,14 @@ DRIVER: ${driverFile()} (a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest publishes a generation under // and prints CHECKPOINT: on stderr) checkpoints --from list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current + close --out --phase

--deferred "" --by > + close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their + reservation), open findings into tasks.jsonl; Critical, urgent and user-challenge findings become gate items + budget --out

[--spend-cap ] [--max-rounds ] --by > + the owner's cap change, journaled; a run stopped incomplete (budget_exhausted) resumes with resume afterwards + export --out --public + write a machine-path-free copy (recorded roots rewritten, plan.md code kept) with provenance digests; the private + run is not touched; refused (ARTIFACT_MACHINE_PATH) when a recorded root would remain status --out [--json] export --out release --out --attempt contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) @@ -132,10 +148,13 @@ function mutating(out: string, json: boolean, fn: () => number): number { }); } +/** Set by a command whose success is a checkpoint moment beyond rest: a bound voice, a phase transition, the first next. */ +let moment: CheckpointMoment | undefined; + /** A checkpoint never changes a committed command's outcome: a failure is a stderr warning, the exit code stands. */ function checkpointQuietly(out: string): void { try { - const line = checkpointRun(out); + const line = checkpointRun(out, process.env, moment); if (line) process.stderr.write(line + '\n'); } catch (e: any) { process.stderr.write(`CHECKPOINT: failed for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands, the previous checkpoint stays current\n`); @@ -150,15 +169,19 @@ function emit(json: boolean, lines: string[], payload: Record): function doNext(out: string, rest: string[], json: boolean): number { const cwd = process.cwd(); const initLines: string[] = []; - if (!fs.existsSync(path.join(out, FILES.run))) { + const fresh = !fs.existsSync(path.join(out, FILES.run)); + if (fresh) { const plan = flag(rest, '--plan'); if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan on the first next`, 3); const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined; - initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, + 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); + initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds, 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 } }, initLines); } const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : [])); const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch }); + moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined; const status = r.state.status; const lines = [...initLines, ...r.lines]; if (status === 'gate_pending') lines.push(`GATE: gate_rev=${r.state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); @@ -176,6 +199,7 @@ function doSubmit(out: string, rest: string[], json: boolean): number { const outcome = unavailable !== undefined || failed !== undefined; if (outcome && (voice !== 'outside' || result || record || (unavailable !== undefined && failed !== undefined))) return usage('--unavailable|--failed "" records the outside voice only, without --result or --record'); if (!outcome && (!!result === !!record || !model)) return usage('submit needs exactly one of --result | --record , and --model '); + moment = 'bound'; const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'), unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() }); const status = r.state.status; @@ -199,6 +223,34 @@ function doRevise(out: string, rest: string[], json: boolean): number { return EXIT.ok; } +function doClose(out: string, rest: string[], json: boolean): number { + const phase = flag(rest, '--phase'); const reason = flag(rest, '--deferred'); const by = flag(rest, '--by'); + if (!phase || !reason?.trim()) return usage('close needs --phase

--deferred "" --by >'); + if (by === undefined || !ANSWERED_BY_RE.test(by)) throw new RunError('ANSWER_BY_REQUIRED', `close records who deferred the phase: pass --by human or --by agent: (got ${JSON.stringify(by ?? null)})`, 3); + const state = readState(out); + 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; + if (!stopped && !['running', 'awaiting_result', 'interrupted'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; close works on a running or budget-stopped run`, 3); + 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(`GATE: gate_rev=${state.gate_rev} render with: gstack-gate render ${path.join(out, 'gate.json')}`); } + else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); } + writeState(state); + lines.push(resultLineFor(state.status, out)); + emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out }); + return EXIT.ok; +} + +function doBudget(out: string, rest: string[], json: boolean): number { + const state = readState(out); + 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 }); + return EXIT.ok; +} + async function doRun(out: string, rest: string[], json: boolean): Promise { const nativeModel = flag(rest, '--native-model'); const outsideModel = flag(rest, '--outside-model'); const outsideRunner = (flag(rest, '--runner') ?? 'codex-cli') as RunnerId; const nativeRunner = (flag(rest, '--native-runner') ?? 'api') as RunnerId; @@ -259,6 +311,8 @@ async function main(argv: string[]): Promise { if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json)); if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json)); if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json)); + if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json)); + if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json)); if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); } if (cmd === 'status') { const state = readState(out); @@ -305,6 +359,14 @@ async function main(argv: string[]): Promise { return EXIT.ok; }); } + if (cmd === 'export' && flag(rest, '--public') !== undefined) { + return withRunLock(out, () => { + const r = exportPublic(readState(out), path.resolve(flag(rest, '--public')!)); + for (const w of r.warnings) process.stderr.write(w + '\n'); + emit(json, r.files.map(f => `EXPORTED_PUBLIC: ${f}`), { files: r.files, warnings: r.warnings }); + return EXIT.ok; + }); + } if (cmd === 'export') { return mutating(out, json, () => { const state = readState(out); @@ -328,7 +390,8 @@ async function main(argv: string[]): Promise { } catch (e: any) { if (e instanceof RunError) { process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n'); - if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); + if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n'); + if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n'); return e.exit === 3 ? EXIT.refused : EXIT.fail; } process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 2399f6a84..c449d8da8 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -213,6 +213,8 @@ gstack_result_anchor() { BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;; IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;; OUTSIDE_VOICE_UNAVAILABLE) printf '%s' 'outside-voice-unavailable' ;; + ARTIFACT_MACHINE_PATH) printf '%s' 'artifact-machine-path' ;; + ARTIFACT_PUBLIC_TARGET_EXISTS) printf '%s' 'artifact-public-target-exists' ;; *) return 1 ;; esac } diff --git a/docs/designs/field-list-2-2026-10-16/PLAN.md b/docs/designs/field-list-2-2026-10-16/PLAN.md new file mode 100644 index 000000000..ae5798242 --- /dev/null +++ b/docs/designs/field-list-2-2026-10-16/PLAN.md @@ -0,0 +1,147 @@ + +# gstack field list 2 (October 16, 2026): what the gbrain crew needs from gstack + +## Implementation plan + +### Context + +This is plan revision 2. Revision 1 was reviewed by six voices (CEO, DX, Eng; Claude Opus 5.5 native, GPT-6.1 Sol outside): 50 findings, 0 Critical, 23 High, one user challenge (defer W13's automatic scope). This revision folds every finding in; the Review record lists each phase and how it was handled. Run budget: at most 3 review rounds (this is round 2); a round-3 phase with open findings closes accepted-with-findings, deferred to the build. + +Owner request (Garry, via GBRA-108, 2026-10-16): "go ahead and turn that into a new thread and get it on the merge queue". Inputs: GBRA-40's field list 2 (items 1-10 plus Step 0) and GBRA-108's addendum (items 11-14), collected from the gbrain threads' transcripts Oct 14-16. Coordinator: GStack Minder (GSTA-36) owns the merge queue and does every merge; this wave opens PRs and sends READY reports. + +Base: origin/main `3e8b34d` (v1.91.106.0). **Every wave PR builds on top of #3190** (`capy/next-wave`, v1.91.107.0, GSTA-31, READY and merging next), because almost every item touches files #3190 rewrites (bin/gstack-capy-install, bin/gstack-capy-session, capy/skill/gstack/**, lib/autoplan-*.ts, lib/pregate/{index,config,context,runtime,supervise,types}.ts, lib/pregate/checks/{guards,lanes}.ts, lib/plan-reality.ts, lib/ship-receipt.ts, lib/result-codes.ts, ship/**, autoplan/DRIVER.md.tmpl, docs/{troubleshooting,pregate,unattended,capy-install}.md). Code citations below are against #3190's head `03968f0`. Other in-flight owners this wave must not collide with: #3200 (GSTA-32: review-army tests), #3225 (GSTA-37: bin/gstack-upgrade Windows path, lib/pregate/checks/secrets.ts, lib/redact-patterns.ts, browse rule 3, several review-section templates), #3226 (GSTA-35: autoplan/SKILL.md.tmpl, autoplan/sections/dx-phase.md.tmpl, plan-devex-review/**, devex-review/**, lib/dx-error-*.ts, scripts/resolvers/{dx,index}.ts), #3215 (community: lib/pregate/docs-lane.ts, lane-selection.ts). A lane that needs one of those files waits for that PR to merge and builds on its version; it never forks a competing edit. + +**Already handled outside this plan.** Step 0 (#3190 merge, #3209 already merged as v1.91.106.0, fresh-machine acceptance on a gbrain lane) belongs to GSTA-31/GStack Minder. Two fast-track PRs ship without this gate because they fix wrong results: **F1** (item 13, install honesty) and **F2** (item 12 part 1, cso partial-input disclosure). They are described here so reviewers see the whole wave, but the gate does not decide them. + +**Triage correction that shapes the wave.** Item 13's evidence ("most gbrain machines report `gstack: ok 1.91.101.0` while main is 1.91.106.0") is mostly a timing artifact: main was v1.91.101.0 from 2026-10-14 17:36Z until #3212 landed 2026-10-16 20:29Z; v1.91.102-104 were stamps on unmerged PRs. The lanes' trailers were current. The real defect is narrower: `--check` prints `gstack-capy-session: behind origin/: -> ( commits); fix: …` on its own line (bin/gstack-capy-session), but lanes report only the `gstack:` trailer, so "behind" is lost in every handoff. Likewise item 1's core tools already exist: `gstack-restamp --after ` (gate-ahead in a throwaway worktree, records .gstack/tmp/gate-ahead.json), `gstack-tree-receipt` (`TREE: same-modulo-stamps`, stamp_files from .gstack/ship-policy.json, `gate-reuse: eligible`) and `gstack-ship-receipt` (identifiers-first handoff block). gbrain already commits a ship policy (`.gstack/ship-policy.json`, gbrain 90feaab2, 2026-10-13: stamp-at-merge, 13 stamp_files, 5 stamp_patterns), so tree-receipt can prove its stamps today. The gbrain queue hand-proved stamp diffs because nothing in /ship strings these tools into the coordinator protocol and the coordinator has no command to re-verify a lane's claim. + +Repo constraints: SKILL.md is generated from .tmpl (`bun run gen:skill-docs`, `--host codex`); logic in lib/*.ts behind thin bin/ entrypoints; module-size ratchet (800 lines/module, 150/function); result codes in lib/result-codes.ts with docs/troubleshooting.md anchors; every test subprocess has a timeout (spawnsync tripwire); parity-suite and context-budget ratchets for template edits; E2E Evals run on every PR push (paid gates pre-approved; batch pushes). + +Thesis: **the gbrain crew's coordinator stops proving things by hand: stamps, approvals, flakes and stale installs become lines a tool prints, and no gstack report claims more coverage than it had.** + +### Release promises + +- **P1. A coordinator can run a shared merge queue from printed lines,** with no hand-built stamp diff: ready report, stamp with proof, gate-ahead, restamp on reorder. +- **P2. Nothing that needs the owner's approval ships without a cited approval,** and data repairs never run at upgrade. +- **P3. No report overstates what it checked:** cso lists what it skipped (F2), the install trailer says when it is behind (F1), exported artifacts carry no machine paths, lanes and reviewers always give a real `gstack:` trailer. +- **P4. Timing-dependent tests are caught before merge,** and /investigate has a flake playbook that never raises a bound. +- **P5. /autoplan honors a budget:** a round cap and spend cap the runner enforces, a phase can close accepted-with-findings deferred to build, and confirm rounds review only the delta. + +### Premise table + +| premise | claim | receipt | verdict | +|---|---|---|---| +| P-a | main was v1.91.101.0 from Oct 14 17:36Z to Oct 16 20:29Z | `git log --format='%h %cI' origin/main`: 47d639a 2026-10-14T17:36:39Z, a889ef9 2026-10-16T13:29:38-07:00 | true | +| P-b | --check prints "behind" only off the trailer | bin/gstack-capy-session (#3190 head) check branch, `echo "gstack-capy-session: behind origin/…"` | true | +| P-c | #3190's `latest:` note needs bootstrapper wrapper 2; the Drive copy is wrapper 1 | capy/skill/gstack/scripts/install.sh `GSTACK_CAPY_WRAPPER=2` on #3190; this machine printed `gstack-capy-skill: wrapper 1` | true | +| P-d | restamp --after, tree-receipt and ship-receipt exist | bin/gstack-restamp:5, bin/gstack-tree-receipt:5, bin/gstack-ship-receipt:5 | true | +| P-e | gbrain commits a ship policy with stamp_files | `gh api repos/garrytan/gbrain/contents/.gstack/ship-policy.json` (829 bytes, commit 90feaab2) | true (rev 1 said false) | +| P-f | autoplan has --spend-cap but host-subagent attempts reserve $0 | lib/autoplan-run.ts:212; this run's spend.json `estimate_usd: 0` | true | +| P-g | cso refuses over 64 MiB and advises "a smaller checkout" | lib/cso/snapshot.ts:54,66 | true | +| P-h | cso counts withheld >1 MiB files at full size toward the cap | lib/cso/snapshot.ts:683 (size added before read) | true | +| P-i | GBRA-102's restamp refusal ("another PR's entry above master") | field list only | unverified | +| P-j | #6792 shipped a take-deleting accept path | field list; gbrain #6807 hotfix | unverified (gbrain repo) | +| P-k | GBRA-117's runner forced Eng after a stop request | addendum only | unverified | + +unverified=3 + +### Already done + +`git log origin/main --since=2026-10-14` for the named files: #3209 (v1.91.106.0) and #3212 (v1.91.105.0) touch none of W1-W12's new surfaces. #3190 (open) already ships pregate's `requires-remote` path, the autoplan checkpoint dir, `rec` gate replies and the `latest:` trailer note; this plan builds on them and does not redo them. + +### Surface check + +Verified with `--help` or source on #3190's head: `gstack-restamp --after/--expect-base/--expect-order`, `gstack-tree-receipt --gated/--from`, `gstack-ship-receipt write/read/census`, `gstack-autoplan next --spend-cap/--estimate-usd`, `gstack-autoplan revise`, `gstack-lane-check --planned`, `gstack-cso start --diff/--base/--scope`. New surfaces are named as new. + +### Binding decisions + +- Every merge is GStack Minder's; lanes send READY and never stamp before a slot call (CREW.md 6). +- Files owned by #3190, #3200, #3225, #3226, #3215 are built on after they merge, never forked. +- Autoplan budget for this run: 3 rounds, then accepted-with-findings (GStack Minder, 2026-10-16). + +### Numbers + +- Rev-1 review: 50 findings, 23 High, 0 Critical [measured: run findings.jsonl]. Queue cost per extra PR: one slot, one stamp, one free suite on Ubicloud, one paid E2E run per push (~$93 [measured: GSTA-37 run 38969924945, $93.06]). +- gbrain source bytes admitted vs withheld under cso's counting [re-measure before build: W13 spike]. + +### Deferred asks + +- Item 12 "cso never needs the whole repo / worst sibling": the automatic sibling expansion (W13) is deferred behind a measurement spike; F2 ships the explicit, disclosed scope now. +- Item 13 "session start upgrades automatically": already true in install mode on main (SELF_UPGRADE); F1 makes the remaining stale cases visible, it does not add upgrades to --check. +- Item 1 "gstack carries no gbrain specifics": kept; gbrain policy changes go to the gbrain crew through GStack Minder as a handoff, not a gstack PR. +- Item 5 root cause (machine replacement) is Capy's; reported, not worked around. + +### PR count + +gstack's ship policy defaults to one PR per wave. This wave asks for four: F1 and F2 are fast-track defect fixes outside the wave (S1 install honesty, wrong security result); the wave itself is two stacked PRs, A then B, because B's ship/review/pregate work and A's autoplan/install work are separate files but share result-codes.ts, troubleshooting.md and the ratchet fixtures, so stacking gives those files one linear owner. Each extra PR costs one queue slot and one paid E2E run per push [estimated: ~$93 each]. + +### Fast track (not gated here) + +- **F1. Install trailer carries "behind" (item 13; #2378's Capy half). Built at b1f7119 on #3190's head.** bin/gstack-capy-session exports GSTACK_CAPY_BEHIND/_BEHIND_TO/_BEHIND_FIX whenever the session ends behind origin (FETCH_HEAD from this run's fetch; works with the wrapper-1 Drive copy); bin/gstack-capy-install puts `behind= (latest: ); fix: ` on an ok trailer, replacing `latest:` (one staleness note, never both). The fix never contains the `; ` separator: `bash [--for ]` when a rerun fast-forwards, `/bin/gstack-upgrade` when the checkout is dirty, detached or diverged (it prints its own refusal), `unset , then …` when disabled. Docs and the capy skill say "a trailer with behind= means run the fix". Floor: at stamp time GSTACK_MIN_REVISION rises to F1's own version. +- **F2. cso discloses partial input (item 12 part 1). Building.** `gstack-cso start --paths `: out-of-scope files are per-file manifest entries with `originalHash: not-read` and `withheld: outside the --paths audit scope` (gaps, never `excluded:`), so snapshot-inputs coverage is `partial` and the report lists what was skipped; out-of-scope bytes skip the 64 MiB accumulation; a `--diff` changed path outside `--paths` refuses; the diff detector never treats an unread entry as changed or unchanged by payload; recheck and replay carry `--paths` and refuse a finding in a withheld path. Only the payload-cap message points to `--paths`; the manifest (16 MiB) and entry-count limits keep their unsupported-capacity guidance, since per-file entries still count there (a gbrain-sized repo, ~7.5k files, needs ~2 MiB). + +### Wave items + +**PR A: autoplan, install, eval-plan (W5, W6, W7, W8, W9, W11).** + +**W5. Autoplan budget (item 11, High).** +- `--max-rounds ` on the first `next` (default 3 unattended), stored in run.json; rounds counted from state.history reopen events per phase. +- At the cap: `closeDeferred(state, phase, reason, by)` in lib/autoplan-gate.ts beside closePhase, used by the cap and by a new `gstack-autoplan close --phase

--deferred "" --by `: marks open voices skipped, releases their reservations, writes a deferred consensus file so export/validate/resume work, and copies open findings into tasks.jsonl and a gate section with counts by severity. It auto-closes only when no open Critical, urgent finding or pending user challenge remains; otherwise those become gate items for the owner. +- A `revise` after a capped phase does not reopen it; it adds a gate approval item "plan changed after cap: unreviewed delta" with the diff summary, so the cap can't be bypassed and the delta can't ship silently. +- Spend: unattended runs on Capy reserve a printed per-voice default (`--estimate-usd`, default stated in DRIVER) for unestimated host attempts, so `--spend-cap` binds; host spend is still reported `unknown` beside the round count; `budget_exhausted` is a resumable status with structured cap/rounds/spend fields and printed recovery commands (close-deferred, or an owner-authorized cap raise). +- Confirm rounds: prompts carry the plan diff since the last reviewed revision and the settled decisions; findings get revision-qualified ids with correlation to earlier occurrences; a finding on settled scope reopens only when it cites a plan-diff hunk or file:line absent from the settled decision's record, else it is recorded `settled`. +Tests: cap close with open Critical routes to gate; revise after cap; resume after budget_exhausted; export and validate after a deferred close. + +**W6. Machine-path-free public export (item 6, Medium).** `gstack-autoplan export --public

` writes a sanitized copy without mutating the private run (resume and bindings unchanged): only the run's recorded machine roots (out dir, home, plan_input dir, checkpoint dir, worktree root) are rewritten to repo-relative paths or ``; derived files get their own digests with provenance naming the private digests. `gstack-artifact validate --public` fails (`ARTIFACT_MACHINE_PATH`) on a recorded root and WARNs with file:line on generic home-path patterns; inline code and fences in plan.md are not rewritten. Regression: gbrain-evals #164's shape. + +**W7. Lane durability (item 5, Medium).** DRIVER.md coordinator contract and /ship's multi-lane guide: push a WIP commit to the lane branch after each step; run gates from a pushed SHA. On Capy with GSTACK_AUTOPLAN_CHECKPOINT_DIR unset, the runner prints the detected private project-user Drive folder and the exact export line (`CHECKPOINT: unset; to survive machine replacement: export GSTACK_AUTOPLAN_CHECKPOINT_DIR=`); it defaults there only when `gstack-config set autoplan_checkpoint drive` opted in. Checkpoints are taken after each bound voice and phase transition, and resume does not redispatch a bound voice. + +**W8. Preregistration dry run (item 7, Medium).** In lib/eval-plan.ts behind gstack-eval-plan (not plan-reality): a preregistration declares a synthetic fixture file and the expected count per diagnostic; `gstack-eval-plan check-prereg --allow-repo-commands` runs the declared summarizer on it and fails `PREREG_DIAGNOSTIC_UNTESTED` on a mismatch or an undeclared diagnostic, before the freeze commit. + +**W9. Eval model-rule lint (item 10, Low).** Optional `.gstack/eval-models.json` holds allow/deny/tier constraints ("fable: smoke only", "deny gpt-5.4-mini", "newest generation per family") resolved through the existing MODEL_CATALOG; gstack-eval-plan lints every arm's model against it. No CLAUDE.md prose parsing. + +**W11. Every machine gives a real trailer (items 9 and 14, Low).** `--for review-voice`: clone or fast-forward plus helper-runtime Bun (gstack's own minimum, not the target repo's test pins), no ./setup, no host profiles; certifies only gstack-pregate sealed, gstack-plan-reality, gstack-artifact and gstack-autoplan submit-side reads, with a SKILL_REQUIREMENTS row so admission refuses heavier workflows with a `--for ` fix. The not-installed fix line and the reviewer/lane brief carry a fallback for machines without the Drive skill (`git clone --depth 1 … && bin/gstack-capy-install --for review-voice`). Budget: under 20 s warm, measured in a test. + +**PR B (stacked on A): ship, review, pregate, update-check (W1, W2, W3, W4, W10, W12).** + +**W1. /ship queue mode (item 1, High).** One entry, `bin/gstack-ship-queue` (lib/ship-queue.ts), subcommands per protocol step, each ending with `GSTACK_RESULT: skill=ship-queue …`; gstack-restamp stays the primitive. +- `ready`: one block in the coordinator contract's identifiers-first order plus head SHA, `version: unstamped`, CI state at that exact head and the pregate line; refuses when the tree differs from the pushed head. +- `stamp --version --expect-gated --expect-base [--expect-order ]`: preflights every proof input, journals stages, runs restamp (base movement → `QUEUE_STALE`), commits, runs tree-receipt, prints `QUEUE_STAMP: head= gated= tree=same-modulo-stamps receipt=`; an identical retry resumes or returns the existing outcome; on refusal it reports the head and whether anything was committed. Never pushes. +- `verify --pr --gated `: run by the coordinator; fetches the PR head and reruns tree-receipt there, so QUEUE_STAMP is never a pasted claim. +- `gate-ahead --after `: prepares the merged worktree and prints the gated sha and the repo's gate command; the caller runs it (background or CI); `gate-ahead --record --exit --log ` binds the result to the gated tree, keyed by PR, and removes the worktree. +- ship-policy: optional `gate_command` and `full_e2e` keys; from this wave on, unknown keys WARN (naming the introducing version) instead of failing POLICY_INVALID, so mixed-version crews keep working. +- docs/ship-policy.md "Run a shared merge queue": each actor's copy-paste sequence and expected lines. +- Acceptance: tree-receipt says `same-modulo-stamps` on a real gbrain stamp commit with gbrain's committed policy (fixture from a recorded gbrain pair); diagnose GBRA-102's refusal (P-i) with a fixture before changing lib/restamp.ts. + +**W2. Approval classes (item 3, High).** Modeled on gstack-banned-terms: `gstack-approval-classes init|validate|check --base ` prints `APPROVAL_CLASS: :` per hit and `APPROVAL_CLASSES: ok|found=|none`. Classes come from the committed base-branch `.gstack/approval-classes.json` (globs, literal patterns, allowed_paths per class); migrations come from ship-policy's `migrations` key. Built-in detectors only report. Enforcement point: `gstack-ship-receipt write` refuses `APPROVAL_CITATION_MISSING` for a declared class without `Approved by via `; the receipt records an approval object. Citations are resolved: a plan sha must match an autoplan APPROVED gate record containing the decision; a URL must be a comment or review by a ship-policy maintainer; anything else is recorded `citation: unverified` for the coordinator. Data-repair: explicit, preview-first, not reachable from upgrade or migration paths (/review rule). + +**W3. Round-trip matrix for state machines (item 4, High).** Unchanged from revision 1: a /review specialist builds kind × owner action × resulting row from the diff, maps cells to tests, names missing cells as High. Proof: #6792's shape as a free regression from a captured review plus one paid `behavior` case. + +**W4. Timing-assertion check + flake playbook (item 2, High).** lib/pregate/checks/timing.ts: on added or changed test lines, flags elapsed wall/CPU-time comparisons against a bound and sleep-then-assert; prints `TIMING_ASSERTION: : ` plus a summary; escape `gstack-timing-ok: ` in any comment syntax on the line or the line above, reason required. Before deciding block vs warn, measure it on the five gbrain flake diffs and gstack's own test tree; P4 says "flagged before merge" unless the measurement supports blocking (then it blocks). Same rule in /review's checklist; /investigate gets the flake playbook (same-load distributions on master and PR head, forced probe, rerun only with outside-cause evidence, never raise the bound). + +**W10. Pregate config friction (item 8, Medium, after #3190 and #3225).** Land #3215 with its author's commits kept, after an untrusted-input review and a reviewer-written test; confirm #3190's `requires-remote` path with a gbrain-shaped fixture (`ci:ubicloud`, `UBI_OWNER`) and fix only what that shows. + +**W12. update-check sees an unreleased main move (#2378's Claude Code half, Medium).** When VERSION is equal and the ls-remote main SHA differs from local HEAD, ask the GitHub compare API through the existing receipted curl (`status`, `ahead_by`), cached like today; print `UPGRADE_AVAILABLE + `, snooze keyed by SHA; a diverged or unknown compare is `CHECK_FAILED`-style unknown, never up to date or upgrade. Never fetches. gstack-upgrade/SKILL.md.tmpl's question text and changelog step handle the equal-version case (substitution test). + +**W13 (deferred). cso auto-scope (item 12 part 2).** A measurement spike only: gbrain's admitted vs withheld bytes under cso's counting (P-h). If withheld >1 MiB payloads cause the overage, the fix is cap accounting (a decision for Garry, reported with numbers), not a scope heuristic. Automatic sibling expansion is built only after disconnected-sibling, alternate-transport, dynamic-registration and cutoff negative controls show its limits, over git objects at a recorded HEAD reusing lib/pregate/import-graph.ts's builder. + +### Delivery shape + +| PR | Contents | Base | Waits for | +|---|---|---|---| +| F1 | item 13 trailer + floor | main after #3190 | #3190 | +| F2 | item 12 part 1 | main | — | +| A | W5, W6, W7, W8, W9, W11; registers the wave's result codes and anchors | main after #3190 (and F1 for W11) | #3190, #3226 for autoplan templates | +| B | W1, W2, W3, W4, W10, W12 | A's branch | A; #3225 for pregate; #3200 for review tests | + +Each PR: `gstack-lane-check --planned` clear against the others, focused tests during repair, the free suite once on its frozen tree on Ubicloud, `bun run eval:bg:pr`, /review clean, READY to GStack Minder. + +### Out of scope + +Capy-side machine replacement (item 5's root cause) and skill mounting (GBRA-56/65): reported to Capy. gbrain-repo config changes: handed to the gbrain crew through GStack Minder. + +## Review record + +- CEO r1 (native Opus 5.5: 6 High, 6 Medium; outside GPT-6.1 Sol: 4 High, 1 Medium, 1 user challenge): lane-check shared files → PR A owns codes, B stacks; QUEUE_STAMP verifiable → `verify`; base-movement race → --expect-base; gbrain policy → P-e corrected, acceptance on a real stamp; citations resolved; spend cap binds; cap never buries Critical; W13 measured first and deferred (user challenge folded in); W4 measured; W7 opt-in; F1 one staleness note; W8 declared fixtures; W6 separate public package; checkpoint after each bound voice. +- DX r1 (native: 3 High, 6 Medium, 1 Low; outside: 1 High, 3 Medium): W6 recorded roots only; W2 banned-terms model, declared classes only; ship-policy unknown keys WARN; one ship-queue entry; one READY format; coordinator how-to; F1 no "; " inside notes; W12 output/snooze; W11 fallback + measured budget; W4 line format; budget_exhausted resumable; stamp retry semantics; review-voice runtime boundary; cso advice per failure kind. +- Eng r1 (native: 4 High, 5 Medium, 1 Low; outside: 2 High, 2 Medium): closeDeferred and revise-after-cap; W2 enforcement in ship-receipt, migrations from policy; F2 manifest/entry limits disclosed; W12 compare API; W13 over git objects reusing import-graph; W8/W9 in eval-plan with MODEL_CATALOG; gate-ahead async with --record; pr_count on the gate; F1 check-mode fix for dirty/detached checkouts; F2 diff detector and replay contracts; revision-qualified finding ids. diff --git a/docs/designs/field-list-2-2026-10-16/ROUND2-OBLIGATIONS.md b/docs/designs/field-list-2-2026-10-16/ROUND2-OBLIGATIONS.md new file mode 100644 index 000000000..f1de00314 --- /dev/null +++ b/docs/designs/field-list-2-2026-10-16/ROUND2-OBLIGATIONS.md @@ -0,0 +1,42 @@ +# Field list 2: round-2 review findings (build obligations) + +Approved by Garry 2026-10-16 ("all", gate_rev=2, relayed by GStack Minder GSTA-36). Autoplan run run-20261016222410. Every lane treats the findings for its items as requirements; verify each against code first (a finding that is wrong gets a written reason in the lane report). + +## ceo / native + +- **High: F2 and the still-open #3215 edit the registries PR A now owns, so A's lane-check cannot be clear** (docs/troubleshooting.md:1207). Fix: Have GStack Minder merge #3215 on its own first (or close it as superseded with credit once B carries its commits); rebase F2 after #3190 or move its troubleshooting.md edit into A; record both on the gate. +- **Medium: Stacking B on A delays W1/W2 (P1, P2) behind six autoplan items and a draft dependency (#3226)**. Fix: Let readiness order the stack: whichever of A or B has its external dependencies merged first is built first and owns the registries; today that is likely B (W1/W2) since #3226 is a draft. +- **Low: Release promise P4 still says 'caught before merge' while W4 now says 'flagged unless measured'**. Fix: Reword P4 to 'flagged before merge (blocking if the five-flake measurement supports it)'. +- **Low: F1 f315806: the session's behind line still prints the rerun fix when the trailer's fix is gstack-upgrade; non-default branches are treated as fast-forwardable** (bin/gstack-capy-session). Fix: Print $GSTACK_CAPY_BEHIND_FIX on the behind line and classify a non-default branch like detached (gstack-upgrade refuses it). + +## ceo / outside + +- **High: Resolved approval citations still lack binding to the effects being shipped**. Fix: Bind approval classes and approved effects to the current plan/scope digest, invalidate reuse after effect changes, and require explicit applicability attestation for unresolved evidence; test unrelated authentic approvals and post-approval effect additions. +- **High: Deferred close must not release reservations for already dispatched reviews** (lib/spend-ledger.ts:149). Fix: Settle dispatched or execution-unknown attempts as known or unknown while retaining their reservation amount; release only proven never-dispatched work, distinguish review deferral from host cancellation, and test close/resume without recovered spend headroom. +- **High: Warning on every unknown ship-policy key weakens safety without fixing old-client compatibility** (lib/ship-policy.ts:195). Fix: Keep unknown safety-policy keys fail-closed and require a compatible minimum revision before enabling new policy fields; explicitly bound any optional extension mechanism and test an old client plus a misspelled gate-reuse restriction. + +## dx / native + +- **Medium: WARN-on-unknown keys helps only future gstacks; lanes on 1.91.106 and earlier still fail POLICY_INVALID once gbrain commits gate_command/full_e2e** (lib/ship-policy.ts:201). Fix: The gbrain handoff and docs/ship-policy.md add gate_command/full_e2e only after the crew's GSTACK_MIN_REVISION reaches B's version; gstack-ship-policy validate prints each key's introducing version. +- **Medium: W8 adds check-prereg beside the existing gstack-eval-plan prereg --check** (bin/gstack-eval-plan:5). Fix: Extend prereg --check: with a declared synthetic fixture it requires --allow-repo-commands (else lists commands and exits 3, like gstack-restamp) and fails PREREG_DIAGNOSTIC_UNTESTED on mismatch; no second verb. +- **Medium: budget_exhausted recovery names an owner-authorized cap raise, but spend cap and max rounds are set only at run creation** (lib/autoplan-run.ts:113). Fix: Add gstack-autoplan budget --out [--spend-cap ] [--max-rounds ] --by that journals the change; print it with close --deferred in the budget_exhausted recovery lines. +- **Low: W11 fallback clone command elides its target directory and bypasses the wrapper's floor check**. Fix: Spell out the full command with the root the wrapper resolves, and print the installer's cloned line so the trailer and the clone agree. + +## dx / outside + +- **Medium: The new no-Drive bootstrap does not locate the cloned installer or preserve project context** (bin/gstack-capy-install:36). Fix: Publish a fallback with an explicit dedicated install root and absolute installer invocation, preserve caller project identity and working directory, handle an existing install, and execute the exact cold and repeat-session brief through a real helper invocation in a non-gstack fixture project. + +## eng / native + +- **High: verify cannot fetch the gate-ahead merge commit, which exists only in the lane's throwaway worktree** (lib/restamp.ts:588). Fix: verify --gated-tree recomputes the merge with git merge-tree --write-tree and compares it to the recorded gated_tree before tree-receipt (or gate-ahead --record pushes the gated commit to refs/gstack/gated/); test gate-ahead then verify from a separate clone. +- **Medium: budget_exhausted as a resumable run status is neither in the status enum nor terminal, so old validators and export both fail** (lib/headless-artifacts.ts:91). Fix: Use the existing terminal 'incomplete' with a stop object {reason:'budget_exhausted', cap_usd, rounds, spent, reserved, unknown}; resume from incomplete only with that reason after a budget raise. +- **Medium: validate --public cannot fail on recorded machine roots because the public package carries none**. Fix: validate --public --private on the exporting machine fails on recorded roots and runs inside export before writing; standalone validation checks provenance digests and WARNs on generic patterns. +- **Medium: MODEL_CATALOG holds one current model per provider x tier, so W9's newest-generation-per-family rule cannot resolve through it (corrects round-1 F7)** (lib/model-catalog.ts:24). Fix: eval-models.json states allow/deny ids or globs, per-tier allow lists and an optional ordered families map (newest first); MODEL_CATALOG only checks frontier/smart currency; unknown ids fail closed. +- **Low: F2 cites ~7.5k gbrain files; the tracked tree has 11,570 blobs**. Fix: Correct the number (about 3.9 MiB of manifest at the cap's density, still within limits) and size F2's capacity fixture from the real count. + +## eng / outside + +- **High: Asynchronous gate-ahead needs attempt-owned worktrees, records and cleanup** (lib/restamp.ts:553). Fix: Isolate each preparation by an immutable attempt handle and worktree; bind completion to its gated/predecessor/command identities, protect live attempts, retain evidence before owned cleanup, and test concurrent preparations with out-of-order completion. +- **Medium: Equal-version update comparisons cannot reuse the version-only cache** (bin/gstack-update-check:195). Fix: Bind cached comparison validity to local HEAD and upstream identity, store the compared remote SHA, invalidate legacy entries without identities, and test same-version fast-forward and divergent branch changes after a cached upgrade. +- **Medium: The four-entry reviewer model catalog cannot resolve general eval-model rules** (lib/model-catalog.ts:24). Fix: Apply literal allow/deny independently of catalog membership, separate eval scope from review tier, and require explicit supported latest-series mappings or report unresolved newest-generation rules; test uncatalogued allowed/denied models and smoke-only rules. + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b87e11067..bf790fd04 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3334,3 +3334,30 @@ unset, not in bare mode). Then re-run the review. **Expected result.** An `OUTSIDE VOICE (...)` report, or `CODEX SAYS (plan review — outside voice)` when the provider runs. + + +### `ARTIFACT_MACHINE_PATH` + +**Meaning.** A public copy of an autoplan run still contains a path the run +recorded on this machine: its run directory, the home directory, the plan's +directory, the checkpoint directory or the worktree root. `gstack-artifact +validate --public --private /run.json` reports each hit with +file:line; `gstack-autoplan export --public` runs that check on a staging copy +and writes nothing when it fails. + +**Fix.** Export with `gstack-autoplan export --out --public `, which +rewrites recorded roots to repo-relative paths or ``. Export never rewrites inline code or fenced blocks in plan.md, so +a hit there needs a plan edit: change the path, `revise`, and export again. +Generic home-directory paths that are not recorded roots are WARN lines, not +errors. + + +### `ARTIFACT_PUBLIC_TARGET_EXISTS` + +**Meaning.** `gstack-autoplan export --public ` found files in `` +that are not an earlier public copy of the same run, and refused to overwrite +them. + +**Fix.** Pass an empty or new directory, or the directory that already holds +this run's public copy (it is replaced as a whole). diff --git a/docs/unattended.md b/docs/unattended.md index 906257591..bf1361cc6 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -238,8 +238,11 @@ the plan. `run.json`; then `gstack-artifact validate /run.json` and the consumption steps above apply unchanged. `$AP status --out ` and `resume` work after the run directory moves (paths are re-rooted; journal - paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set, a run - at rest publishes an immutable, hash-listed generation under + paths are run-relative). With `GSTACK_AUTOPLAN_CHECKPOINT_DIR` set (on Capy, + `gstack-config set autoplan_checkpoint drive` defaults it to the private + project-user Drive folder; unset, the first `next` prints the export line), + a run at rest, after each bound voice and after each phase transition + publishes an immutable, hash-listed generation under `//generations/` and switches `current.json` to it (the previous generation is kept; a stale copy never replaces a newer one; a directory overlapping the run is refused with `CHECKPOINT_OVERLAP`; a failure is a diff --git a/lib/autoplan-budget.ts b/lib/autoplan-budget.ts new file mode 100644 index 000000000..37ac60edd --- /dev/null +++ b/lib/autoplan-budget.ts @@ -0,0 +1,157 @@ +/** + * autoplan-budget — the run budget of the unattended runner (field list 2, + * W5). Review rounds are counted per phase from `state.history` reopen events + * and capped by `run.json.max_rounds` (default 3 for unattended runs) plus the + * owner's per-phase grants at the gate. Unattended runs on Capy reserve a + * default per-voice estimate for unestimated host attempts, so `--spend-cap` + * binds. A dispatch the cap refuses stops the run at the terminal `incomplete` + * status with a structured `stop` object (`reason: budget_exhausted`) and + * printed recovery commands; `gstack-autoplan budget` journals the owner's cap + * change and `resume` lifts the stop only after one. Phase close at the cap + * lives in lib/autoplan-gate.ts (`closeDeferred`). + */ +import * as path from 'node:path'; +import { ANSWERED_BY_RE, type GateItem } from './gate-list'; +import { FILES, RunError, journal, now, readJournal, type RunState, type RunStop } from './autoplan-state'; +import { readLedger, release, setCap, summarize } from './spend-ledger'; + +export const DEFAULT_MAX_ROUNDS = 3; +/** Reserved per voice for a host-subagent attempt with no `--estimate-usd` in an unattended Capy run. */ +export const DEFAULT_HOST_ESTIMATE_USD = 5; + +/** Round r of a phase: 1 plus the times it was reopened. */ +export const roundOf = (state: RunState, phase: string): number => 1 + state.history.filter(h => h.event === 'reopened' && h.phase === phase).length; +/** The phase's round cap: max_rounds plus the owner's grants; null when the run is uncapped. */ +export function roundCap(state: RunState, phase: string): number | null { + return state.max_rounds == null ? null : state.max_rounds + (state.round_grants?.[phase] ?? 0); +} +/** Reopening would pass the cap: the phase has used its last round. */ +export const atCap = (state: RunState, phase: string): boolean => { const cap = roundCap(state, phase); return cap !== null && roundOf(state, phase) >= 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; }; + +export function roundsLine(state: RunState): string { + const cell = (p: string) => `${p}=${roundOf(state, p)}/${roundCap(state, p) ?? 'none'}`; + 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). */ +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` }; +} + +/** Stop the run: terminal `incomplete` with the structured budget_exhausted stop, journaled. Returns the STOP and RECOVER lines. */ +export function stopForBudget(state: RunState, phase: string, nextEstimate: number): string[] { + const s = summarize(readLedger(path.join(state.out, FILES.spend), state.spend_cap_usd)); + const stop: RunStop = { + reason: 'budget_exhausted', phase, cap_usd: state.spend_cap_usd, max_rounds: state.max_rounds ?? null, + rounds: Object.fromEntries(state.phase_order.map(p => [p, roundOf(state, p)])), spent: s.spent_usd, reserved: s.reserved_usd, + unknown: s.unknown_usd, unknown_count: s.unknown_count, next_estimate_usd: nextEstimate, at: now(), + }; + state.stop = stop; + state.status = 'incomplete'; + journal(state.out, { event: 'stopped', ...stop }); + return stopLines(state); +} + +export function stopLines(state: RunState): string[] { + const s = state.stop; + if (!s) return []; + const rounds = Object.entries(s.rounds).map(([p, r]) => `${p}:${r}/${roundCap(state, p) ?? 'none'}`).join(','); + return [ + `STOP: reason=${s.reason} phase=${s.phase ?? 'none'} cap=${s.cap_usd === null ? 'none' : s.cap_usd.toFixed(2)} spent=${s.spent.toFixed(2)} reserved=${s.reserved.toFixed(2)} unknown=${s.unknown_count}(${s.unknown.toFixed(2)}) next_estimate=${s.next_estimate_usd.toFixed(2)} rounds=${rounds}`, + ...recoveryLines(state), + ]; +} + +export function recoveryLines(state: RunState): string[] { + const phase = state.stop?.phase ?? ''; + return [ + `RECOVER: gstack-autoplan close --out ${state.out} --phase ${phase} --deferred "" --by > (close ${phase} accepted-with-findings, deferred to the build)`, + `RECOVER: gstack-autoplan budget --out ${state.out} --spend-cap [--max-rounds ] --by >, then gstack-autoplan resume --out ${state.out} (an owner-authorized cap raise)`, + ]; +} + +/** Clear the stop into `stops` (its history) and leave the run resumable. */ +export function liftStop(state: RunState, why: string): string[] { + if (!state.stop) return []; + (state.stops ??= []).push(state.stop); + delete state.stop; + state.status = 'running'; + journal(state.out, { event: 'stop_lifted', why }); + return [`STOP_LIFTED: ${why}`]; +} + +/** `resume` on a budget-stopped run: lifts the stop only when a budget change was journaled after it. */ +export function resumeStop(state: RunState): string[] { + if (state.status !== 'incomplete' || state.stop?.reason !== 'budget_exhausted') return []; + const at = state.stop.at; + const raised = readJournal(state.out).some(r => r.event === 'budget_changed' && String(r.ts) >= at); + if (!raised) return [`STOPPED: budget_exhausted at ${at}; nothing changed the budget since`, ...recoveryLines(state)]; + return liftStop(state, 'budget raised after budget_exhausted'); +} + +export interface BudgetChange { spendCap?: number; maxRounds?: number; by?: string } + +/** `gstack-autoplan budget`: the owner's journaled change of the spend cap and/or the round cap. */ +export function changeBudget(state: RunState, o: BudgetChange): string[] { + if (o.by === undefined || !ANSWERED_BY_RE.test(o.by)) throw new RunError('ANSWER_BY_REQUIRED', `budget records who authorized the change: pass --by human or --by agent: (got ${JSON.stringify(o.by ?? null)})`, 3); + if (o.spendCap === undefined && o.maxRounds === undefined) throw new RunError('ARTIFACT_SCHEMA', 'budget needs --spend-cap and/or --max-rounds ', 1); + if (o.maxRounds !== undefined && (!Number.isInteger(o.maxRounds) || o.maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1); + const change: Record = { event: 'budget_changed', by: o.by }; + const parts: string[] = []; + if (o.spendCap !== undefined) { + change.spend_cap = { from: state.spend_cap_usd, to: o.spendCap }; + parts.push(`spend_cap=${state.spend_cap_usd ?? 'none'}->${o.spendCap}`); + state.spend_cap_usd = o.spendCap; + setCap(path.join(state.out, FILES.spend), o.spendCap); + } + if (o.maxRounds !== undefined) { + change.max_rounds = { from: state.max_rounds ?? null, to: o.maxRounds }; + parts.push(`max_rounds=${state.max_rounds ?? 'none'}->${o.maxRounds}`); + state.max_rounds = o.maxRounds; + } + journal(state.out, change); + const lines = [`BUDGET: ${parts.join(' ')} by=${o.by}`]; + if (state.stop) lines.push(`NEXT: gstack-autoplan resume --out ${state.out}`); + return lines; +} + +/** Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released. */ +export function releaseUndispatched(state: RunState): string[] { + const file = path.join(state.out, FILES.spend); + const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched').map(r => r.attempt)); + return readLedger(file).reserved.filter(r => !dispatched.has(r.attempt)).map(r => { + release(file, r.attempt); + journal(state.out, { event: 'released_undispatched', attempt: r.attempt, estimate_usd: r.estimate_usd }); + return `RELEASED: ${r.attempt} (never dispatched)`; + }); +} + +export const pendingDeltas = (state: RunState) => (state.capped_deltas ?? []).filter(d => d.status === 'pending'); + +/** + * The owner's answers to cap items (deferred closes, deferred blockers, capped deltas) once the plan proceeds: + * `b` grants that phase one more round, journaled as a budget change, and adds it (and Eng) to the reopen set; + * `a` accepts. Capped deltas record which. + */ +export function applyCapChoices(state: RunState, choices: Array<{ item: GateItem; chosen: string }>, reopen: Set, by: string | undefined): string[] { + const lines: string[] = []; + const granted = new Set(); + for (const { item, chosen } of choices) { + const cap = item.cap; + if (!cap) continue; + const reopens = chosen === 'b'; + for (const d of pendingDeltas(state)) if (d.phase === cap.phase && (cap.kind !== 'delta' || String(d.k) === cap.ref)) { d.status = reopens ? 'reopened' : 'accepted'; d.decided_at = now(); } + if (!reopens || granted.has(cap.phase)) continue; + granted.add(cap.phase); + (state.round_grants ??= {})[cap.phase] = (state.round_grants[cap.phase] ?? 0) + 1; + journal(state.out, { event: 'budget_changed', by: by ?? 'gate', round_grant: { phase: cap.phase, cap: roundCap(state, cap.phase) }, item: item.id }); + reopen.add(cap.phase); + if (state.phase_order.includes('eng')) reopen.add('eng'); + lines.push(`ROUND_GRANTED: ${cap.phase} cap=${roundCap(state, cap.phase)} (${item.id}b by ${by ?? 'gate'})`); + } + return lines; +} diff --git a/lib/autoplan-checkpoint.ts b/lib/autoplan-checkpoint.ts index dff1b3ad0..e134d20c1 100644 --- a/lib/autoplan-checkpoint.ts +++ b/lib/autoplan-checkpoint.ts @@ -9,7 +9,12 @@ * previous generation stays until the switch commits, so an interrupted * publish leaves the last checkpoint current. A generation never replaces a * newer one (higher run revision). Publication and restore hold the - * collection's `.lock`; restore also holds `.lock`. + * collection's `.lock`; restore also holds `.lock`. Field list 2, W7: + * a generation is also published after each bound voice and each phase + * transition (so a restored run never redispatches a bound voice); on Capy + * with no directory set the runner prints the export line naming the private + * project-user Drive folder, and uses that folder only after `gstack-config + * set autoplan_checkpoint drive`. * * Restore picks the named generation, or the newest generation that verifies * when `--from` names the collection (or a root holding one run). It refuses @@ -23,6 +28,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { sha256File, validateRun } from './headless-artifacts'; import { FILES, RunError, journal, now, readState, type RunState } from './autoplan-state'; +import { repoRoot } from './autoplan-plan-text'; +import { readConfigKey } from './state-root'; const MANIFEST = 'checkpoint.json'; const CURRENT = 'current.json'; @@ -95,12 +102,47 @@ function newestRevision(collection: string): number { return Math.max(-1, ...fs.readdirSync(dir).map(g => { try { return Number(JSON.parse(fs.readFileSync(path.join(dir, g, MANIFEST), 'utf8')).revision); } catch { return -1; } }).filter(Number.isFinite)); } -/** Publish a generation when the env opts in and the run is at rest; returns the CHECKPOINT line, or nothing. */ -export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env): string | undefined { - const root = env.GSTACK_AUTOPLAN_CHECKPOINT_DIR; - if (!root || !fs.existsSync(path.join(out, FILES.run))) return undefined; +/** + * The private project-user Drive folder on a Capy machine (GSTACK_CAPY=1): `/autoplan-checkpoints` under the one + * `~/.capy/drive/project-user-*` folder, or the one naming `repo` when there are several; undefined otherwise. + */ +export function capyDriveFolder(env: NodeJS.ProcessEnv = process.env, repo?: string): string | undefined { + if (env.GSTACK_CAPY !== '1') return undefined; + const drive = path.join(env.HOME || os.homedir(), '.capy', 'drive'); + let folders: string[]; + try { folders = fs.readdirSync(drive).filter(d => d.startsWith('project-user-') && fs.statSync(path.join(drive, d)).isDirectory()); } catch { return undefined; } + if (folders.length > 1 && repo) folders = folders.filter(d => d.startsWith(`project-user-${repo}-`)); + return folders.length === 1 ? path.join(drive, folders[0]!, 'autoplan-checkpoints') : undefined; +} + +/** Where checkpoints go: GSTACK_AUTOPLAN_CHECKPOINT_DIR, else the Capy Drive folder when `gstack-config set autoplan_checkpoint drive` opted in. */ +export function checkpointRoot(env: NodeJS.ProcessEnv = process.env, repo?: string): string | undefined { + if (env.GSTACK_AUTOPLAN_CHECKPOINT_DIR) return env.GSTACK_AUTOPLAN_CHECKPOINT_DIR; + return readConfigKey('autoplan_checkpoint', env) === 'drive' ? capyDriveFolder(env, repo) : undefined; +} + +/** On Capy with no checkpoint directory: the exact line that makes the run survive machine replacement. */ +export function checkpointNotice(env: NodeJS.ProcessEnv, repo?: string): string | undefined { + if (env.GSTACK_CAPY !== '1') return undefined; + const dir = capyDriveFolder(env, repo); + return `CHECKPOINT: unset; to survive machine replacement: export GSTACK_AUTOPLAN_CHECKPOINT_DIR=${dir ?? ''}${dir ? ' (or for every run: gstack-config set autoplan_checkpoint drive)' : ''}`; +} + +/** A checkpoint beyond the at-rest ones: after a bound voice, after a phase transition, and the notice at the first next. */ +export type CheckpointMoment = 'bound' | 'transition' | 'init'; +const repoOf = (state: RunState) => { const input = state.plan_input ?? state.plan_source; const root = input ? repoRoot(path.dirname(input)) : undefined; return root ? path.basename(root) : undefined; }; + +/** + * Publish a generation when a checkpoint directory is set (or Drive opted in) and the run is at rest, or at a + * `moment` (a bound voice, a phase transition); returns the CHECKPOINT line, the unset notice on Capy, or nothing. + */ +export function checkpointRun(out: string, env: NodeJS.ProcessEnv = process.env, moment?: CheckpointMoment): string | undefined { + if (!fs.existsSync(path.join(out, FILES.run))) return undefined; const state = readState(out); - if (!AT_REST.includes(state.status)) return undefined; + const atRest = AT_REST.includes(state.status); + if (!atRest && !moment) return undefined; + const root = checkpointRoot(env, repoOf(state)); + if (!root) return atRest || moment === 'init' ? checkpointNotice(env, repoOf(state)) : undefined; const collection = path.join(path.resolve(root), state.run); refuseOverlap('the checkpoint directory overlaps the run directory', collection, state.out); return exclusive(path.join(collection, '.lock'), () => { @@ -214,6 +256,9 @@ export function restoreCheckpoint(from: string, out: string): string[] { lines.push(`PLAN_INPUT: ${m.plan_input.original} is missing; restored the checkpoint copy to ${path.join(target, m.plan_input.file)}`); } else if (m.plan_input && sha256File(m.plan_input.original) !== m.plan_input.sha256) lines.push(`PLAN_INPUT: ${m.plan_input.original} differs from the checkpoint copy; kept the file on disk`); const v = validateRun(runFile); + // A mid-run generation (after a bound voice or a transition) is unfinished by design: only its integrity must hold. + if (!AT_REST.includes(m.status)) v.errors = v.errors.filter(e => !['ARTIFACT_RUN_INTERRUPTED', 'ARTIFACT_REVIEWER_MISSING', 'ARTIFACT_GUARD_LINE_MISSING'].includes(e.code)); + v.valid = v.errors.length === 0; if (!v.valid) throw new RunError('CHECKPOINT_INVALID', `${gen.dir}: the staged run does not validate: ${v.errors.slice(0, 3).map(e => `${e.code} ${e.path}: ${e.message}`).join('; ')}`, 3); if (fs.existsSync(target)) fs.rmdirSync(target); fs.mkdirSync(path.dirname(target), { recursive: true }); diff --git a/lib/autoplan-confirm.ts b/lib/autoplan-confirm.ts new file mode 100644 index 000000000..75d9102d1 --- /dev/null +++ b/lib/autoplan-confirm.ts @@ -0,0 +1,147 @@ +/** + * autoplan-confirm — delta-only confirm rounds (field list 2, W5). A phase + * reopened for round r > 1 is asked to review the Implementation-plan diff + * since the revision it last reviewed, beside the settled gate decisions. + * Its findings carry revision-qualified ids (canonicalRows in + * lib/autoplan-reconcile.ts) and `correlates`, naming earlier occurrences in + * this phase; a finding that correlates with one an answered gate decision + * settled is recorded `settled_by` that decision unless it cites a hunk of + * the diff or a file:line the settled finding did not. Settled rows reach no + * gate item and no task (lib/autoplan-gate.ts, lib/autoplan-export.ts). + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { FindingRow } from './headless-artifacts'; +import { readJsonl } from './headless-artifacts'; +import { jaccard, tokens } from './autoplan-reconcile'; +import { readDecisions, type PhaseState, type RunState } from './autoplan-state'; + +export interface DiffLine { op: ' ' | '+' | '-'; text: string; a?: number; b?: number } +/** Above this many LCS cells the changed middle is shown as removed then added, never computed. */ +const MAX_CELLS = 4_000_000; + +/** A line diff (common prefix and suffix trimmed, LCS on the middle). */ +export function lineDiff(before: string, after: string): DiffLine[] { + const a = before.split('\n'), b = after.split('\n'); + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + let endA = a.length, endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; } + const ops: DiffLine[] = a.slice(0, start).map((text, i) => ({ op: ' ', text, a: i + 1, b: i + 1 })); + const midA = a.slice(start, endA), midB = b.slice(start, endB); + const n = midA.length, m = midB.length; + const del = (i: number) => ops.push({ op: '-', text: midA[i]!, a: start + i + 1 }); + const add = (j: number) => ops.push({ op: '+', text: midB[j]!, b: start + j + 1 }); + if (n * m > MAX_CELLS) { midA.forEach((_, i) => del(i)); midB.forEach((_, j) => add(j)); } + else { + const w = m + 1; + const L = new Uint32Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i * w + j] = midA[i] === midB[j] ? L[(i + 1) * w + j + 1]! + 1 : Math.max(L[(i + 1) * w + j]!, L[i * w + j + 1]!); + let i = 0, j = 0; + while (i < n && j < m) { + if (midA[i] === midB[j]) { ops.push({ op: ' ', text: midA[i]!, a: start + i + 1, b: start + j + 1 }); i++; j++; } + else if (L[(i + 1) * w + j]! >= L[i * w + j + 1]!) del(i++); + else add(j++); + } + while (i < n) del(i++); + while (j < m) add(j++); + } + for (let k = 0; k < a.length - endA; k++) ops.push({ op: ' ', text: a[endA + k]!, a: endA + k + 1, b: endB + k + 1 }); + return ops; +} + +/** Unified hunks with `context` lines around each change; empty when nothing changed. */ +export function renderHunks(ops: DiffLine[], context = 2): string { + const changed = ops.flatMap((o, i) => (o.op === ' ' ? [] : [i])); + const out: string[] = []; + for (let i = 0; i < changed.length;) { + const lo = Math.max(0, changed[i]! - context); + let hi = Math.min(ops.length - 1, changed[i]! + context); + let k = i + 1; + while (k < changed.length && changed[k]! - context <= hi + 1) { hi = Math.min(ops.length - 1, changed[k]! + context); k++; } + const slice = ops.slice(lo, hi + 1); + out.push(`@@ -${slice.find(o => o.a)?.a ?? 0},${slice.filter(o => o.op !== '+').length} +${slice.find(o => o.b)?.b ?? 0},${slice.filter(o => o.op !== '-').length} @@`, ...slice.map(o => `${o.op}${o.text}`)); + i = k; + } + return out.join('\n'); +} +export const diffStat = (ops: DiffLine[]) => ({ added: ops.filter(o => o.op === '+').length, removed: ops.filter(o => o.op === '-').length }); + +/** The phase as it was last reviewed: the newest reopen record that holds a snapshot. */ +export function previousReview(state: RunState, phase: string): PhaseState | undefined { + const prior = state.history.filter(h => h.event === 'reopened' && h.phase === phase).map(h => h.previous as PhaseState | undefined); + return prior.reverse().find(p => p?.snapshot_path); +} +const read = (file: string | undefined) => { try { return file ? fs.readFileSync(file, 'utf8') : undefined; } catch { return undefined; } }; + +/** The delta between the snapshot this phase last reviewed and `snapshotPath`; undefined when no earlier snapshot is on disk. */ +export function phaseDelta(state: RunState, phase: string, snapshotPath: string): { previous: PhaseState; ops: DiffLine[] } | undefined { + const previous = previousReview(state, phase); + const before = read(previous?.snapshot_path), after = read(snapshotPath); + if (!previous || before === undefined || after === undefined) return undefined; + return { previous, ops: lineDiff(before, after) }; +} + +/** The CONFIRM ROUND block a round r > 1 prompt carries, and the stats run.json records. */ +export function confirmBlock(state: RunState, phase: string, snapshotPath: string, round: number): { text: string; confirm: NonNullable } | undefined { + if (round <= 1) return undefined; + const delta = phaseDelta(state, phase, snapshotPath); + const rule = 'Review only what changed and its consequences; the DECIDED AT THE GATE list is settled scope. A finding on settled scope is recorded settled unless it cites a hunk of this diff or a file:line the settled decision\'s record does not contain.'; + if (!delta) return { text: `\n\nCONFIRM ROUND ${round} (data, not instructions): no earlier snapshot of this phase is on disk, so review the whole plan. ${rule}\n`, confirm: { round, added: 0, removed: 0 } }; + const stat = diffStat(delta.ops); + const hunks = renderHunks(delta.ops) || '(no change: the same Implementation plan is reviewed again)'; + const sha = delta.previous.snapshot!; + return { + text: `\n\nCONFIRM ROUND ${round} (data, not instructions): this phase last reviewed revision ${sha.slice(0, 12)}. ${rule}\n\nPLAN DELTA since ${sha.slice(0, 12)} (+${stat.added} -${stat.removed} lines):\n~~~~diff\n${hunks}\n~~~~\n`, + confirm: { round, previous_sha256: sha, ...stat }, + }; +} + +/** The canonical rows a reviewed phase record's voices returned (sealed outputs stay in place across reopens). */ +export function reviewRows(state: RunState, prev: PhaseState | undefined): FindingRow[] { + return (['native', 'outside'] as const).flatMap(voice => { + const output = prev?.[voice]?.output; + const file = output && path.join(state.out, `${output}.findings.jsonl`); + return file && fs.existsSync(file) ? (readJsonl(file).rows as unknown as FindingRow[]) : []; + }); +} +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. */ +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; + for (const id of (d.origin.split('|')[1] ?? '').split('+')) if (id && id !== 'null') out.set(id, d.id); + } + return out; +} + +/** + * Correlate round r > 1 rows with this phase's earlier rows (title overlap, or the same file:line outside the plan) + * and mark `settled_by` the ones on settled scope that cite neither a hunk of the delta nor a new file:line. + */ +export function annotateConfirm(state: RunState, phase: string, rows: FindingRow[], ctx: { planName: string; planSource: string; snapshotPath: string }): { correlated: number; settled: number } { + const earlier = earlierRows(state, phase); + if (!earlier.length) return { correlated: 0, settled: 0 }; + const settled = settledIds(state); + const added = new Set((phaseDelta(state, phase, ctx.snapshotPath)?.ops ?? []).filter(o => o.op === '+' && o.text.trim()).map(o => o.text.trim())); + const planLines = (read(ctx.planSource) ?? '').split('\n'); + const isPlan = (f: FindingRow) => f.file === ctx.planName; + let correlated = 0, settledCount = 0; + for (const row of rows as Array) { + const same = earlier.filter(e => jaccard(tokens(e.title), tokens(row.title)) >= 0.6 || (!!e.file && !isPlan(e) && e.file === row.file && e.line === row.line)); + if (!same.length) continue; + row.correlates = same.map(e => e.id); + correlated++; + const prior = same.find(e => settled.has(e.id)); + if (!prior) continue; + const citesHunk = isPlan(row) && row.line !== undefined && added.has((planLines[row.line - 1] ?? '').trim()); + const newLocation = !isPlan(row) && !!row.file && !same.some(e => e.file === row.file && e.line === row.line); + if (citesHunk || newLocation) continue; + row.settled_by = settled.get(prior.id)!; + settledCount++; + } + return { correlated, settled: settledCount }; +} diff --git a/lib/autoplan-export.ts b/lib/autoplan-export.ts index c88e274c5..3b65c4177 100644 --- a/lib/autoplan-export.ts +++ b/lib/autoplan-export.ts @@ -16,7 +16,7 @@ import type { ConsensusRecord } from './autoplan-reconcile'; import { decisionId } from './gate-list'; import { readDecisions, voiceRows, type RunState } from './autoplan-state'; import type { FindingRow, TaskRow } from './headless-artifacts'; -import { renderUrgentBlock, sha256File } from './headless-artifacts'; +import { readJsonl, renderUrgentBlock, sha256File } from './headless-artifacts'; export const PLAN_WARN_BYTES = 30_000; @@ -24,8 +24,15 @@ export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] { const tasks: TaskRow[] = []; const covered = new Set(); let n = 0; - for (const f of findings) { - if (covered.has(f.id) || !['Critical', 'High'].includes(f.severity) || f.disposition === 'rejected') continue; + for (const f of findings as Array) { + if (f.settled_by || covered.has(f.id)) continue; + if (f.disposition === 'deferred') { + covered.add(f.id); + tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: `deferred from ${f.phase}: ${f.title}`, status: 'pending', depends_on: [], findings: [f.id], + blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `the build resolves: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase }); + 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)); @@ -39,14 +46,19 @@ export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] { export function aggregateFindings(state: RunState): FindingRow[] { const out: FindingRow[] = []; for (const phase of state.phase_order) { - if (!state.phases[phase]?.closed_at) continue; + const ps = state.phases[phase]; + if (!ps?.closed_at) continue; const consensus = JSON.parse(fs.readFileSync(path.join(state.out, `${phase}-consensus.json`), 'utf8')) as ConsensusRecord; const byOutside = new Map(consensus.rows.map(r => [r.outside_id, r])); - for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null }); + // A phase closed accepted-with-findings: its open rows (and the rows it carried from the round last reviewed) are deferred to the build. + const deferred = (row: FindingRow) => (ps.deferred && !(row as { settled_by?: string }).settled_by ? { disposition: 'deferred' as const } : {}); + for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null, ...deferred(row) }); for (const row of voiceRows(state, phase, 'outside')) { const r = byOutside.get(row.id); - out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}) } as FindingRow); + out.push({ ...row, native_counterpart: r?.disposition === 'unverified' ? null : r?.native_id ?? null, ...(r ? { reconciliation: r.disposition, reconciliation_resolution: r.resolution } : {}), ...deferred(row) } as FindingRow); } + const carried = ps.deferred?.carried_file && path.join(state.out, ps.deferred.carried_file); + if (carried && fs.existsSync(carried)) out.push(...(readJsonl(carried).rows as unknown as FindingRow[]).map(row => ({ ...row, native_counterpart: null, disposition: 'deferred' as const }))); } return out; } @@ -81,6 +93,12 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t } for (const h of state.history) lines.push(`Reopened ${h.phase} at gate_rev ${h.gate_rev}: ${h.reason} (${h.at})`); if (state.history.length) lines.push(''); + const budget = [ + ...state.phase_order.flatMap(p => { const d = state.phases[p]?.deferred; return d ? [`Deferred ${p} at round ${d.round} (${d.cause}, by ${d.by}): ${d.reason}; ${d.open} open finding(s) carried into tasks.jsonl; owner items: ${d.blockers.join(', ') || 'none'}`] : []; }), + ...(state.capped_deltas ?? []).map(d => `Plan changed after the ${d.phase} cap (revise v${d.k}, round ${d.round} of ${d.cap}): +${d.added} -${d.removed} lines unreviewed; ${d.status}`), + ...[...(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}.`); return lines.join('\n') + '\n'; } diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 35db3f557..6270e7925 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -22,11 +22,13 @@ import { exportRun } from './autoplan-export'; import { driftLine, reviewHash } from './autoplan-plan-text'; import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHANGES, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; import type { Phase } from './autoplan-prompts'; -import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; +import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; +import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget'; +import { 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 type { FindingRow } from './headless-artifacts'; -import { readJsonl } from './headless-artifacts'; +import { readJsonl, sha256File } from './headless-artifacts'; import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief'; import { vaguePlanTerms } from './plan-reality'; import { settle } from './spend-ledger'; @@ -64,6 +66,92 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s return { consensus }; } +const severityText = (counts: Record) => Object.entries(counts).filter(([, n]) => n).map(([s, n]) => `${s}=${n}`).join(' ') || 'none'; +type OpenRow = FindingRow & { urgent?: boolean; user_challenge?: boolean; settled_by?: string }; + +/** + * Close `phase` accepted-with-findings (field list 2, W5): at the round cap, or by the owner (`close --deferred`). + * Open voices become skipped. Their dispatched or execution-unknown attempts are settled `unknown` at their + * reservation (a deferred review may still have run and been billed); only reservations with no journaled dispatch + * are released. The consensus file is written from the bound voices so export, validate and resume work. Open + * findings (this round's bound rows, else the rows of the round last reviewed, minus answered ones) carry into + * tasks.jsonl and the gate; Critical, urgent and user-challenge findings become owner approval items. + */ +export function closeDeferred(state: RunState, phase: Phase, o: { reason: string; by: string; cause: 'cap' | 'owner' }): { lines: string[]; blockers: string[] } { + const ps = (state.phases[phase] ??= {}); + const at = now(); + for (const voice of ['native', 'outside'] as const) { + const v = (ps[voice] ??= { status: 'pending' }); + for (const a of v.attempts ?? []) { + if (a.state !== 'dispatched' && a.state !== 'execution_unknown') continue; + a.state = 'deferred'; a.terminal_at = at; + settle(path.join(state.out, FILES.spend), a.attempt, 'unknown'); + journalRow(state.out, { event: 'attempt_deferred', attempt: a.attempt, phase, voice, reason: o.reason }); + } + if (!voiceTerminal(v)) Object.assign(v, { status: 'skipped', detail: `deferred: ${o.reason}` }); + } + const lines = releaseUndispatched(state); + const native = voiceRows(state, phase, 'native'), outside = voiceRows(state, phase, 'outside'); + const consensus = consensusRecord(phase, ps.native?.status === 'completed' ? { rows: native, model: ps.native.model } : null, + ps.outside?.status === 'completed' ? { rows: outside, model: ps.outside.model, provider: ps.outside.provider } : null, reconcile(native, outside, [])); + const answered = settledIds(state); + let rows: OpenRow[] = [...native, ...outside]; + let carried: string | undefined; + if (!rows.length) { + rows = reviewRows(state, previousReview(state, phase)).filter(r => !answered.has(r.id)); + if (rows.length) { + carried = `${phase}-deferred.findings.jsonl`; + fs.writeFileSync(path.join(state.out, carried), rows.map(r => JSON.stringify({ ...r, disposition: 'deferred' })).join('\n') + '\n'); + state.artifacts[carried] = { path: carried, sha256: sha256File(path.join(state.out, carried)) }; + } + } + const open = rows.filter(r => !r.settled_by && !answered.has(r.id)); + const counts = Object.fromEntries(['Critical', 'High', 'Medium', 'Low', 'Informational'].map(s => [s, open.filter(r => r.severity === s).length])); + const blockers = open.filter(r => r.severity === 'Critical' || r.urgent || r.user_challenge).map(r => r.id); + const round = roundOf(state, phase); + ps.deferred = { reason: o.reason, by: o.by, cause: o.cause, at, round, counts, ...(carried ? { carried_file: carried } : {}), blockers, open: open.length }; + const head = `DEFERRED: ${phase} closed accepted-with-findings at round ${round} (${o.reason}; by ${o.by}); open findings: ${severityText(counts)}; ${blockers.length ? `owner items: ${blockers.join(', ')}` : 'no Critical, urgent or user-challenge finding, so no owner item'}\n\n`; + fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify({ ...consensus, deferred: ps.deferred }, null, 2) + '\n'); + fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), head + renderConsensus(consensus, [...native, ...outside])); + ps.consensus = { confirmed: consensus.confirmed, unverified: consensus.unverified, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage, deferred: true }; + ps.closed_at = at; + journalRow(state.out, { event: 'phase_deferred', phase, ...ps.deferred }); + lines.push(`PHASE_DEFERRED: ${phase} round=${round}/${roundCap(state, phase) ?? 'none'} by=${o.by} open=${open.length} (${severityText(counts)}) blockers=${blockers.join(',') || 'none'} reason=${JSON.stringify(o.reason)}`); + return { lines, blockers }; +} + +/** The round-cap items: one per deferred phase, one per deferred blocker, one per capped plan delta. */ +function capItems(state: RunState, next: { d: () => string; uc: () => string }): GateItem[] { + const items: GateItem[] = []; + const push = (item: GateItem, ids: string) => items.push({ ...item, origin: ['cap', ids, optionHash(item.options)].join('|') }); + const reopen = (phase: string) => ({ key: 'b', text: `reopen ${phase} for one more round (an owner budget raise${phase === 'eng' ? '' : '; Eng reruns last'})`, changes_plan: false }); + for (const phase of state.phase_order) { + const def = state.phases[phase]?.deferred; + if (!def) continue; + const rows: OpenRow[] = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'), + ...(def.carried_file ? readJsonl(path.join(state.out, def.carried_file)).rows as unknown as OpenRow[] : [])]; + push({ id: next.d(), title: `${phase} closed accepted-with-findings at round ${def.round} (${def.reason}): ${def.open} open finding(s), ${severityText(def.counts)}, carried into tasks.jsonl`, + kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`, + options: [{ key: 'a', text: 'accept with findings, deferred to the build', changes_plan: false }, reopen(phase)] }, `deferred-${phase}`); + for (const id of def.blockers) { + const f = rows.find(r => r.id === id); + if (!f) continue; + const risk = f.severity === 'Critical' || f.urgent; + push({ id: next.uc(), title: `${f.severity}${f.urgent ? ' urgent' : ''}${f.user_challenge ? ' user challenge' : ''} left open when ${phase} closed deferred: ${f.title} [${id}]`, + kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id }, + why: `${phase} closed deferred (${def.reason}) with this finding open; only you can accept it`, + options: [{ key: 'a', text: 'carry it into the build as a blocking task (you accept the risk)', changes_plan: false }, reopen(phase)] }, id); + } + } + for (const delta of pendingDeltas(state)) { + push({ id: next.uc(), title: `plan changed after ${delta.phase} cap: unreviewed delta (+${delta.added} -${delta.removed} lines, ${delta.old_plan_sha256.slice(0, 12)} -> ${delta.new_plan_sha256.slice(0, 12)})`, + kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) }, + why: `revise v${delta.k} changed the plan after ${delta.phase} used round ${delta.round} of ${delta.cap}, so ${delta.phase} never reviewed it`, + options: [{ key: 'a', text: 'accept the unreviewed delta into the approved plan', changes_plan: false }, reopen(delta.phase)] }, `delta-${delta.k}`); + } + return items; +} + // --------------------------------------------------------------------------- // the gate list // --------------------------------------------------------------------------- @@ -108,7 +196,7 @@ export function buildGate(state: RunState, gateRev: number): GateList { let d = 0, uc = 0; for (const phase of state.phase_order) { const ps = state.phases[phase]; - if (!ps?.closed_at) continue; + if (!ps?.closed_at || ps.deferred) continue; const consensus = JSON.parse(fs.readFileSync(path.join(state.out, `${phase}-consensus.json`), 'utf8')) as ConsensusRecord; const native = voiceRows(state, phase, 'native'); const outside = voiceRows(state, phase, 'outside'); @@ -118,7 +206,8 @@ export function buildGate(state: RunState, gateRev: number): GateList { const push = (item: GateItem, ids: string) => items.push({ ...item, origin: origin(ids, item.options) }); const challenged = new Set(); for (const row of consensus.rows) { - const o = byId.get(row.outside_id)! as FindingRow & { user_challenge?: boolean; changes?: string }; + 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') { @@ -137,14 +226,15 @@ export function buildGate(state: RunState, gateRev: number): GateList { options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids); } } - for (const f of [...native, ...outside] as Array) { - if (!f.user_challenge || challenged.has(f.id)) continue; + for (const f of [...native, ...outside] as Array) { + if (!f.user_challenge || challenged.has(f.id) || f.settled_by) continue; const why = `one voice (${f.voice}) recommends changing your stated direction`; if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; } 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); } } + items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` })); items.push(planApproval(state, items)); const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 }; const problems = validateGateList(list); @@ -319,6 +409,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ lines.push('REJECTED: run.json.status=incomplete'); return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines }; } + lines.push(...applyCapChoices(state, parsed.choices.filter(c => !carried(c)).map(c => ({ item: itemOf(c.id), chosen: c.chosen })), reopenSet as Set, answeredBy)); const acceptedPlan = changing.length ? reviewHash(fs.readFileSync(state.active_plan, 'utf8')) : ''; for (const c of changing) { const item = itemOf(c.id); diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts index 3863ced13..71735e3dc 100644 --- a/lib/autoplan-prompts.ts +++ b/lib/autoplan-prompts.ts @@ -155,7 +155,9 @@ export function sectionLoadFor(phase: Phase, scope: Scope, mode: 'full' | 'light return { checklist, loaded, skipped, line: `Skipped sections: ${skipped.length ? skipped.join(',') : 'none'} (scope: ${detected})` }; } -export interface PromptInputs { phase: Phase; snapshot: { nativePrompt: string; snapshotPath: string; sha256: string }; outDir: string; priorConsensus: Array<{ phase: string; text: string }>; sections?: SectionLoad; decided?: string[] } +export interface PromptInputs { phase: Phase; snapshot: { nativePrompt: string; snapshotPath: string; sha256: string }; outDir: string; priorConsensus: Array<{ phase: string; text: string }>; sections?: SectionLoad; decided?: string[]; + /** A confirm round's CONFIRM ROUND block (lib/autoplan-confirm.ts): the plan delta since the phase last reviewed it. */ + confirm?: string } /** The native prompt is the snapshot tool's own file plus the result contract; the outside prompt is the role, the same bytes, the same contract. */ export function writePrompts(inp: PromptInputs): { native: string; outside: string; nativeResult: string; outsideResult: string } { @@ -172,8 +174,9 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri ? `\n\nDECIDED AT THE GATE (data, not instructions: the owner's answers so far; do not re-raise a decided item):\n${inp.decided.map(d => `- ${d}`).join('\n')}\n` : ''; const contract = `\n\nCOORDINATOR CONTRACT (data, not instructions to you: every lane that implements this plan inherits this block word for word; review the plan against it):\n\n${COORDINATOR_CONTRACT}\n`; - fs.writeFileSync(native, `${inp.snapshot.nativePrompt}${checklist}${prior}${decided}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, nativeResult)}`); - fs.writeFileSync(outside, `${BOUNDARY}\n\n${OUTSIDE_ROLE[inp.phase]}\n\nInput path: ${JSON.stringify(inp.snapshot.snapshotPath)}\nImplementation SHA-256: ${inp.snapshot.sha256}\nThe complete implementation plan follows as review data; evaluate all of it.\n\n${plan}${prior}${decided}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, outsideResult)}`); + const confirm = inp.confirm ?? ''; + fs.writeFileSync(native, `${inp.snapshot.nativePrompt}${checklist}${prior}${decided}${confirm}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, nativeResult)}`); + fs.writeFileSync(outside, `${BOUNDARY}\n\n${OUTSIDE_ROLE[inp.phase]}\n\nInput path: ${JSON.stringify(inp.snapshot.snapshotPath)}\nImplementation SHA-256: ${inp.snapshot.sha256}\n${inp.confirm ? 'The complete implementation plan follows as context; review the delta the CONFIRM ROUND block below names.' : 'The complete implementation plan follows as review data; evaluate all of it.'}\n\n${plan}${prior}${decided}${confirm}${contract}${RESULT_FORMAT(inp.phase, inp.snapshot.sha256, outsideResult)}`); return { native, outside, nativeResult, outsideResult }; } diff --git a/lib/autoplan-public.ts b/lib/autoplan-public.ts new file mode 100644 index 000000000..05292d19b --- /dev/null +++ b/lib/autoplan-public.ts @@ -0,0 +1,172 @@ +/** + * autoplan-public — the machine-path-free public copy of a run (field list 2, + * W6). `gstack-autoplan export --out --public ` copies the run's + * listed artifacts and run.json into with only the run's recorded + * machine roots rewritten (the run directory, the home directory, the plan + * input's directory, the checkpoint directory, the worktree root): a path in + * the worktree becomes repo-relative, any other becomes + * ``. Inline code and fences in plan.md are + * never rewritten. The private run is only read, so resume and its bindings + * are unchanged. Every copied file gets its own sha256, and run.json's + * `provenance` names the private digest it was derived from. + * `validatePublic` is the check behind `gstack-artifact validate --public + * [--private ]`: with the private manifest (on the exporting + * machine) any recorded root is an ARTIFACT_MACHINE_PATH error with + * file:line; without it the provenance digests are checked; both WARN with + * file:line on generic home-path patterns. Export runs the private check on a + * staging copy before anything reaches . + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { repoRoot } from './autoplan-plan-text'; +import { checkpointRoot } from './autoplan-checkpoint'; +import { FILES, RunError, type RunState } from './autoplan-state'; +import { sha256File, validateRun, type ValidationError } from './headless-artifacts'; + +export const LOCAL_NOTES = ''; +const GENERIC_HOME = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\[^\\\s]+\\)/; + +export interface RecordedRoot { label: string; root: string } +interface Provenance { private_run: string; private_manifest_sha256: string; exported_at: string; roots: string[]; files: Record } + +/** The machine roots a run recorded on this machine, longest first; the worktree root is labelled for the repo-relative rewrite. */ +export function recordedRoots(state: Pick, env: NodeJS.ProcessEnv = process.env): RecordedRoot[] { + const input = state.plan_input ?? state.plan_source; + const worktree = input ? repoRoot(path.dirname(input)) : undefined; + const candidates: RecordedRoot[] = [ + { label: 'run', root: state.out }, { label: 'home', root: os.homedir() }, + ...(input ? [{ label: 'plan_input', root: path.dirname(input) }] : []), + ...(checkpointRoot(env) ? [{ label: 'checkpoint', root: checkpointRoot(env)! }] : []), + ...(worktree ? [{ label: 'worktree', root: worktree }] : []), + ]; + const seen = new Set(); + return candidates.map(c => ({ ...c, root: path.resolve(c.root) })).filter(c => c.root !== path.parse(c.root).root && !seen.has(c.root) && seen.add(c.root)) + .sort((a, b) => b.root.length - a.root.length); +} + +/** Rewrite every recorded root in `text`: worktree paths become repo-relative, the rest ``. */ +export function rewriteRoots(text: string, roots: RecordedRoot[]): string { + const worktree = roots.find(r => r.label === 'worktree')?.root; + const inWorktree = (p: string) => !!worktree && (p === worktree || p.startsWith(worktree + path.sep)); + let out = text; + for (const r of roots) { + if (r.label !== 'worktree' && inWorktree(r.root)) continue; + const local = r.label !== 'worktree'; + out = out.split(r.root + path.sep).join(local ? `${LOCAL_NOTES}/` : '').split(r.root).join(local ? LOCAL_NOTES : '.'); + } + return out; +} + +/** Per line of a Markdown text: inside a fenced block (or a fence marker itself), or not. */ +function fencedLines(text: string): boolean[] { + let fence: string | null = null; + return text.split('\n').map(line => { + const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]; + if (marker && (fence === null || marker.startsWith(fence))) { fence = fence === null ? marker[0]!.repeat(marker.length) : null; return true; } + return fence !== null; + }); +} + +/** plan.md: rewrite prose only; fenced blocks and inline code spans keep their bytes. */ +function rewriteProse(text: string, roots: RecordedRoot[]): string { + const fenced = fencedLines(text); + return text.split('\n').map((line, i) => (fenced[i] ? line + : line.split(/(`[^`]*`)/).map(part => (part.startsWith('`') && part.endsWith('`') && part.length > 1 ? part : rewriteRoots(part, roots))).join(''))).join('\n'); +} + +function rewriteJson(value: unknown, roots: RecordedRoot[]): unknown { + if (typeof value === 'string') return rewriteRoots(value, roots); + if (Array.isArray(value)) return value.map(v => rewriteJson(v, roots)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [rewriteRoots(k, roots), rewriteJson(v, roots)])); + return value; +} + +/** Every recorded-root hit (error) and generic home-path hit (warning) in the files under `dir`, with file:line. */ +function scan(dir: string, roots: RecordedRoot[]): { errors: ValidationError[]; warnings: string[] } { + const errors: ValidationError[] = [], warnings: string[] = []; + const walk = (rel: string): string[] => fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => (e.isDirectory() ? walk(path.join(rel, e.name)) : e.isFile() ? [path.join(rel, e.name)] : [])); + for (const rel of walk('')) { + const text = fs.readFileSync(path.join(dir, rel), 'utf8'); + const fenced = rel === 'plan.md' ? fencedLines(text) : []; + text.split('\n').forEach((line, i) => { + const hit = roots.find(r => line.includes(r.root)); + const code = rel === 'plan.md' && (fenced[i] || /`/.test(line)); + if (hit) errors.push({ code: 'ARTIFACT_MACHINE_PATH', path: `${rel}:${i + 1}`, message: `the recorded ${hit.label} root ${hit.root} appears in the public copy${code ? ' (inside code, which export never rewrites: edit the plan)' : ''}` }); + else if (GENERIC_HOME.test(line)) warnings.push(`WARN: ${rel}:${i + 1} has a home-directory path (${GENERIC_HOME.exec(line)![0]}); check it is not a machine path`); + }); + } + return { errors, warnings }; +} + +export interface PublicValidation { valid: boolean; errors: ValidationError[]; warnings: string[]; run?: string } + +/** `gstack-artifact validate --public [--private ]`. */ +export function validatePublic(dir: string, privateRunJson?: string, env: NodeJS.ProcessEnv = process.env): PublicValidation { + const runJson = path.join(dir, FILES.run); + const structural = validateRun(runJson); + const errors = [...structural.errors]; + const manifest = structural.manifest as (RunState & { provenance?: Provenance }) | undefined; + const prov = manifest?.provenance; + if (manifest && !prov) errors.push({ code: 'ARTIFACT_SCHEMA', path: 'run.json.provenance', message: 'a public copy names the private digests it came from; this run.json has no provenance (write it with gstack-autoplan export --public)' }); + for (const [name, d] of Object.entries(prov?.files ?? {})) { + const file = path.join(dir, name); + if (!fs.existsSync(file)) errors.push({ code: 'ARTIFACT_MISSING', path: name, message: 'listed in provenance, absent from the public copy' }); + else if (sha256File(file) !== d.public_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `sha256 differs from provenance public_sha256 ${d.public_sha256.slice(0, 12)}` }); + } + let roots: RecordedRoot[] = []; + if (privateRunJson) { + const priv = JSON.parse(fs.readFileSync(privateRunJson, 'utf8')) as RunState; + priv.out = path.dirname(path.resolve(privateRunJson)); + roots = recordedRoots(priv, env); + if (prov && prov.private_run !== priv.run) errors.push({ code: 'ARTIFACT_UNBOUND_ID', path: 'run.json.provenance.private_run', message: `${prov.private_run} is not the private run ${priv.run}` }); + for (const [name, d] of Object.entries(prov?.files ?? {})) { + const listed = name === FILES.run ? sha256File(privateRunJson) : priv.artifacts?.[name]?.sha256; + if (listed !== d.private_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `provenance private_sha256 ${d.private_sha256.slice(0, 12)} is not the private run's ${(listed ?? 'missing').slice(0, 12)}` }); + } + } + const s = scan(dir, roots); + errors.push(...s.errors); + return { valid: errors.length === 0, errors, warnings: s.warnings, run: manifest?.run }; +} + +/** Write the public copy of `state` to `dir` without touching the private run; refuses on any recorded root left behind. */ +export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEnv = process.env): { files: string[]; warnings: string[] } { + const target = path.resolve(dir); + const out = path.resolve(state.out); + if (target === out || target.startsWith(out + path.sep) || out.startsWith(target + path.sep)) throw new RunError('ARTIFACT_PATH_ESCAPE', `--public ${target} overlaps the run directory ${out}`, 3); + if (fs.existsSync(target) && fs.readdirSync(target).length) { + let previous: string | undefined; + try { previous = JSON.parse(fs.readFileSync(path.join(target, FILES.run), 'utf8')).provenance?.private_run; } catch { /* not a public copy */ } + if (previous !== state.run) throw new RunError('ARTIFACT_PUBLIC_TARGET_EXISTS', `${target} is not empty and holds no earlier public copy of ${state.run}`, 3); + } + const roots = recordedRoots(state, env); + const privateRun = path.join(out, FILES.run); + const staging = `${target}.public-${process.pid}`; + fs.rmSync(staging, { recursive: true, force: true }); + try { + const files: Provenance['files'] = {}; + for (const [name, entry] of Object.entries(state.artifacts)) { + const src = path.join(out, entry.path); + if (!fs.existsSync(src) || sha256File(src) !== entry.sha256) throw new RunError('ARTIFACT_STALE', `${entry.path} does not match run.json; run gstack-autoplan export --out ${out} first`, 3); + const text = fs.readFileSync(src, 'utf8'); + const dest = path.join(staging, entry.path); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, name === 'plan.md' ? rewriteProse(text, roots) : entry.path.endsWith('.json') ? JSON.stringify(rewriteJson(JSON.parse(text), roots), null, 2) + '\n' : rewriteRoots(text, roots)); + files[name] = { private_sha256: entry.sha256, public_sha256: sha256File(dest) }; + } + const manifest = rewriteJson(JSON.parse(fs.readFileSync(privateRun, 'utf8')), roots) as RunState & { provenance?: Provenance }; + for (const [name, d] of Object.entries(files)) manifest.artifacts[name] = { path: state.artifacts[name]!.path, sha256: d.public_sha256 }; + manifest.provenance = { private_run: state.run, private_manifest_sha256: sha256File(privateRun), exported_at: new Date().toISOString(), roots: roots.map(r => r.label), files }; + fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(manifest, null, 2) + '\n'); + const check = validatePublic(staging, privateRun, env); + const leaks = check.errors.filter(e => e.code === 'ARTIFACT_MACHINE_PATH'); + if (leaks.length) throw new RunError('ARTIFACT_MACHINE_PATH', leaks.slice(0, 5).map(e => `${e.path}: ${e.message}`).join('; ') + (leaks.length > 5 ? ` (+${leaks.length - 5} more)` : ''), 3); + fs.rmSync(target, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(staging, target); + return { files: [...Object.values(state.artifacts).map(e => path.join(target, e.path)), path.join(target, FILES.run)], warnings: check.warnings }; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts index 48f5eb5e1..48b64ff19 100644 --- a/lib/autoplan-reconcile.ts +++ b/lib/autoplan-reconcile.ts @@ -175,9 +175,11 @@ export function parseRecordFindings(text: string): { findings: CanonicalFinding[ } /** Run-bound findings.jsonl rows for one voice of one phase. */ -export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; phase: string; voice: Voice; model?: string }): FindingRow[] { +/** Ids are `---`; a confirm round r > 1 qualifies them as `--r--`. */ +export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; phase: string; voice: Voice; model?: string; round?: number }): FindingRow[] { + const stem = `${ctx.run}-${ctx.phase}${(ctx.round ?? 1) > 1 ? `-r${ctx.round}` : ''}-${ctx.voice}`; return findings.map((f, i) => ({ - schema_version: 1, run: ctx.run, id: `${ctx.run}-${ctx.phase}-${ctx.voice}-${i + 1}`, phase: ctx.phase, voice: ctx.voice, + schema_version: 1, run: ctx.run, id: `${stem}-${i + 1}`, phase: ctx.phase, voice: ctx.voice, severity: f.severity, title: f.title, disposition: 'open', ...(ctx.model ? { model: ctx.model } : {}), ...(f.file ? { file: f.file } : {}), ...(f.line !== undefined ? { line: f.line } : {}), ...(f.fix ? { resolution: f.fix } : {}), ...(f.plan_items?.length ? { plan_items: f.plan_items } : {}), @@ -201,8 +203,8 @@ export interface ReconciliationRow { } export interface ReconciliationOverride { outside_id: string; native_id?: string | null; disposition: ReconciliationDisposition; resolution: string; proposal?: string } -const tokens = (title: string) => new Set(title.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(t => t.length > 2)); -function jaccard(a: Set, b: Set): number { +export const tokens = (title: string) => new Set(title.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; for (const t of a) if (b.has(t)) inter++; diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index 777416cc8..572927853 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -16,7 +16,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; -import { applyRenames, approve, closeDraftDirections, planReopen } from './autoplan-gate'; +import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate'; +import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget'; +import { diffStat, lineDiff } from './autoplan-confirm'; import { SETTLEABLE_CHANGES } from './autoplan-reconcile'; import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text'; import { parseScopeTokens } from './autoplan-scope'; @@ -80,12 +82,21 @@ function settleEvidence(state: RunState, ids: string[], revised?: string): { pla return { plan_sha256: active, changes }; } +/** Every accepted change is in: approve, unless a plan delta after a round cap still needs the owner (a new gate revision). */ +function approveOrRegate(state: RunState, at: string): string[] { + if (pendingDeltas(state).length) { + finishGate(state); + return [`GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`]; + } + return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; +} + /** Every accepted change is discharged: the answered gate stands (complete) unless a revise reopened phases still to rerun. */ function finishAmendments(state: RunState, at: string): string[] { if (state.status !== 'amend_pending' || pendingAmendments(state).length) return []; journal(state.out, { event: 'amendments_settled', gate_rev: state.gate_rev ?? null }); if (state.phase_order.some(p => !state.phases[p]?.closed_at)) { state.status = 'running'; return []; } - return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; + return approveOrRegate(state, at); } export function reviseRun(o: ReviseOptions): ReviseResult { @@ -130,11 +141,19 @@ export function reviseRun(o: ReviseOptions): ReviseResult { const added = order.filter(p => !state.phase_order.includes(p)); const opened = state.phase_order.filter(p => state.phases[p]?.snapshot); const changed = opened.filter(p => state.phases[p]!.snapshot !== newHash); - const reopen = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0)); + const touched = state.phase_order.filter(p => changed.includes(p) || (p === 'eng' && opened.includes('eng') && changed.length > 0)); + // A phase that used its last round is not reopened: the delta becomes an owner gate item, so the cap neither loops nor hides it. + const capped = touched.filter(p => atCap(state, p)); + const reopen = touched.filter(p => !capped.includes(p)); + const delta = capped.length ? diffStat(lineDiff(fs.readFileSync(state.active_plan, 'utf8'), fs.readFileSync(path.join(staging, FILES.active), 'utf8'))) : { added: 0, removed: 0 }; const atGate = state.status === 'gate_pending' ? state.gate_rev : undefined; journal(out, { event: 'revise_intent', k, staging: path.relative(out, staging), plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates, withdraw, settle }); const renames = reopen.length ? planReopen(state, reopen, state.gate_rev ?? 0, `revise v${k}`) : []; + for (const phase of capped) { + (state.capped_deltas ??= []).push({ k, phase, round: roundOf(state, phase), cap: roundCap(state, phase)!, old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta, status: 'pending', at }); + journal(out, { event: 'cap_delta', k, phase, round: roundOf(state, phase), cap: roundCap(state, phase), old_plan_sha256: oldHash, new_plan_sha256: newHash, ...delta }); + } for (const p of added) state.phases[p] = {}; state.phase_order = order; state.required_phases = order; state.scope = { ...detected, ui: detected.ui || state.scope.ui, dx: detected.dx || state.scope.dx }; @@ -143,11 +162,17 @@ export function reviseRun(o: ReviseOptions): ReviseResult { delete state.plan_reality; const settled = [...settleAmendments(state, incorporates, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' }), ...settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 }).map(r => ({ ...r, changes: evidence!.changes[r.decision as string] }))]; - if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : 'complete'; + if (amending) state.status = pendingAmendments(state).length ? 'amend_pending' : state.phase_order.some(p => !state.phases[p]?.closed_at) ? 'running' : pendingDeltas(state).length ? 'gate_pending' : 'complete'; + const regate = capped.length > 0 && state.status === 'gate_pending'; const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, reopened: reopen, added, at_gate_rev: atGate ?? null }; fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n'); publish(state, staging, renames, committed, settled); - const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}`, amendmentLine(state)]; + const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`, amendmentLine(state)]; + for (const phase of capped) lines.push(`CAPPED: ${phase} used round ${roundOf(state, phase)} of ${roundCap(state, phase)}: not reopened; the gate asks the owner about the unreviewed delta (+${delta.added} -${delta.removed} lines)`); + if (regate) { + lines.push(...approveOrRegate(state, at)); + writeState(state); + } if (state.status === 'complete') { lines.push(...closeDraftDirections(state, at.slice(0, 10)), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`); exportRun(state); diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 6346133b4..3b03ef132 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -19,7 +19,7 @@ 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, closePhase, finishGate } from './autoplan-gate'; +import { amendmentSteps, applyGuidance, closeDeferred, closePhase, finishGate } from './autoplan-gate'; 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'; @@ -31,6 +31,8 @@ import { sha256File } from './headless-artifacts'; import { outsideConsent } from './outside-consent'; import { familyConflict, modelFamily } from './outside-voice-runner'; import { renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger'; +import { DEFAULT_MAX_ROUNDS, attemptEstimate, overCap, resumeStop, roundCap, roundOf, roundsLine, stopForBudget, stopLines } from './autoplan-budget'; +import { annotateConfirm, confirmBlock } from './autoplan-confirm'; /** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */ const LOCK_WRITE_GRACE_MS = 30_000; @@ -69,7 +71,7 @@ export function withRunLock(out: string, fn: () => T, opts: { steal?: boolean // --------------------------------------------------------------------------- // init // --------------------------------------------------------------------------- -export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean } +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 } /** `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 { @@ -105,6 +107,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState { // codex_reviews gates the outside voice here, before any dispatch: a refusal is a terminal not_run outcome per phase. const consent = (o.outsideEnabled ?? true) ? outsideConsent({ userInvoked: false }) : undefined; const consentMissing = consent && !consent.allowed ? consent.lines[0] : undefined; + const sessionKind = o.sessionKind ?? process.env.GSTACK_SESSION_KIND ?? 'unattended'; const phases: Record = {}; for (const p of PHASES) if (!order.includes(p)) phases[p] = { skipped: p === 'design' ? 'no UI scope detected' : 'no developer-facing scope detected', native: { status: 'skipped' }, outside: { status: 'skipped' } }; const state: RunState = { @@ -112,7 +115,7 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState { plan_source: init.sourcePlan, active_plan: init.activePlan, restore: init.restorePath, scope, phase_order: order, outside_enabled: (o.outsideEnabled ?? true) && !consentMissing, attempt_counter: 0, spend_cap_usd: o.spendCap ?? null, ...(consentMissing ? { outside_not_run: { reason: 'consent_missing', detail: consentMissing } } : {}), - session_kind: o.sessionKind ?? process.env.GSTACK_SESSION_KIND ?? 'unattended', host: 'gstack-autoplan', history: [], created_at: now(), updated_at: now(), + session_kind: sessionKind, host: 'gstack-autoplan', max_rounds: o.maxRounds ?? (sessionKind === 'unattended' ? DEFAULT_MAX_ROUNDS : null), history: [], created_at: now(), updated_at: now(), 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 } : {}), @@ -159,7 +162,9 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void { const sections = sectionLoadFor(phase, state.scope, state.session_kind === 'unattended' || state.light ? 'light' : 'full'); const decided = (state.gate_rev ?? 0) >= 1 ? readDecisions(state.out).filter(r => r.chosen != null && r.status !== 'pending') .map(r => `r${r.gate_rev} ${(r.label ?? r.id).toLowerCase()}${r.chosen} ${r.title}${r.chosen_text ? ` -> ${r.chosen_text}` : ''}`) : []; - const prompts = writePrompts({ phase, snapshot: { nativePrompt: snap.nativePrompt, snapshotPath: snap.snapshotPath, sha256: snap.sha256 }, outDir: state.out, priorConsensus: prior, sections, decided }); + const confirm = confirmBlock(state, phase, snap.snapshotPath, roundOf(state, phase)); + const prompts = writePrompts({ phase, snapshot: { nativePrompt: snap.nativePrompt, snapshotPath: snap.snapshotPath, sha256: snap.sha256 }, outDir: state.out, priorConsensus: prior, sections, decided, confirm: confirm?.text }); + if (confirm) ps.confirm = confirm.confirm; ps.snapshot = snap.sha256; ps.snapshot_dir = path.dirname(snap.snapshotPath); ps.snapshot_path = snap.snapshotPath; ps.methodology = methodology.methodologyPath; ps.opened_at = now(); ps.sections = { loaded: sections.loaded, skipped: sections.skipped, checklist: !!sections.checklist }; ps.native = { status: 'pending', attempts: [] }; @@ -174,6 +179,7 @@ export function nextStep(o: NextOptions): NextResult { const state = readState(path.resolve(o.out)); 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 (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); @@ -181,7 +187,11 @@ export function nextStep(o: NextOptions): NextResult { const each = pendingAmendments(state).map(a => `APPLY ${a.item ?? a.decision}: ${applyGuidance(a)}`).join('; '); throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')} (${each}); ${amendmentSteps(state, state.out)} (or --withdraw )`, 3); } - const phase = currentPhase(state); + let phase = currentPhase(state); + while (phase && !state.phases[phase]?.snapshot && overCap(state, phase)) { + 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); writeState(state); return result; } const first = !state.phase_order.some(p => state.phases[p]?.snapshot); openPhase(state, phase, o.cwd); @@ -194,6 +204,15 @@ export function nextStep(o: NextOptions): NextResult { if (ps.outside?.reason === 'consent_missing') lines.push(`OUTSIDE: ${phase} not_run reason=consent_missing`, String(ps.outside.detail)); 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!; + const estimate = attemptEstimate(state, o.estimateUsd, process.env); + if (estimate.line) lines.push(estimate.line); + 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); + 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] }); + } for (const voice of ['native', 'outside'] as const) { const v = voiceState(state, phase, voice); if (voiceTerminal(v)) continue; @@ -208,12 +227,11 @@ export function nextStep(o: NextOptions): NextResult { continue; } const attempt = `${state.run}-a${++state.attempt_counter}`; - const estimate = o.estimateUsd ?? 0; - try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: `${phase}/${voice}` }); } + try { reserve(path.join(state.out, FILES.spend), attempt, estimate.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, 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: estimate.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), result: path.relative(state.out, row.result), estimate_usd: estimate }); + journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: path.relative(state.out, row.prompt), prompt_sha256: sha256File(row.prompt), result: path.relative(state.out, row.result), estimate_usd: estimate.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 }); @@ -221,7 +239,7 @@ export function nextStep(o: NextOptions): NextResult { state.status = 'awaiting_result'; writeState(state); for (const a of result.attempts) lines.push(`ATTEMPT: ${a.attempt} phase=${phase} voice=${a.voice} prompt=${a.prompt} result=${a.result} model_family=${a.model_family}${a.new ? '' : ' (open)'}`); - lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend))))); + lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend)))), roundsLine(state)); return result; } function familyHint(state: RunState, voice: Voice): string { @@ -324,7 +342,9 @@ export function submitResult(o: SubmitOptions): SubmitResult { if (process.env.GSTACK_AUTOPLAN_FAULT === 'submit-after-seal') throw new Error('fault injected: submit interrupted after sealing (GSTACK_AUTOPLAN_FAULT, tests only)'); const planFiles = [ps.snapshot_path, ps.snapshot_path && path.join(path.dirname(ps.snapshot_path), 'source-implementation.md'), state.active_plan].filter((p): p is string => !!p); const pathCtx = { cwd: o.cwd, planFiles, planSource: state.plan_source, planName: path.basename(state.plan_input ?? state.plan_source) }; - const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model }); + const round = roundOf(state, phase); + const rows = canonicalRows(bind.findings.map(f => { const { file, line, ...rest } = f; return { ...rest, ...normalizeFindingFile({ file, line }, pathCtx) }; }), { run: state.run, phase, voice: o.voice, model, round }); + const confirm = round > 1 ? annotateConfirm(state, phase, rows, { planName: pathCtx.planName, planSource: state.plan_source, snapshotPath: ps.snapshot_path! }) : undefined; fs.writeFileSync(`${bound}.findings.jsonl`, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '')); const sha = sha256File(bound); const provider = o.runner ?? 'host-subagent'; @@ -336,7 +356,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { if (bind.receipt === 'attested') (state.deviations ??= []).push(`receipt attested: ${phase} ${o.voice} ${open.attempt} bound from a hand-run record without a reviewer receipt (snapshot ${ps.snapshot.slice(0, 12)}; reason: ${o.attest})`); state.artifacts[path.basename(bound)] = { path: path.basename(bound), sha256: sha }; if (o.voice === 'native') state.native_model = model; else state.outside_model = model; - const lines = [`BOUND: ${open.attempt} phase=${phase} voice=${o.voice} model=${model} findings=${rows.length} sha256=${sha.slice(0, 12)} receipt=${bind.receipt}`]; + const lines = [`BOUND: ${open.attempt} phase=${phase} voice=${o.voice} model=${model} findings=${rows.length} sha256=${sha.slice(0, 12)} receipt=${bind.receipt}${confirm ? ` round=${round} correlated=${confirm.correlated} settled=${confirm.settled}` : ''}`]; lines.push(...recordPlanReality(state)); const closed = ps.native?.status === 'completed' && voiceTerminal(ps.outside); if (closed) lines.push(...closeAndAdvance(state, phase, o.reconcile)); @@ -385,7 +405,7 @@ export function resumeRun(out: string): ResumeResult { const recovered = recoverRevise(out); const state = readState(out); const rows = readJournal(out); - const terminal = new Set(rows.filter(r => ['submitted', 'cancelled', 'superseded', 'outside_unavailable', 'outside_failed'].includes(r.event)).map(r => r.attempt)); + const terminal = new Set(rows.filter(r => ['submitted', 'cancelled', 'superseded', 'outside_unavailable', 'outside_failed', 'attempt_deferred'].includes(r.event)).map(r => r.attempt)); const unknown: string[] = []; const rebound: string[] = []; // An outside outcome journaled before its manifest write: replay it onto the attempt it names, never onto a later round. @@ -428,6 +448,7 @@ export function resumeRun(out: string): ResumeResult { if (!ps?.snapshot || ps.closed_at) continue; if (ps.native?.status === 'completed' && voiceTerminal(ps.outside)) closePhase(state, phase, {}); } + const stopped = resumeStop(state); if (!currentPhase(state) && !['gate_pending', 'amend_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(state); else if (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)))); @@ -435,7 +456,7 @@ export function resumeRun(out: string): ResumeResult { } journal(out, { event: 'resumed', unknown, rebound, status: state.status }); writeState(state); - const lines = [...recovered, `RESUMED: run=${state.run} status=${state.status} execution_unknown=${unknown.length} rebound=${rebound.length}`]; + const lines = [...recovered, ...stopped, `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 }; } @@ -447,7 +468,7 @@ export function statusLines(state: RunState): string[] { if (!ps) { lines.push(`PHASE: ${p} pending`); continue; } if (ps.skipped) { lines.push(`PHASE: ${p} skipped (${ps.skipped})`); continue; } const v = (voice: Voice) => { const s = ps[voice]; return `${voice}=${s?.status ?? 'pending'}${s?.model ? `:${s.model}` : ''}${s?.attempt ? `@${s.attempt}` : ''}`; }; - lines.push(`PHASE: ${p} ${ps.closed_at ? 'closed' : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`); + lines.push(`PHASE: ${p} ${ps.closed_at ? (ps.deferred ? `closed-deferred(${ps.deferred.reason})` : 'closed') : ps.snapshot ? 'open' : 'pending'} ${v('native')} ${v('outside')}${ps.consensus ? ` confirmed=${(ps.consensus as any).confirmed} disagree=${(ps.consensus as any).disagree} new=${(ps.consensus as any).new}` : ''}`); } for (const a of pendingAmendments(state)) lines.push(`AMEND_PENDING: ${a.decision} ${a.item ?? ''}${a.changes ? ` changes=${a.changes}` : ''} -> ${applyGuidance(a)}`); for (const p of state.phase_order) for (const voice of ['native', 'outside'] as const) { @@ -455,6 +476,8 @@ export function statusLines(state: RunState): string[] { if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`); } if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend))))); + lines.push(roundsLine(state), ...stopLines(state)); + 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']; diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts index b7e48ca44..f82ce08c9 100644 --- a/lib/autoplan-state.ts +++ b/lib/autoplan-state.ts @@ -13,7 +13,7 @@ import { readJsonl } from './headless-artifacts'; import type { ResultCodeName } from './result-codes'; export type Voice = 'native' | 'outside'; -export interface AttemptState { attempt: string; state: 'dispatched' | 'submitted' | 'execution_unknown' | 'cancelled' | 'superseded'; prompt: string; result: string; estimate_usd: number; dispatched_at: string; terminal_at?: string } +export interface AttemptState { attempt: string; state: 'dispatched' | 'submitted' | 'execution_unknown' | 'cancelled' | 'superseded' | 'deferred'; prompt: string; result: string; estimate_usd: number; dispatched_at: string; terminal_at?: string } export interface VoiceState extends VoiceOutcome { attempt?: string; attempts?: AttemptState[]; result_sha256?: string; findings?: number; label?: string; usage?: Record; detail?: string; /** verified: the INPUT receipt matched the snapshot; attested: a --record bound without one, on the parent's word (attest_reason). */ @@ -27,11 +27,29 @@ export interface Amendment { /** The reviewed Implementation-plan hash of the active plan when the owner accepted it: `--settle` evidence. */ plan_sha256?: string; } +/** A revise that changed the plan under a phase at its round cap: the phase keeps its review, the delta becomes a gate item. */ +export interface CappedDelta { + k: number; phase: string; round: number; cap: number; old_plan_sha256: string; new_plan_sha256: string; added: number; removed: number; + status: 'pending' | 'accepted' | 'reopened'; at: string; decided_at?: string; +} +/** Why a run stopped `incomplete` with work left: the structured budget_exhausted stop `resume` lifts after a budget raise. */ +export interface RunStop { + reason: 'budget_exhausted'; phase?: string; cap_usd: number | null; max_rounds: number | null; rounds: Record; + spent: number; reserved: number; unknown: number; unknown_count: number; next_estimate_usd: number; at: string; +} +/** A phase closed accepted-with-findings (`closeDeferred`): its open findings carry into tasks.jsonl and the gate. */ +export interface DeferredClose { + reason: string; by: string; cause: 'cap' | 'owner'; at: string; round: number; counts: Record; + carried_file?: string; blockers: string[]; open: number; +} export interface PhaseState { snapshot?: string; snapshot_dir?: string; snapshot_path?: string; methodology?: string; opened_at?: string; closed_at?: string; native?: VoiceState; outside?: VoiceState; consensus?: Record; skipped?: string; prompts?: { native: string; outside: string; nativeResult: string; outsideResult: string }; sections?: { loaded: string[]; skipped: string[]; checklist: boolean }; + deferred?: DeferredClose; + /** A confirm round (r > 1): the revision this phase last reviewed. */ + confirm?: { round: number; previous_sha256?: string; added: number; removed: number }; } export interface RunState extends RunManifest { revision: number; out: string; plan_source: string; active_plan: string; restore: string; scope: Scope; phase_order: Phase[]; @@ -43,6 +61,9 @@ export interface RunState extends RunManifest { plan_input?: string; plan_heading?: string; scope_flags?: Record; scaffolded?: boolean; plan_revision?: number; amendments?: Amendment[]; plan_reality?: { plan_sha256: string; checker: string; verdict: string; line: string }; + /** Review rounds per phase (null: uncapped); round_grants are the owner's per-phase raises at the gate. */ + max_rounds?: number | null; round_grants?: Record; + stop?: RunStop; stops?: RunStop[]; capped_deltas?: CappedDelta[]; } export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const; @@ -52,7 +73,8 @@ export function voiceTerminal(v: { status?: string } | undefined): boolean { } export const pendingAmendments = (state: RunState): Amendment[] => (state.amendments ?? []).filter(a => a.status === 'pending'); export class RunError extends Error { - constructor(public code: ResultCodeName, public detail: string, public exit: 1 | 3 = 1) { super(`${code}: ${detail}`); } + /** `status`: the run status the refusal leaves (default refused); `lines`: what the command still reports on stdout. */ + constructor(public code: ResultCodeName, public detail: string, public exit: 1 | 3 = 1, public after: { status?: string; lines?: string[] } = {}) { super(`${code}: ${detail}`); } } export const FILES = { run: 'run.json', journal: 'attempts.jsonl', lock: 'run.lock', spend: 'spend.json', active: 'plan-active.md', restore: 'restore.md', source: 'plan-source.md' } as const; diff --git a/lib/coordinator-contract.ts b/lib/coordinator-contract.ts index 86243b453..a507928b0 100644 --- a/lib/coordinator-contract.ts +++ b/lib/coordinator-contract.ts @@ -17,8 +17,8 @@ Merge conditions. A lane's PR merges only when: the required gate lanes are gree Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under \`## Behavior changes\` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. -Spend caps. Each lane runs under the \`--spend-cap\` the coordinator names; a lane that reaches it stops with \`status=budget_exhausted\` and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as \`unknown\`, never zero. +Spend caps. Each lane runs under the \`--spend-cap\` the coordinator names; a lane that reaches it stops \`incomplete\` (\`STOP: reason=budget_exhausted\`) and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as \`unknown\`, never zero. -Before done. A lane runs \`gstack-pregate run --allow-repo-commands\` on its branch (the unit tier on the changed files plus the repo's guards) and reports done only after it passes. In a repo whose \`.gstack/pregate.json\` has a \`sealed\` key, it runs \`gstack-pregate sealed\` before any push or PR creation; a refusal stops the push, because a PR tool may not run local hooks. +Before done. A lane pushes a WIP commit to its branch after each step and gates a pushed SHA. It runs \`gstack-pregate run --allow-repo-commands\` on its branch and reports done only after it passes. Where \`.gstack/pregate.json\` has a \`sealed\` key, it runs \`gstack-pregate sealed\` before any push or PR creation; a refusal stops the push. Handoff format. A lane reports in this order, identifiers first: PR URL (or \`no PR\` with branch and SHA), \`GSTACK_RESULT\` line, the \`gstack: ok|fail \` line, the \`PREGATE:\` line, gate summary (lanes passed/failed/skipped), behavior changes (or \`none\`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (\`jam_…\`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be.`; diff --git a/lib/gate-list.ts b/lib/gate-list.ts index 680fbde5c..86182d60d 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -37,6 +37,8 @@ export interface GateItem { flags?: string[]; /** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */ carried?: { from: string; chosen: string; gate_rev: number }; + /** A round-cap item (lib/autoplan-budget.ts): `b` grants the phase one more round and reopens it. */ + cap?: { phase: string; kind: 'deferred' | 'blocker' | 'delta'; ref?: string }; } export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string; /** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */ diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index f39a19543..ae6938d9b 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -351,15 +351,17 @@ function checkPhases(m: RunManifest, out: ValidationError[]): void { if (!TERMINAL_RUN.includes(m.status)) { out.push({ code: 'ARTIFACT_RUN_INTERRUPTED', path: 'run.json.status', message: `status ${m.status} is not terminal` }); } + // A run stopped by its budget (status incomplete with a stop object) records the phases it never reached as such, not as missing. + const stopped = m.status === 'incomplete' && !!(m as { stop?: unknown }).stop; for (const phase of m.required_phases ?? []) { const p = m.phases?.[phase]; - if (!p) { out.push({ code: 'ARTIFACT_REVIEWER_MISSING', path: `run.json.phases.${phase}`, message: 'required phase absent' }); continue; } + if (!p) { if (!stopped) out.push({ code: 'ARTIFACT_REVIEWER_MISSING', path: `run.json.phases.${phase}`, message: 'required phase absent' }); continue; } for (const voice of ['native', 'outside'] as const) { const v = p[voice]; if (!v || typeof v !== 'object') { out.push({ code: 'ARTIFACT_REVIEWER_MISSING', path: `run.json.phases.${phase}.${voice}`, message: 'voice has no outcome' }); continue; } checkSchema(v, voiceOutcome, `.phases.${phase}.${voice}`, out, 'run.json'); if (['pending', 'running'].includes(String(v.status))) { - out.push({ code: 'ARTIFACT_RUN_INTERRUPTED', path: `run.json.phases.${phase}.${voice}.status`, message: `voice is still ${v.status}` }); + if (!stopped) out.push({ code: 'ARTIFACT_RUN_INTERRUPTED', path: `run.json.phases.${phase}.${voice}.status`, message: `voice is still ${v.status}` }); } else if (!TERMINAL_VOICE.includes(String(v.status))) { continue; } @@ -477,6 +479,9 @@ function checkGateSettled(m: RunManifest, decisions: Record[] | for (const a of Array.isArray(m.amendments) ? m.amendments as Array> : []) { if (a.status === 'pending') out.push({ code: 'ARTIFACT_GATE_UNSETTLED', path: 'run.json.amendments', message: `plan amendment ${String(a.decision)} is still pending` }); } + for (const d of Array.isArray(m.capped_deltas) ? m.capped_deltas as Array> : []) { + if (d.status === 'pending') out.push({ code: 'ARTIFACT_GATE_UNSETTLED', path: 'run.json.capped_deltas', message: `the plan change after the ${String(d.phase)} round cap (revise v${String(d.k)}) was never put to the owner` }); + } } /** Validate a standalone artifact file (pregate.json, a ship receipt, timing.json, or a JSONL of rows) against its schema. */ diff --git a/lib/result-codes.ts b/lib/result-codes.ts index c3bdeaace..556ea280e 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -656,6 +656,17 @@ export const RESULT_CODES = { summary: 'a plan review ran without its second opinion: the outside provider did not run or failed, and the bounded native Plan reviewer was not offered or did not finish within five minutes', fix: 'repair the provider the line names (install it, or log in with `codex login`); for the native fallback use Claude Code 2.1.277 or later with background tasks enabled (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` unset), then re-run the review', }, + // Field list 2, W6: the public copy of an autoplan run. + ARTIFACT_MACHINE_PATH: { + anchor: 'artifact-machine-path', + summary: 'a public copy of an autoplan run still contains a machine path the run recorded (its run directory, home, plan directory, checkpoint directory or worktree root)', + fix: 'export with `gstack-autoplan export --out --public `, which rewrites recorded roots in prose; a root inside a code span or fence in the plan is never rewritten, so edit the plan, revise, and export again', + }, + ARTIFACT_PUBLIC_TARGET_EXISTS: { + anchor: 'artifact-public-target-exists', + summary: '`gstack-autoplan export --public ` writes only into an empty directory or over an earlier public copy of the same run', + fix: 'pass an empty or new --public directory, or the directory that already holds this run\'s public copy', + }, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/lib/spend-ledger.ts b/lib/spend-ledger.ts index 0b28ef8b0..53e60ce56 100644 --- a/lib/spend-ledger.ts +++ b/lib/spend-ledger.ts @@ -159,6 +159,16 @@ export function release(file: string, attempt: string, opts: { now?: Date } = {} }); } +/** Change the cap (an owner's budget raise); every later admission checks the new cap. */ +export function setCap(file: string, cap: number | null): SpendLedger { + return withFileLock(file, () => { + const ledger = readLedger(file, cap); + ledger.cap_usd = cap; + writeLedger(file, ledger); + return ledger; + }); +} + export function renderSpend(summary: SpendSummary): string { const cap = summary.cap_usd === null ? 'none' : summary.cap_usd.toFixed(2); return `SPEND: spent=${summary.spent_usd.toFixed(2)} reserved=${summary.reserved_usd.toFixed(2)} unknown=${summary.unknown_count}(${summary.unknown_usd.toFixed(2)}) worst_case=${summary.worst_case_usd.toFixed(2)} cap=${cap}`; diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 867a80ca9..140c213d1 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -12,11 +12,12 @@ import { FILES } from '../../lib/autoplan-state'; import { PHASES, RESULT_FORMAT } from '../../lib/autoplan-prompts'; import { SCOPE_FLAG_TOKENS } from '../../lib/autoplan-scope'; import { RESULT_CODES } from '../../lib/result-codes'; +import { DEFAULT_HOST_ESTIMATE_USD, DEFAULT_MAX_ROUNDS } from '../../lib/autoplan-budget'; import type { ResolverFn } from './types'; /** One entry per command line the loop runs, as the field-wave plan specifies them. */ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ - { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--scaffold]', does: 'first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice' }, + { cmd: 'gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--scaffold]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended), and an unattended Capy run reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per host attempt unless --estimate-usd says otherwise, so --spend-cap binds` }, { cmd: 'gstack-autoplan next --out [--redispatch ]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' }, { cmd: 'gstack-autoplan submit --out --phase

--voice native|outside --record --model [--attempt ]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' }, { cmd: 'gstack-autoplan submit ... --record --attest "" --attempt ', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' }, @@ -27,7 +28,10 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan revise --out

--plan --incorporates ', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' }, { cmd: 'gstack-autoplan revise --out --settle ', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' }, { cmd: 'gstack-autoplan revise --out --withdraw ', does: 'drop a pending amendment instead of applying it' }, + { cmd: 'gstack-autoplan close --out --phase

--deferred "" --by human|agent:', does: '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`' }, + { cmd: 'gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by human|agent:', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' }, { cmd: 'gstack-autoplan export --out ', does: 'write the artifact set; then `gstack-artifact validate /run.json`' }, + { cmd: 'gstack-autoplan export --out --public ', does: 'a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine' }, { cmd: 'gstack-autoplan status|resume --out ', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, { cmd: 'gstack-autoplan resume --out --from ', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from ` lists them' }, ]; diff --git a/ship/sections/handoff.md b/ship/sections/handoff.md index efcc24bc6..22f915991 100644 --- a/ship/sections/handoff.md +++ b/ship/sections/handoff.md @@ -5,9 +5,9 @@ Replaces the pr-body section when the run must not open a PR (`--no-pr`, an automation lane, an unattended parent said so). Nothing here publishes. -1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming the - lanes that need a remote (`requires-remote`), to `.gstack/tmp/ship-gate.json` - as the pr-body section does, then (with its Issue links flags; ask nothing): +1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming + `requires-remote` lanes, to `.gstack/tmp/ship-gate.json` as the pr-body + section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json @@ -19,6 +19,9 @@ automation lane, an unattended parent said so). Nothing here publishes. SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) in any cross-thread reference, never a short code. - Prose may follow the identifiers; nothing may precede them. + Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. + +### Multi-lane runs + +Lanes push a WIP commit after each step and gate a pushed SHA. diff --git a/ship/sections/handoff.md.tmpl b/ship/sections/handoff.md.tmpl index 66758528b..efdc0937c 100644 --- a/ship/sections/handoff.md.tmpl +++ b/ship/sections/handoff.md.tmpl @@ -3,9 +3,9 @@ Replaces the pr-body section when the run must not open a PR (`--no-pr`, an automation lane, an unattended parent said so). Nothing here publishes. -1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming the - lanes that need a remote (`requires-remote`), to `.gstack/tmp/ship-gate.json` - as the pr-body section does, then (with its Issue links flags; ask nothing): +1. Verify-only checks: write Step 16's per-lane pass/fail/skip counts, naming + `requires-remote` lanes, to `.gstack/tmp/ship-gate.json` as the pr-body + section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json @@ -17,6 +17,9 @@ automation lane, an unattended parent said so). Nothing here publishes. SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) in any cross-thread reference, never a short code. - Prose may follow the identifiers; nothing may precede them. + Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. + +### Multi-lane runs + +Lanes push a WIP commit after each step and gate a pushed SHA. diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index 8dde3becd..ab771c099 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -443,9 +443,9 @@ Merge conditions. A lane's PR merges only when: the required gate lanes are gree Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under `## Behavior changes` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. -Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops with `status=budget_exhausted` and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero. +Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane that reaches it stops `incomplete` (`STOP: reason=budget_exhausted`) and reports spent, reserved and unknown charges. Paid evals are never retried to change a verdict; a rerun needs a named repair. Host-subagent spend is reported as `unknown`, never zero. -Before done. A lane runs `gstack-pregate run --allow-repo-commands` on its branch (the unit tier on the changed files plus the repo's guards) and reports done only after it passes. In a repo whose `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push, because a PR tool may not run local hooks. +Before done. A lane pushes a WIP commit to its branch after each step and gates a pushed SHA. It runs `gstack-pregate run --allow-repo-commands` on its branch and reports done only after it passes. Where `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push. Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, the `gstack: ok|fail ` line, the `PREGATE:` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be. diff --git a/test/autoplan-budget.test.ts b/test/autoplan-budget.test.ts new file mode 100644 index 000000000..713418f87 --- /dev/null +++ b/test/autoplan-budget.test.ts @@ -0,0 +1,219 @@ +/** + * Field list 2, W5: the autoplan run budget on the simulated Capy host. An + * unattended Capy run reserves the default per-voice estimate so --spend-cap + * binds, and a refused dispatch stops the run `incomplete` with a structured + * stop object that export, validate and resume understand; `budget` journals + * the owner's raise. `close --deferred` keeps a dispatched voice's + * reservation, routes an open Critical to the gate and carries open findings + * into tasks.jsonl; the round cap defers a rerun phase and turns a later + * revise into an owner gate item; confirm rounds carry the plan delta and + * revision-qualified, settled finding ids. Every spawn has a timeout. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; + +const r = capyRunner(); +afterAll(() => r.cleanup()); +let n = 0; +const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts'); +const NONE = { native: [], outside: [] }; +function start(args: string[] = []): { out: string; dir: string; plan: string } { + const dir = path.join(r.host.base, `budget${++n}`); + const plan = writePlan(dir); + const out = path.join(dir, 'run'); + const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', ...args]); + if (first.status !== 0) throw new Error(`first next failed: ${first.stderr}\n${first.stdout}`); + return { out, dir, plan }; +} +const spend = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'spend.json'), 'utf8')); +const unknownUsd = (out: string) => spend(out).settled.filter((s: any) => s.usd === 'unknown').reduce((t: number, s: any) => t + s.reserved_usd, 0); +const answer = (out: string, rev: number, reply: string) => r.ap(['answer', '--by', 'human', '--out', out, '--gate-rev', String(rev), '--reply', reply]); +const gate = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')); +const v2File = (dir: string, text = V2) => { const f = path.join(dir, `plan-v2-${++n}.md`); fs.writeFileSync(f, text); return f; }; + +describe('spend: the default host estimate binds and budget_exhausted is a resumable incomplete stop', () => { + test('Capy unattended reserves the default per voice; the cap stops the run incomplete; budget raise then resume continues', () => { + const dir = path.join(r.host.base, 'spend'); + const plan = writePlan(dir); + const out = path.join(dir, 'run'); + const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--spend-cap', '12']); + expect(first.status, first.stderr).toBe(0); + expect(first.stdout).toMatch(/^ESTIMATE: 5\.00 per voice reserved for each host-subagent attempt/m); + expect(first.stdout).toMatch(/^ROUNDS: ceo=1\/3 eng=1\/3 max_rounds=3 host_spend=unknown$/m); + expect(spend(out).reserved.map((x: any) => x.estimate_usd)).toEqual([5, 5]); + expect(readRunJson(out).max_rounds).toBe(3); + phaseVia(r, out, 'ceo', NONE); + expect(unknownUsd(out)).toBe(10); + const stopped = r.ap(['next', '--out', out]); + expect(stopped.status).toBe(3); + expect(stopped.stderr).toMatch(/\(SPEND_CAP_EXCEEDED\)$/m); + expect(stopped.stdout).toMatch(/^STOP: reason=budget_exhausted phase=eng cap=12\.00 spent=0\.00 reserved=0\.00 unknown=2\(10\.00\) next_estimate=10\.00 rounds=ceo:1\/3,eng:1\/3$/m); + expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan close --out ${out} --phase eng --deferred`); + expect(stopped.stdout).toContain(`RECOVER: gstack-autoplan budget --out ${out} --spend-cap `); + expect(stopped.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=incomplete run=${out}`); + const run = readRunJson(out); + expect(run.status).toBe('incomplete'); + expect(run.stop).toMatchObject({ reason: 'budget_exhausted', cap_usd: 12, rounds: { ceo: 1, eng: 1 }, spent: 0, reserved: 0, unknown: 10 }); + expect(readJsonl(path.join(out, 'attempts.jsonl')).filter(e => e.event === 'dispatched' && e.phase === 'eng')).toEqual([]); + expect(r.ap(['export', '--out', out]).status).toBe(0); + const v = r.validate(out); + expect(v.status, v.stdout + v.stderr).toBe(0); + expect(v.stdout).toContain('status=incomplete'); + const early = r.ap(['resume', '--out', out]); + expect(early.stdout).toMatch(/^STOPPED: budget_exhausted at /m); + expect(readRunJson(out).status).toBe('incomplete'); + const noBy = r.ap(['budget', '--out', out, '--spend-cap', '40']); + expect(noBy.status).toBe(3); + expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m); + const raise = r.ap(['budget', '--out', out, '--spend-cap', '40', '--by', 'human']); + expect(raise.status, raise.stderr).toBe(0); + expect(raise.stdout).toMatch(/^BUDGET: spend_cap=12->40 by=human$/m); + expect(raise.stdout).toContain(`NEXT: gstack-autoplan resume --out ${out}`); + expect(spend(out).cap_usd).toBe(40); + const resumed = r.ap(['resume', '--out', out]); + expect(resumed.stdout).toMatch(/^STOP_LIFTED: budget raised after budget_exhausted$/m); + expect(readRunJson(out).stops).toHaveLength(1); + const eng = phaseVia(r, out, 'eng', NONE); + expect(eng.outside.stdout).toContain('status=gate_pending'); + expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/Stopped budget_exhausted at .* \(lifted\)/); + }); +}); + +describe('close --deferred', () => { + test('a dispatched voice keeps its reservation, an open Critical becomes an owner item, open findings become tasks', () => { + const s = start(['--spend-cap', '100']); + r.review(path.join(s.out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Critical', title: 'Retries can double-charge', fix: 'idempotency key' }, { severity: 'Medium', title: 'Jitter source unstated' }]) }); + const run0 = readRunJson(s.out); + const bound = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', run0.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run0.phases.ceo.native.attempt]); + expect(bound.status, bound.stderr).toBe(0); + const outsideAttempt = run0.phases.ceo.outside.attempt; + const before = unknownUsd(s.out) + spend(s.out).reserved.reduce((t: number, x: any) => t + x.estimate_usd, 0); + const noBy = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop']); + expect(noBy.status).toBe(3); + expect(noBy.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)$/m); + const close = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'owner stop', '--by', 'human']); + expect(close.status, close.stderr).toBe(0); + expect(close.stdout).toMatch(/^PHASE_DEFERRED: ceo round=1\/3 by=human open=2 \(Critical=1 Medium=1\) blockers=\S+-ceo-native-1 reason="owner stop"$/m); + expect(close.stdout).toContain('GSTACK_RESULT: skill=autoplan status=running'); + const run = readRunJson(s.out); + expect(run.phases.ceo.outside.status).toBe('skipped'); + expect(run.phases.ceo.outside.attempts[0].state).toBe('deferred'); + const settled = spend(s.out).settled.find((x: any) => x.attempt === outsideAttempt); + expect(settled).toMatchObject({ usd: 'unknown', reserved_usd: 5 }); + expect(spend(s.out).released).toEqual([]); + const after = unknownUsd(s.out) + spend(s.out).reserved.reduce((t: number, x: any) => t + x.estimate_usd, 0); + expect(after).toBe(before); + expect(r.ap(['resume', '--out', s.out]).status).toBe(0); + expect(unknownUsd(s.out)).toBe(after); + expect(readJsonl(path.join(s.out, 'attempts.jsonl')).some(e => e.event === 'attempt_deferred' && e.attempt === outsideAttempt)).toBe(true); + const again = r.ap(['close', '--out', s.out, '--phase', 'ceo', '--deferred', 'twice', '--by', 'human']); + expect(again.status).toBe(3); + expect(again.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); + expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending'); + const items = gate(s.out).items; + const blocker = items.find((i: any) => i.cap?.kind === 'blocker'); + expect(blocker).toMatchObject({ kind: 'approval', recommended: 'b', phase: 'ceo' }); + expect(blocker.title).toContain('Retries can double-charge'); + expect(items.find((i: any) => i.cap?.kind === 'deferred')).toMatchObject({ kind: 'auto', recommended: 'a' }); + const tasks = readJsonl(path.join(s.out, 'tasks.jsonl')); + expect(tasks.filter((t: any) => t.title.startsWith('deferred from ceo:')).map((t: any) => t.priority).sort()).toEqual(['Critical', 'Medium']); + expect(readJsonl(path.join(s.out, 'findings.jsonl')).filter((f: any) => f.phase === 'ceo').every((f: any) => f.disposition === 'deferred')).toBe(true); + const v = r.validate(s.out); + expect(v.status, v.stdout + v.stderr).toBe(0); + const reply = answer(s.out, 1, 'rec'); + expect(reply.status, reply.stderr).toBe(0); + expect(reply.stdout).toMatch(/^ROUND_GRANTED: ceo cap=4 \(uc\d+b by human\)$/m); + expect(reply.stdout).toMatch(/^REOPENED: ceo,eng/m); + expect(readRunJson(s.out).round_grants).toEqual({ ceo: 1 }); + }); +}); + +describe('the round cap', () => { + test('a revise after a capped phase reopens nothing and asks the owner about the unreviewed delta', () => { + const s = start(['--max-rounds', '1']); + phaseVia(r, s.out, 'ceo', NONE); + expect(phaseVia(r, s.out, 'eng', NONE).outside.stdout).toContain('status=gate_pending'); + const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]); + expect(rev.status, rev.stderr).toBe(0); + expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=none capped=ceo,eng$/m); + expect(rev.stdout).toMatch(/^CAPPED: ceo used round 1 of 1: not reopened; the gate asks the owner about the unreviewed delta \(\+1 -1 lines\)$/m); + expect(rev.stdout).toMatch(/^GATE: gate_rev=2 /m); + const run = readRunJson(s.out); + expect(run.status).toBe('gate_pending'); + expect(run.capped_deltas.map((d: any) => [d.phase, d.status])).toEqual([['ceo', 'pending'], ['eng', 'pending']]); + const deltas = gate(s.out).items.filter((i: any) => i.cap?.kind === 'delta'); + expect(deltas.map((i: any) => i.title.replace(/\(.*/, '').trim())).toEqual(['plan changed after ceo cap: unreviewed delta', 'plan changed after eng cap: unreviewed delta']); + expect(deltas.every((i: any) => i.kind === 'approval')).toBe(true); + const partial = answer(s.out, 2, 'p1a'); + expect(partial.stdout).toMatch(/^GATE_PENDING: /m); + const done = answer(s.out, 2, 'rec'); + expect(done.status, done.stderr).toBe(0); + expect(done.stdout).toMatch(/^APPROVED: gate_rev=2/m); + expect(readRunJson(s.out).capped_deltas.map((d: any) => d.status)).toEqual(['accepted', 'accepted']); + expect(r.validate(s.out).status).toBe(0); + }); + + test('a rerun past the cap closes the phase deferred, carrying the last round\'s findings, and export and validate pass', () => { + const s = start(['--max-rounds', '1']); + phaseVia(r, s.out, 'ceo', { native: [{ severity: 'High', title: 'Backoff cap is unbounded under clock skew' }], outside: [] }); + phaseVia(r, s.out, 'eng', NONE); + const rerun = answer(s.out, 1, 'p1d'); + expect(rerun.stdout).toMatch(/^REOPENED: ceo,eng/m); + const next = r.ap(['next', '--out', s.out]); + expect(next.status, next.stderr).toBe(0); + expect(next.stdout).toMatch(/^PHASE_DEFERRED: ceo round=2\/1 by=runner open=1 \(High=1\) blockers=none reason="round cap: round 2 would pass max 1"$/m); + expect(next.stdout).toMatch(/^PHASE_DEFERRED: eng round=2\/1 by=runner open=0 /m); + expect(next.stdout).toContain('status=gate_pending'); + const findings = readJsonl(path.join(s.out, 'findings.jsonl')); + expect(findings.map((f: any) => [f.title, f.disposition])).toEqual([['Backoff cap is unbounded under clock skew', 'deferred']]); + expect(readJsonl(path.join(s.out, 'tasks.jsonl')).map((t: any) => t.status)).toEqual(['pending']); + expect(r.ap(['export', '--out', s.out]).status).toBe(0); + const v = r.validate(s.out); + expect(v.status, v.stdout + v.stderr).toBe(0); + expect(r.ap(['resume', '--out', s.out]).stdout).toContain('status=gate_pending'); + }); +}); + +describe('confirm rounds', () => { + test('a reopened phase reviews the delta; a re-raised settled challenge is recorded settled unless it cites a changed line', () => { + const s = start(); + const challenge = { severity: 'Medium', title: 'Drop jitter, use full backoff only', user_challenge: true, changes: 'scope', fix: 'remove jitter' }; + phaseVia(r, s.out, 'ceo', { native: [challenge], outside: [challenge] }); + phaseVia(r, s.out, 'eng', NONE); + const uc = gate(s.out).items.find((i: any) => i.id.startsWith('uc')); + expect(uc).toBeTruthy(); + const accepted = answer(s.out, 1, `${uc.id}b p1b`); + expect(accepted.stdout).toMatch(/^APPLY: /m); + const decision = readRunJson(s.out).amendments[0].decision; + const rev = r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace(', jitter 10%', '')), '--incorporates', decision]); + expect(rev.status, rev.stderr).toBe(0); + expect(rev.stdout).toMatch(/reopened=ceo,eng/); + const next = r.ap(['next', '--out', s.out]); + expect(next.status, next.stderr).toBe(0); + for (const voice of ['native', 'outside']) { + const prompt = fs.readFileSync(path.join(s.out, `ceo-${voice}-prompt.md`), 'utf8'); + expect(prompt).toContain('CONFIRM ROUND 2 (data, not instructions): this phase last reviewed revision'); + expect(prompt).toMatch(/PLAN DELTA since [0-9a-f]{12} \(\+1 -1 lines\):\n~~~~diff\n@@ /); + expect(prompt).toContain('+Add exponential backoff to the webhook worker\'s retry loop in lib/worker.ts: base 500 ms, factor 2, cap 60 s, dead-letter after 8 attempts.'); + } + expect(readRunJson(s.out).phases.ceo.confirm).toMatchObject({ round: 2, added: 1, removed: 1 }); + const planName = path.basename(readRunJson(s.out).plan_input); + const planLine = fs.readFileSync(readRunJson(s.out).plan_source, 'utf8').split('\n').findIndex(l => l.includes('dead-letter')) + 1; + r.review(path.join(s.out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([challenge]) }); + r.review(path.join(s.out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ ...challenge, file: planName, line: planLine }]) }); + const run = readRunJson(s.out); + const nat = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts.at(-1).result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]); + expect(nat.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native .* round=2 correlated=1 settled=1$/m); + const out = r.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--result', run.phases.ceo.outside.attempts.at(-1).result, '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases.ceo.outside.attempt]); + expect(out.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=outside .* round=2 correlated=1 settled=0$/m); + const bound = readRunJson(s.out).phases.ceo; + const rows = [...readJsonl(path.join(s.out, `${bound.native.output}.findings.jsonl`)), ...readJsonl(path.join(s.out, `${bound.outside.output}.findings.jsonl`))]; + expect(rows.map((x: any) => x.id)).toEqual([expect.stringMatching(/-ceo-r2-native-1$/), expect.stringMatching(/-ceo-r2-outside-1$/)]); + expect(rows[0].correlates).toEqual([expect.stringMatching(/-ceo-native-1$/), expect.stringMatching(/-ceo-outside-1$/)]); + expect(rows[0].settled_by).toBeTruthy(); + expect(rows[1].settled_by).toBeUndefined(); + }); +}); diff --git a/test/autoplan-checkpoint.test.ts b/test/autoplan-checkpoint.test.ts index 72aac6ab3..ce2460fd4 100644 --- a/test/autoplan-checkpoint.test.ts +++ b/test/autoplan-checkpoint.test.ts @@ -25,16 +25,20 @@ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const current = (collection: string) => JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8')); const generations = (collection: string) => fs.readdirSync(path.join(collection, 'generations')).sort(); -/** A small run brought to gate_pending with the checkpoint env on for the last submit. */ +/** + * A small run brought to gate_pending with the checkpoint env on for the last submit only, so the collection holds one + * generation (field list 2, W7: a bound voice or a phase transition with the env on publishes one too; see + * test/autoplan-durability.test.ts). + */ function checkpointed(name: string): { dir: string; out: string; root: string; collection: string; run: string; e: Record } { const dir = path.join(TMP, name); const root = path.join(dir, 'drive'); const e = env({ GSTACK_AUTOPLAN_CHECKPOINT_DIR: root }); const out = startSmallRun(dir); runPhase(out, 'ceo'); - expect(autoplan(['next', '--out', out], { env: e }).stderr).not.toContain('CHECKPOINT:'); + expect(autoplan(['next', '--out', out]).status).toBe(0); for (const voice of ['native', 'outside'] as const) review(path.join(out, `eng-${voice}-prompt.md`)); - expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7'], { env: e }).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(out, 'eng', 'native'), '--model', 'anthropic/claude-opus-4-7']).status).toBe(0); const gated = autoplan(['submit', '--out', out, '--phase', 'eng', '--voice', 'outside', '--result', resultOf(out, 'eng', 'outside'), '--model', 'openai/gpt-6-astra'], { env: e }); expect(gated.stdout).toContain('status=gate_pending'); const run = readRun(out).run; diff --git a/test/autoplan-durability.test.ts b/test/autoplan-durability.test.ts new file mode 100644 index 000000000..905df1993 --- /dev/null +++ b/test/autoplan-durability.test.ts @@ -0,0 +1,126 @@ +/** + * Field list 2, W7: lane durability for unattended /autoplan on the simulated + * Capy host. With GSTACK_AUTOPLAN_CHECKPOINT_DIR unset the runner prints the + * export line naming the private project-user Drive folder and writes nothing + * there; `gstack-config set autoplan_checkpoint drive` opts in. A generation is + * published after each bound voice and phase transition, and a run restored + * from one never redispatches a voice it had bound. The coordinator contract + * and /ship's handoff carry the WIP-push rule. Every spawn has a timeout. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { capyDriveFolder } from '../lib/autoplan-checkpoint'; +import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract'; +import { ROOT } from './helpers/autoplan-run-fixture'; +import { capyRunner, readRunJson, writePlan } from './helpers/autoplan-capy'; + +const r = capyRunner(); +afterAll(() => r.cleanup()); +const drive = path.join(r.host.home, '.capy', 'drive', 'project-user-demo-garry'); +fs.mkdirSync(drive, { recursive: true }); +const folder = path.join(drive, 'autoplan-checkpoints'); +const config = (value: string) => spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-config'), 'set', 'autoplan_checkpoint', value], { env: r.env, encoding: 'utf8', timeout: 30_000 }); +let n = 0; +const start = () => { const dir = path.join(r.host.base, `dur${++n}`); const out = path.join(dir, 'run'); return { dir, out, first: r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']) }; }; + +describe('checkpoints on Capy', () => { + test('unset: the first next prints the export line for the project-user Drive folder and writes nothing there', () => { + const { first } = start(); + expect(first.status, first.stderr).toBe(0); + expect(first.stderr).toContain(`CHECKPOINT: unset; to survive machine replacement: export GSTACK_AUTOPLAN_CHECKPOINT_DIR=${folder} (or for every run: gstack-config set autoplan_checkpoint drive)`); + expect(fs.existsSync(folder)).toBe(false); + }); + + test('opted in: a generation after each bound voice and transition; a restored run keeps the bound voice and redispatches only the other', () => { + expect(config('drive').status).toBe(0); + try { + const { dir, out, first } = start(); + expect(first.status, first.stderr).toBe(0); + expect(first.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result /m); + const run = readRunJson(out); + const collection = path.join(folder, run.run); + expect(fs.existsSync(path.join(collection, 'current.json'))).toBe(true); + expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: '[]' }).status).toBe(0); + const bound = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', run.phases.ceo.native.attempts[0].result, '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases.ceo.native.attempt]); + expect(bound.status, bound.stderr).toBe(0); + expect(bound.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result gate_rev=0 revision=\d+ /m); + const restored = path.join(dir, 'restored'); + const resume = r.ap(['resume', '--out', restored, '--from', collection]); + expect(resume.status, resume.stderr).toBe(0); + expect(resume.stdout).toMatch(/^EXECUTION_UNKNOWN: \S+-a2$/m); + const back = readRunJson(restored); + expect(back.phases.ceo.native.status).toBe('completed'); + const refused = r.ap(['next', '--out', restored]); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/\S+-a2 \(ceo outside\).*\(EXECUTION_UNKNOWN\)$/m); + const again = r.ap(['next', '--out', restored, '--redispatch', back.phases.ceo.outside.attempt]); + expect(again.status, again.stderr).toBe(0); + expect(again.stdout.match(/^ATTEMPT: /gm)).toHaveLength(1); + expect(again.stdout).toMatch(/^ATTEMPT: \S+-a3 phase=ceo voice=outside /m); + expect(readRunJson(restored).phases.ceo.native.attempt).toBe(run.phases.ceo.native.attempt); + } finally { + config('off'); + } + }); + + test('opted in: the next that opens a phase mid-run publishes a generation', () => { + expect(config('drive').status).toBe(0); + try { + const { out, first } = start(); + expect(first.status, first.stderr).toBe(0); + const run = readRunJson(out); + for (const voice of ['native', 'outside'] as const) { + expect(r.review(path.join(out, `ceo-${voice}-prompt.md`), { FIXTURE_FINDINGS: '[]' }).status).toBe(0); + const bound = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', voice, '--result', run.phases.ceo[voice].attempts[0].result, '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol', '--attempt', run.phases.ceo[voice].attempt]); + expect(bound.status, bound.stderr).toBe(0); + } + const current = () => JSON.parse(fs.readFileSync(path.join(folder, run.run, 'current.json'), 'utf8')); + const before = current().generation; + const opened = r.ap(['next', '--out', out]); + expect(opened.status, opened.stderr).toBe(0); + expect(opened.stdout).toMatch(/^ATTEMPT: \S+ phase=eng voice=native /m); + expect(opened.stderr).toMatch(/^CHECKPOINT: \S+ status=awaiting_result /m); + expect(current()).toMatchObject({ status: 'awaiting_result' }); + expect(current().generation).not.toBe(before); + } finally { + config('off'); + } + }); + + test('GSTACK_AUTOPLAN_CHECKPOINT_DIR wins over the Drive opt-in', () => { + expect(config('drive').status).toBe(0); + try { + const elsewhere = path.join(r.host.base, 'explicit-checkpoints'); + const dir = path.join(r.host.base, `dur${++n}`); + const first = r.ap(['next', '--out', path.join(dir, 'run'), '--plan', writePlan(dir), '--no-ui'], { GSTACK_AUTOPLAN_CHECKPOINT_DIR: elsewhere }); + expect(first.status, first.stderr).toBe(0); + expect(first.stderr).toContain(`CHECKPOINT: ${elsewhere}`); + } finally { + config('off'); + } + }); +}); + +describe('capyDriveFolder', () => { + test('one project-user folder, or the one named for the repo among several; none off Capy or when ambiguous', () => { + const home = path.join(r.host.base, 'drive-home'); + const env = { GSTACK_CAPY: '1', HOME: home }; + const base = path.join(home, '.capy', 'drive'); + fs.mkdirSync(path.join(base, 'project-user-gstack-garry'), { recursive: true }); + expect(capyDriveFolder(env, 'gstack')).toBe(path.join(base, 'project-user-gstack-garry', 'autoplan-checkpoints')); + expect(capyDriveFolder({ HOME: home }, 'gstack')).toBeUndefined(); + fs.mkdirSync(path.join(base, 'project-user-gbrain-garry'), { recursive: true }); + expect(capyDriveFolder(env, 'gbrain')).toBe(path.join(base, 'project-user-gbrain-garry', 'autoplan-checkpoints')); + expect(capyDriveFolder(env)).toBeUndefined(); + }); +}); + +describe('the WIP-push rule', () => { + test('the coordinator contract and the /ship handoff carry it', () => { + expect(COORDINATOR_CONTRACT).toContain('A lane pushes a WIP commit to its branch after each step and gates a pushed SHA.'); + expect(fs.readFileSync(path.join(ROOT, 'autoplan', 'DRIVER.md'), 'utf8')).toContain('A lane pushes a WIP commit to its branch after each step and gates a pushed SHA.'); + expect(fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'handoff.md'), 'utf8')).toMatch(/### Multi-lane runs\n\nLanes push a WIP commit after each step and gate a pushed SHA\./); + }); +}); diff --git a/test/autoplan-public-export.test.ts b/test/autoplan-public-export.test.ts new file mode 100644 index 000000000..3ff7c62fe --- /dev/null +++ b/test/autoplan-public-export.test.ts @@ -0,0 +1,117 @@ +/** + * Field list 2, W6: `gstack-autoplan export --public ` writes a copy of a + * run with its recorded machine roots rewritten, never touching the private + * run, and `gstack-artifact validate --public [--private ]` + * fails on a recorded root (file:line) and on provenance drift, and WARNs on + * generic home paths. The regression shape is gbrain-evals #164: a committed + * plan that cited the run directory and the author's home in prose. Runs on + * the simulated Capy host; every spawn has a timeout. + */ +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 path from 'node:path'; +import { LOCAL_NOTES, rewriteRoots } from '../lib/autoplan-public'; +import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { capyRunner, phaseVia, readRunJson, writePlan } from './helpers/autoplan-capy'; + +const r = capyRunner(); +afterAll(() => r.cleanup()); +const ARTIFACT = path.join(ROOT, 'bin', 'gstack-artifact'); +const artifact = (args: string[]) => spawnSync('bun', [ARTIFACT, ...args], { cwd: ROOT, env: r.env, encoding: 'utf8', timeout: 60_000 }); +let n = 0; + +/** A gated run whose plan cites its own run directory and the author's home in prose (gbrain-evals #164). */ +function gatedRun(extra = ''): { out: string; dir: string } { + const dir = path.join(r.host.base, `pub${++n}`); + const out = path.join(dir, 'run'); + const text = SMALL_PLAN.replace('## Review record', `Run notes live in ${out}/plan-active.md and ${r.host.home}/notes/retry.md.${extra}\n\n## Review record`); + const plan = writePlan(dir, text); + const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui']); + if (first.status !== 0) throw new Error(first.stderr); + phaseVia(r, out, 'ceo', { native: [{ severity: 'High', title: 'Notes path leaks', file: path.join(out, 'plan-active.md'), line: 3 }], outside: [] }); + phaseVia(r, out, 'eng', { native: [], outside: [] }); + expect(r.ap(['export', '--out', out]).status).toBe(0); + return { out, dir }; +} +const digest = (dir: string) => Object.fromEntries(fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(e => e.isFile() && e.name !== 'run.lock') + .map(e => { const f = path.join(e.parentPath, e.name); return [path.relative(dir, f), crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex')]; })); +const allText = (dir: string) => fs.readdirSync(dir).map(f => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n'); + +describe('export --public', () => { + test('rewrites recorded roots, leaves the private run byte-identical and resumable, and validates', () => { + const { out, dir } = gatedRun(); + const before = digest(out); + const pub = path.join(dir, 'public'); + const e = r.ap(['export', '--out', out, '--public', pub]); + expect(e.status, e.stderr).toBe(0); + expect(e.stdout).toContain(`EXPORTED_PUBLIC: ${path.join(pub, 'plan.md')}`); + expect(digest(out)).toEqual(before); + const text = allText(pub); + expect(text).not.toContain(out); + expect(text).not.toContain(r.host.home); + expect(text).not.toContain(dir); + expect(fs.readFileSync(path.join(pub, 'plan.md'), 'utf8')).toContain('Run notes live in /plan-active.md and /notes/retry.md.'); + const manifest = JSON.parse(fs.readFileSync(path.join(pub, 'run.json'), 'utf8')); + expect(manifest.provenance.private_run).toBe(readRunJson(out).run); + expect(manifest.provenance.files['plan.md'].private_sha256).toBe(readRunJson(out).artifacts['plan.md'].sha256); + expect(manifest.artifacts['plan.md'].sha256).toBe(manifest.provenance.files['plan.md'].public_sha256); + const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]); + expect(full.status, full.stderr).toBe(0); + expect(full.stdout).toMatch(/^ARTIFACT_VALID: public run=\S+ warnings=0$/m); + const standalone = artifact(['validate', '--public', pub]); + expect(standalone.status, standalone.stderr).toBe(0); + expect(standalone.stdout).toContain('recorded roots unchecked: pass --private on the exporting machine'); + const resumed = r.ap(['resume', '--out', out]); + expect(resumed.stdout).toContain('status=gate_pending'); + expect(r.validate(out).status).toBe(0); + }); + + test('validate --public fails on a recorded root with file:line, and on provenance drift without --private', () => { + const { out, dir } = gatedRun(); + const pub = path.join(dir, 'public'); + expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0); + const record = path.join(pub, 'review-record.md'); + const lines = fs.readFileSync(record, 'utf8').split('\n').length; + fs.appendFileSync(record, `see ${out}/attempts.jsonl\n`); + const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]); + expect(full.status).toBe(1); + expect(full.stderr).toContain(`at review-record.md:${lines}`); + expect(full.stderr).toMatch(/the recorded run root \S+ appears in the public copy at review-record\.md:\d+\).*\(ARTIFACT_MACHINE_PATH\)$/m); + const standalone = artifact(['validate', '--public', pub]); + expect(standalone.status).toBe(1); + expect(standalone.stderr).toMatch(/\(ARTIFACT_STALE\)$/m); + }); + + test('a recorded root inside plan.md code is never rewritten: export refuses and writes nothing; generic home paths WARN', () => { + const { out, dir } = gatedRun(`\n\n\`\`\`bash\ncat ${r.host.home}/notes/retry.md\n\`\`\`\n\nThe old box kept them in /home/someone/notes.`); + const pub = path.join(dir, 'public'); + const e = r.ap(['export', '--out', out, '--public', pub]); + expect(e.status).toBe(3); + expect(e.stderr).toMatch(/plan\.md:\d+: the recorded home root .* \(inside code, which export never rewrites: edit the plan\).*\(ARTIFACT_MACHINE_PATH\)$/m); + expect(fs.existsSync(pub)).toBe(false); + expect(fs.readdirSync(dir).filter(f => f.includes('.public-'))).toEqual([]); + const ok = gatedRun('\n\nThe old box kept them in /home/someone/notes.'); + const pub2 = path.join(ok.dir, 'public'); + const warned = r.ap(['export', '--out', ok.out, '--public', pub2]); + expect(warned.status, warned.stderr).toBe(0); + expect(warned.stderr).toMatch(/^WARN: plan\.md:\d+ has a home-directory path \(\/home\/someone\/\)/m); + const taken = path.join(ok.dir, 'taken'); + fs.mkdirSync(taken); + fs.writeFileSync(path.join(taken, 'keep.txt'), 'x'); + const refused = r.ap(['export', '--out', ok.out, '--public', taken]); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/\(ARTIFACT_PUBLIC_TARGET_EXISTS\)$/m); + expect(r.ap(['export', '--out', ok.out, '--public', pub2]).status).toBe(0); + }); +}); + +describe('rewriteRoots', () => { + test('a worktree path becomes repo-relative; a local root nested in the worktree still reads as local notes', () => { + const roots = [{ label: 'run', root: '/w/repo/.context/run' }, { label: 'home', root: '/home/u' }, { label: 'worktree', root: '/w/repo' }]; + expect(rewriteRoots('see /w/repo/lib/a.ts and /w/repo', roots)).toBe('see lib/a.ts and .'); + expect(rewriteRoots('notes at /home/u/notes.md', roots)).toBe(`notes at ${LOCAL_NOTES}/notes.md`); + expect(rewriteRoots('/w/repo/.context/run/run.json', roots)).toBe('.context/run/run.json'); + }); +}); diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index a42fafb6b..060ada325 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -115,7 +115,10 @@ { "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" }, { "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" }, { "file": "scripts/resolvers/autoplan-driver.ts", "movedFrom": "(new: renders autoplan/DRIVER.md from the runner's constants, field wave B2e)" }, - { "file": "lib/script-invocation.ts", "movedFrom": "(new: the one Windows bash resolver, shebang-bin invocation and wtreeFingerprint, from spawn-bin.ts and gbrain-exec.ts, #3197)" } + { "file": "lib/script-invocation.ts", "movedFrom": "(new: the one Windows bash resolver, shebang-bin invocation and wtreeFingerprint, from spawn-bin.ts and gbrain-exec.ts, #3197)" }, + { "file": "lib/autoplan-budget.ts", "movedFrom": "(new: round cap, default host estimate, budget_exhausted stop and the budget verb, field list 2 W5)" }, + { "file": "lib/autoplan-confirm.ts", "movedFrom": "(new: confirm-round plan delta, revision-qualified correlation and settled findings, field list 2 W5)" }, + { "file": "lib/autoplan-public.ts", "movedFrom": "(new: machine-path-free public run copy and validate --public, field list 2 W6)" } ], "residualFiles": [ { "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },