diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 3f29a58c0..c31483744 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -28,12 +28,13 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=`); render it with - `gstack-gate render /gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render /gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. @@ -51,16 +52,20 @@ line yourself; gstack never writes the plan. bind a canonical result (the RESULT FORMAT below). - `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"` close the phase native-only when no outside voice can run. -- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"` - answer the gate with the reply grammar below. +- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]` + answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim. +- `gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"` + change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`). - `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>` bind the amended plan; reopens every phase whose reviewed plan changed, Eng last. +- `gstack-autoplan revise --out <dir> --settle <decision id|d4>` + discharge a packaging or ordering change with no plan edit; reopens nothing. - `gstack-autoplan revise --out <dir> --withdraw <decision id>` drop a pending amendment instead of applying it. - `gstack-autoplan export --out <dir>` write the artifact set; then `gstack-artifact validate <dir>/run.json`. - `gstack-autoplan status|resume --out <dir>` - read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). + read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). ## Reviewer result format @@ -73,22 +78,23 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c ```gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} ``` - severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. + severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. 3. Write the whole result to this file: <result file> ## Gate replies A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unparsed, never guessed. -- `all`: every item takes its recommendation (an agreed change prints APPLY: and needs revise). -- `all as-is`: keep every direction; no plan change. +- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias. +- `as-is`: keep every direction; no plan change. - `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default. -- `all except d3b`: recommendations, with the listed overrides. +- `rec except d3b`: recommendations, with the listed overrides. ## Run directory Runner state: `run.json`, `attempts.jsonl`, `run.lock`, `spend.json`, `plan-active.md`, `restore.md`, `plan-source.md`. Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. @@ -108,7 +114,11 @@ is the completion check. Copy the whole directory, never single files. - `CONSENSUS_MISSING`: resume the run so the phase closes through reconciliation; the Eng prompt is never sent without every prior phase summary - `GATE_REV_STALE`: render the current gate list, read it, and reply with its gate_rev - `GATE_REPLY_UNPARSED`: reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous -- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `GATE_REC_STALE`: render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash` +- `OWNER_REPLY_REQUIRED`: pass the owner's words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>` +- `GATE_OWNER_REPLY_MISMATCH`: relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree +- `PLAN_AMEND_PENDING`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>` +- `AMENDMENT_NOT_SETTLEABLE`: edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan - `GSTACK_VERSION_CHANGED`: nothing to do for an older run (journaled forward migrations ran); a run written by a newer gstack needs that gstack: upgrade with bin/gstack-upgrade ## Coordinator contract (inherited by every lane) diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 46a0ef6ce..258f8226b 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -25,12 +25,13 @@ line yourself; gstack never writes the plan. a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with - `gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it. + `gstack-gate render <dir>/gate.json --all` and relay every page. You never approve the + gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. 4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or - `--by agent:<id>` (you relayed or decided them); without it an unattended run refuses - (`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's - own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are - accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until + `--by agent:<id> --owner-reply "<their words>"` (you relayed them); without `--by` an + unattended run refuses (`ANSWER_BY_REQUIRED`). `APPLY: <id> <title> -> <change>` lines are + accepted plan changes (status `amend_pending`): edit the plan, then `revise` (the printed + `NEXT:` names it), or `revise --settle <id>` when the line says no plan edit is needed. Until then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete. 5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`. @@ -54,7 +55,8 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa ## Run directory {{AUTOPLAN_DRIVER_FILES}} Reviewer files: `<phase>-native-prompt.md`, -`<phase>-outside-prompt.md`, `<phase>-native.md`, `<phase>-outside.md`, `<phase>-consensus.{json,md}`, +`<phase>-outside-prompt.md`, `<phase>-<voice>.<attempt>.md` (the reviewer writes it), its sealed +`<phase>-<voice>.<attempt>.bound.md` (what submit bound), `<phase>-consensus.{json,md}`, `gate.json`, `decisions.jsonl`. `export` writes `plan.md`, `review-record.md`, `findings.jsonl`, `tasks.jsonl`, `timing.json` and rehashes `run.json`; `gstack-artifact validate <dir>/run.json` is the completion check. Copy the whole directory, never single files. diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index eb91f849a..154fc5a9e 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -7,10 +7,11 @@ * gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>] * --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json] * gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json] - * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--json] review a revised plan in this run + * gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run * gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] * [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices - * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" [--json] + * gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json] + * gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] * gstack-autoplan resume --out <dir> [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes @@ -20,12 +21,12 @@ * 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=<s> run=<dir>; the run is never approved here. - * Mutating verbs (next, submit, answer, revise, export, run) pass admission first (lib/autoplan-admission.ts) + * Mutating verbs (next, submit, answer, recommend, revise, 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 { answerGate } from '../lib/autoplan-gate'; +import { answerGate, applyGuidance, recommendGate } from '../lib/autoplan-gate'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; import { exportRun } from '../lib/autoplan-export'; import { migrateRun } from '../lib/autoplan-migrate'; @@ -55,12 +56,17 @@ DRIVER: ${driverFile()} without its INPUT line it binds only with --attest <reason> and --attempt (receipt: attested) submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> the outside voice could not run: closes the phase native_only once native is bound - revise --out <dir> --plan <file> [--incorporates <decision id,...>] [--withdraw <decision id>] - review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (plus Eng); - --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one + revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] | --settle <id,...> + review a revised plan in this run: every phase whose reviewed Implementation-plan hash differs reopens (Eng last); + --incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one; + --settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4) run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ... next in a loop, only when a runner executes both voices here; --deadline cancels the children it started - answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json] + answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json] + --by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim; + --rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending + recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>] + set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id> contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) @@ -87,10 +93,13 @@ function num(args: string[], name: string): number | undefined { } const resultLineFor = (status: string, out: string) => `GSTACK_RESULT: skill=autoplan status=${status} run=${out}`; const listFlag = (args: string[], name: string) => args.flatMap((a, i) => (a === name && args[i + 1] ? args[i + 1]!.split(',').map(x => x.trim()).filter(Boolean) : [])); -/** The NEXT line after a reopen or revise: revise while accepted changes are pending, else next. */ +/** The NEXT line after an answer or revise: per pending change, settle it or edit the plan and revise; else next. */ function nextLine(state: RunState, out: string): string { - const pending = pendingAmendments(state).map(a => a.decision); - return pending.length ? `NEXT: gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')}` : `NEXT: gstack-autoplan next --out ${out}`; + const pending = pendingAmendments(state); + const settle = pending.filter(a => applyGuidance(a).startsWith('no plan edit')).map(a => a.item ?? a.decision); + const edit = pending.filter(a => !applyGuidance(a).startsWith('no plan edit')).map(a => a.decision); + if (!pending.length) return `NEXT: gstack-autoplan next --out ${out}`; + return `NEXT: ${[edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : '', settle.length ? `gstack-autoplan revise --out ${out} --settle ${settle.join(',')}` : ''].filter(Boolean).join('; ')}`; } /** @@ -166,12 +175,12 @@ function doSubmit(out: string, rest: string[], json: boolean): number { } function doRevise(out: string, rest: string[], json: boolean): number { - const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); - if (!plan && !withdraw.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), or --withdraw <decision id>'); + const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle'); + if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>'); if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>'); - const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, cwd: process.cwd() }); + const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() }); const lines = [...r.lines]; - if (r.state.status === 'running') lines.push(nextLine(r.state, out)); + if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out)); lines.push(resultLineFor(r.state.status, out)); emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out }); return EXIT.ok; @@ -249,17 +258,31 @@ async function main(argv: string[]): Promise<number> { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by'); if (reply === undefined) return usage('answer needs --reply "<text>"'); if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; } + const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply'); + if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive'); + const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag; return mutating(out, json, () => { const state = readState(out); - const r = answerGate(state, Number(rev), reply, by); + const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply }); writeState(state); const lines = [...r.lines]; - if (r.outcome === 'reopened') lines.push(nextLine(state, out)); + if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out)); lines.push(resultLineFor(state.status, out)); emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out }); return EXIT.ok; }); } + if (cmd === 'recommend') { + const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis'); + if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"'); + return mutating(out, json, () => { + const state = readState(out); + const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent'); + writeState(state); + emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out }); + return EXIT.ok; + }); + } if (cmd === 'export') { return mutating(out, json, () => { const state = readState(out); @@ -283,7 +306,7 @@ async function main(argv: string[]): Promise<number> { } 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'].includes(cmd)) process.stdout.write(resultLineFor('refused', out) + '\n'); + if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend'].includes(cmd)) process.stdout.write(resultLineFor('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 181410d40..f3d9c7962 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -184,6 +184,10 @@ gstack_result_anchor() { PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;; PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;; AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; + AMENDMENT_NOT_SETTLEABLE) printf '%s' 'amendment-not-settleable' ;; + GATE_REC_STALE) printf '%s' 'gate-rec-stale' ;; + OWNER_REPLY_REQUIRED) printf '%s' 'owner-reply-required' ;; + GATE_OWNER_REPLY_MISMATCH) printf '%s' 'gate-owner-reply-mismatch' ;; GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;; ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;; UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f5073c73f..bd10459f6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2700,7 +2700,10 @@ incorporated or withdrawn; `export` refuses a `complete` run with one pending. **Fix.** Edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`. `revise` binds each named id to the old and new Implementation-plan hashes and refuses when the Implementation plan did -not change; an id you do not name stays pending. To drop an accepted change +not change; an id you do not name stays pending. A packaging or ordering change +(its `APPLY:` line ends `no plan edit needed: revise --settle <id>`) is +discharged with `gstack-autoplan revise --out <dir> --settle <id>` and reopens +nothing. To drop an accepted change instead, `gstack-autoplan revise --out <dir> --withdraw <decision id>` records the withdrawal in the journal. @@ -2716,6 +2719,53 @@ amendments), or it was already incorporated or withdrawn. Nothing was changed. pending, revise without `--incorporates`: `gstack-autoplan revise --out <dir> --plan <file>`. +<a id="amendment-not-settleable"></a> +### `AMENDMENT_NOT_SETTLEABLE` + +**Meaning.** `gstack-autoplan revise --settle <id>` discharges an accepted change +without a plan edit, which is honest only for a packaging or ordering change +(the gate item's `changes` is `packaging` or `ordering`) on a plan nobody has +edited since it was reviewed. The named change alters scope, cost or direction, +names no `changes` value, or the plan file's Implementation plan no longer +matches the reviewed snapshot. Nothing was changed. + +**Fix.** Edit the plan, then +`gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`. +The `APPLY:` line for each change says which path it takes. + +<a id="gate-rec-stale"></a> +### `GATE_REC_STALE` + +**Meaning.** `gstack-autoplan recommend` changed a recommendation after the gate +was rendered, so a `rec` (or `all`) reply the owner gave to the earlier render +would take recommendations they never saw. Every render prints +`rec_hash=<h>`; once `recommend` has run, a `rec` answer must name the hash of +the render the owner answered. + +**Fix.** Render again (`gstack-gate render <dir>/gate.json --all`), relay it, +and answer with `--rec-hash <h>` from that render. + +<a id="owner-reply-required"></a> +### `OWNER_REPLY_REQUIRED` + +**Meaning.** An agent answered the gate (`--by agent:<id>`) without recording +what the owner actually said. Nothing was applied. + +**Fix.** Pass the owner's words verbatim: `--owner-reply "<text>"`, +`--owner-reply -` to read them from stdin, or `--owner-reply-file <path>`. They +are stored on every decision row; when they parse under the gate grammar they +must resolve to the same choices as `--reply`. + +<a id="gate-owner-reply-mismatch"></a> +### `GATE_OWNER_REPLY_MISMATCH` + +**Meaning.** The owner's verbatim reply is itself a gate reply (for example +`rec`), and it resolves to different choices than the tokens the agent relayed. +Nothing was applied. + +**Fix.** Relay what the owner chose; passing their reply as `--reply` is always +consistent. When the owner's words and your tokens disagree, ask the owner. + <a id="gstack-version-changed"></a> ### `GSTACK_VERSION_CHANGED` diff --git a/lib/autoplan-export.ts b/lib/autoplan-export.ts index cf876bde8..c88e274c5 100644 --- a/lib/autoplan-export.ts +++ b/lib/autoplan-export.ts @@ -45,7 +45,7 @@ export function aggregateFindings(state: RunState): FindingRow[] { for (const row of voiceRows(state, phase, 'native')) out.push({ ...row, native_counterpart: null }); for (const row of voiceRows(state, phase, 'outside')) { const r = byOutside.get(row.id); - out.push({ ...row, native_counterpart: 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 } : {}) } as FindingRow); } } return out; diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index b46e895ea..ad099e598 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -20,11 +20,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; import { driftLine } from './autoplan-plan-text'; -import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type ReconciliationOverride, type ReconciliationRow } from './autoplan-reconcile'; +import { consensusRecord, reconcile, 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 RunState, type VoiceState } from './autoplan-state'; +import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; import { phaseClose } from './autoplan-timing'; -import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list'; +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 { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief'; @@ -57,7 +57,7 @@ export function closePhase(state: RunState, phase: Phase, o: { overridesFile?: s const md = renderConsensus(consensus, [...native, ...outside]); fs.writeFileSync(path.join(state.out, `${phase}-consensus.json`), JSON.stringify(consensus, null, 2) + '\n'); fs.writeFileSync(path.join(state.out, `${phase}-consensus.md`), md); - ps.consensus = { confirmed: consensus.confirmed, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; + ps.consensus = { confirmed: consensus.confirmed, unverified: consensus.unverified, disagree: consensus.disagree, new: consensus.new, native_only: consensus.native_only, coverage: consensus.coverage }; ps.closed_at = now(); const closed = phaseClose(path.join(state.out, 'timing.json'), state.run, phase, { outside_s: seconds(ps.outside), native_s: seconds(ps.native), session_kind: state.session_kind }); journalRow(state.out, { event: 'phase_closed', phase, consensus: ps.consensus, wall_s: closed.entry.wall_s, analytics: closed.analytics }); @@ -71,25 +71,31 @@ const CONTENT_SCOPE: Record<string, string> = { ceo: 'scope', design: 'design', const optionHash = (options: GateOption[]) => createHash('sha256').update(JSON.stringify(options.map(o => [o.key, o.text]))).digest('hex').slice(0, 16); const KEEP = (text: string): GateOption => ({ key: 'a', text, changes_plan: false }); +const remedyKey = (text: string | undefined) => (text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + /** - * A user challenge both voices raised recommends the change only when the - * finding reconciled `confirmed`, both name the same `changes` and the - * remedies are on the record (both voices', or the parent's one shared - * proposal from a reconcile override); option b shows them verbatim. Anything - * weaker keeps the plan's direction, and the basis says why. + * A user challenge both voices raised recommends the change only with an + * explicit, coherent shared proposal: the finding reconciled `confirmed`, + * both name the same `changes`, and either the parent's reconcile override + * states one proposal for both or both voices wrote the same remedy. Title + * similarity and file:line proximity only associate the findings; anything + * weaker keeps the plan's direction, and the basis says why. Option b shows + * the remedies verbatim. */ -function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } { +export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefined, o: FindingRow): { recommended: 'a' | 'b'; basis: string; text: string } { const nf = n as (FindingRow & { changes?: string }) | undefined, of = o as FindingRow & { changes?: string }; const remedies = row.proposal ? ` (shared proposal: ${row.proposal})` : nf?.resolution || of.resolution ? ` (native: ${nf?.resolution ?? 'no remedy stated'}; outside: ${of.resolution ?? 'no remedy stated'})` : ''; const text = `accept the change both reviewers recommend${remedies}`; const keep = (basis: string) => ({ recommended: 'a' as const, basis: `plan direction; ${basis}`, text }); + if (row.disposition === 'unverified') return keep('same location, agreement unverified'); if (row.disposition !== 'confirmed') return keep('voices disagree'); if (!nf?.changes || !of.changes) return keep('no shared changes value'); if (nf.changes !== of.changes) return keep('voices name different changes'); if (row.proposal) return { recommended: 'b', basis: 'both voices; shared proposal', text }; if (!nf.resolution || !of.resolution) return keep('a remedy is missing'); - return { recommended: 'b', basis: 'both voices; remedies shown', text }; + if (remedyKey(nf.resolution) !== remedyKey(of.resolution)) return keep('remedies differ; no shared proposal'); + return { recommended: 'b', basis: 'both voices; same remedy', text }; } /** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */ @@ -140,19 +146,54 @@ export function buildGate(state: RunState, gateRev: number): GateList { cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id); } } - const changes = items.filter(i => i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); - const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; - const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); - items.push({ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), - options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, - { key: 'c', text: 'interrogate (ask questions; stays pending)' }, - { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }); + items.push(planApproval(state, items)); const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate' }; const problems = validateGateList(list); if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`); return list; } +/** The p1 plan-approval item: recommends `b` when any item's recommendation changes the plan, with PLAN_REALITY flags. */ +function planApproval(state: RunState, items: GateItem[]): GateItem { + const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`); + const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan; + const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`); + return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}), + options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' }, + { key: 'c', text: 'interrogate (ask questions; stays pending)' }, + { key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] }; +} + +/** + * `gstack-autoplan recommend`: the relaying parent sets one item's recommendation before relay, with its basis and + * provenance. The plan-approval item and decisions.jsonl follow, and `rec_rev` bumps so a `rec` rendered earlier is + * refused (GATE_REC_STALE) until the gate is rendered and relayed again. + */ +export function recommendGate(state: RunState, gateRev: number, itemId: string, option: string, basis: string, by: string): string[] { + const gateFile = path.join(state.out, 'gate.json'); + if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3); + const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + if (gateRev !== list.gate_rev) throw new RunError('GATE_REV_STALE', `--gate-rev ${gateRev}, current gate_rev=${list.gate_rev}`, 3); + const item = list.items.find(i => i.id === itemId); + if (!item || item.id === 'p1' || !item.options.some(o => o.key === option)) throw new RunError('GATE_REPLY_UNPARSED', `${itemId}${option} names no item option a recommendation can take (p1 follows the items)`); + Object.assign(item, { recommended: option, recommended_basis: basis, recommended_by: by }); + const p1 = planApproval(state, list.items); + list.items = list.items.map(i => (i.id === 'p1' ? p1 : i)); + list.rec_rev = (list.rec_rev ?? 0) + 1; + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); + const updated = new Map([item, p1].map(i => [decisionId(list.run, gateRev, i.id), i])); + writeDecisions(state.out, readDecisions(state.out).map(r => { + const i = updated.get(r.id); + if (!i) return r; + return { ...r, recommended: i.recommended, ...(i.kind === 'auto' ? { default_if_unanswered: i.recommended } : {}), ...(i.recommended_basis ? { recommended_basis: i.recommended_basis } : {}), ...(i.recommended_by ? { recommended_by: i.recommended_by } : {}) }; + })); + const hash = recHash(list); + journalRow(state.out, { event: 'recommended', gate_rev: gateRev, item: itemId, option, basis, by, rec_rev: list.rec_rev, rec_hash: hash }); + exportRun(state); + return [`RECOMMENDED: ${itemId}${option} by=${by} basis=${JSON.stringify(basis)} p1=${p1.recommended} rec_hash=${hash}`, + `RELAY: gstack-gate render ${gateFile} --all (a rec rendered before this is refused; answer with --rec-hash ${hash})`]; +} + /** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */ export function finishGate(state: RunState): GateList { const gateRev = (state.gate_rev ?? 0) + 1; @@ -183,32 +224,61 @@ export function finishGate(state: RunState): GateList { // --------------------------------------------------------------------------- // answer // --------------------------------------------------------------------------- -export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected'; lines: string[] } +export interface AnswerResult { state: RunState; applied: boolean; pending: string[]; reopened: Phase[]; outcome: 'complete' | 'gate_pending' | 'reopened' | 'rejected' | 'amend_pending'; lines: string[] } +export interface AnswerOptions { + /** The rec_hash of the render the owner saw; required for a `rec` reply once `recommend` changed the gate. */ + recHash?: string; + /** The owner's verbatim words; required for every `--by agent:<id>` answer. */ + ownerReply?: string; +} + +/** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */ +export function applyGuidance(a: Pick<Amendment, 'changes' | 'item'>): string { + return a.changes && SETTLEABLE_CHANGES.includes(a.changes) ? `no plan edit needed: revise --settle ${a.item}` : 'edit the plan, then revise'; +} + +/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */ +function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean { + const owner = parseReply(ownerReply, list, gateRev, prior); + if (!owner.valid) return false; + const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort()); + if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) { + throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3); + } + return true; +} /** * Apply one reply. Earlier answers at this revision stand unless named again; * the gate stays pending until every approval settles. A chosen option with - * `changes_plan` is an accepted plan change: it reopens its phase plus Eng, - * prints `APPLY:` and becomes a pending amendment (`next` refuses until - * `revise --incorporates` or `--withdraw`). + * `changes_plan` is an accepted plan change: it prints `APPLY:` and becomes a + * pending amendment (status `amend_pending`); nothing reopens here. `revise` + * reopens exactly the phases whose reviewed plan changed, and `revise + * --settle` discharges a packaging/ordering change without an edit. */ -export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult { +export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string, o: AnswerOptions = {}): AnswerResult { const gateFile = path.join(state.out, 'gate.json'); if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3); if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3); if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3); + if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "<text>" (or --owner-reply - / --owner-reply-file <path>)`, 3); const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; const rows = readDecisions(state.out); const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!])); const parsed = parseReply(reply, list, gateRev, prior); if (parsed.error === 'GATE_REV_STALE') throw new RunError('GATE_REV_STALE', `reply names gate_rev=${gateRev}, current gate_rev=${list.gate_rev}`, 3); if (!parsed.valid) throw new RunError('GATE_REPLY_UNPARSED', parsed.error === 'GATE_EMPTY' ? 'empty reply' : `unparsed: ${parsed.unparsed.join(', ')}`); + const currentHash = recHash(list); + if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3); + if (parsed.mode === 'rec' && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3); + const ownerParsed = o.ownerReply !== undefined ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined; + const provenance = o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {}; const itemOf = (id: string) => list.items.find(i => i.id === id)!; const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!; const plan = parsed.choices.find(c => c.id === 'p1'); const lines: string[] = []; const answeredAt = now(); - const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => { + const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy, provenance).map(r => { const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined; return c ? { ...r, chosen_text: c.text } : r; })); @@ -225,15 +295,10 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : []; const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`); const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1'); - const reopenSet = new Set<Phase>(); - if (plan.chosen === 'd') state.phase_order.forEach(p => reopenSet.add(p)); - for (const c of changing) { - const phase = itemOf(c.id).phase as Phase | undefined; - if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); } - } + const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []); record(); - state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) }; - journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) }); + state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }; + journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance }); if (plan.chosen === 'c') { lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`); return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines }; @@ -246,30 +311,44 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ return { state, applied: true, pending: [], reopened: [], outcome: 'rejected', lines }; } for (const c of changing) { - const decision = decisionId(state.run, gateRev, c.id); - (state.amendments ??= []).push({ decision, title: itemOf(c.id).title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt }); - journalRow(state.out, { event: 'amendment_pending', decision, gate_rev: gateRev }); - lines.push(`APPLY: ${decision} ${itemOf(c.id).title} -> ${optionOf(c).text}`); + const item = itemOf(c.id); + const amendment: Amendment = { decision: decisionId(state.run, gateRev, c.id), title: item.title, chosen: c.chosen, text: optionOf(c).text, gate_rev: gateRev, status: 'pending', at: answeredAt, + item: c.id, ...(item.changes ? { changes: item.changes } : {}), ...(item.phase ? { phase: item.phase } : {}) }; + (state.amendments ??= []).push(amendment); + journalRow(state.out, { event: 'amendment_pending', decision: amendment.decision, gate_rev: gateRev, ...(item.changes ? { changes: item.changes } : {}) }); + lines.push(`APPLY: ${amendment.decision} ${item.title} -> ${amendment.text}; ${applyGuidance(amendment)}`); } - if (reopenSet.size === 0) { - state.status = 'complete'; - lines.push(...closeDraftDirections(state, answeredAt.slice(0, 10))); + if (reopenSet.size) { + const reopened = state.phase_order.filter(p => reopenSet.has(p)); + reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: revise`); + if (changing.length) state.status = 'amend_pending'; + lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=revise`); + const drift = driftLine(state, 'PLAN_SOURCE_CHANGED'); + if (drift) lines.push(drift); + return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines }; + } + if (changing.length) { + state.status = 'amend_pending'; exportRun(state); - const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id))); - lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`); - return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines }; + lines.push(`AMEND_PENDING: ${changing.length} accepted change(s) not yet in the plan; nothing reopens until revise`); + return { state, applied: true, pending: [], reopened: [], outcome: 'amend_pending', lines }; } - const reopened = state.phase_order.filter(p => reopenSet.has(p)); - const reason = plan.chosen === 'd' ? 'revise' : tokens(changing).join(','); - reopenPhases(state, reopened, gateRev, `gate_rev ${gateRev}: ${reason}`); - lines.push(`REOPENED: ${reopened.join(',')} (Eng last) reason=${reason}`); - const drift = driftLine(state, 'PLAN_SOURCE_CHANGED'); - if (drift) lines.push(drift); - return { state, applied: true, pending: [], reopened, outcome: 'reopened', lines }; + lines.push(...approve(state, answeredAt)); + const notes = overrides.filter(o => o.chosen !== keepDirection(itemOf(o.id))); + lines.push(`APPROVED: gate_rev=${gateRev} ${notes.length ? `overrides=${tokens(notes).join(',')} (none change reviewed content)` : 'as-is'}`); + return { state, applied: true, pending: [], reopened: [], outcome: 'complete', lines }; +} + +/** The run is approved: status complete, draft directions closed, files exported. */ +export function approve(state: RunState, at: string): string[] { + state.status = 'complete'; + const lines = closeDraftDirections(state, at.slice(0, 10)); + exportRun(state); + return lines; } /** E3 phase-close operation: every `draft direction stands until the owner decides` line in the active plan becomes the decided option and date. */ -function closeDraftDirections(state: RunState, date: string): string[] { +export function closeDraftDirections(state: RunState, date: string): string[] { const loaded = loadBriefInputs(state.out); if ('error' in loaded) return []; const r = closeDraftDirectionsFile(state.active_plan, loaded.inputs, date); @@ -308,7 +387,8 @@ export function planReopen(state: RunState, phases: Phase[], gateRev: number, re journalRow(state.out, { event: 'superseded', attempt: a.attempt, phase, voice, reason }); } const output = ps[voice]?.output; - if (output) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; } + // A sealed copy is attempt-owned and immutable: it stays in place and bound; only a pre-seal output (bound at the result path) is archived. + if (output && !output.endsWith('.bound.md')) { archive(`${output}.findings.jsonl`); archive(output); delete state.artifacts[output]; } archive(`${phase}-${voice}-prompt.md`); } archive(`${phase}-consensus.json`); archive(`${phase}-consensus.md`); diff --git a/lib/autoplan-prompts.ts b/lib/autoplan-prompts.ts index d5f952bf4..3863ced13 100644 --- a/lib/autoplan-prompts.ts +++ b/lib/autoplan-prompts.ts @@ -112,7 +112,7 @@ RESULT FORMAT (required by gstack-autoplan; the parent binds your file by this c \`\`\`gstack-findings {"id":"F1","severity":"High","title":"<one line>","file":"<path or omit>","line":12,"fix":"<the fix>"} \`\`\` - severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction; then also add "changes": "scope" | "cost" | "direction". Packaging, ordering, naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. + severity is Critical | High | Medium | Low | Informational. Add "user_challenge": true only when you recommend changing the plan's stated direction AND the change alters its scope, cost or direction (then also add "changes": "scope" | "cost" | "direction") or only its delivery packaging or ordering, with no Implementation-plan edit (then "changes": "packaging" | "ordering"). Naming and other reversible choices are ordinary findings, not user challenges. Add "urgent": true and "suggested_owner": "<who>" for a security or data-loss finding this plan does not own. An empty fence means no findings; a missing fence is refused. 3. Write the whole result to this file: ${resultPath} `; @@ -177,6 +177,15 @@ export function writePrompts(inp: PromptInputs): { native: string; outside: stri return { native, outside, nativeResult, outsideResult }; } +/** An attempt-owned file in the run directory: `<phase>-<voice>.<attempt>.md` (the reviewer writes it) or `.bound.md` (the sealed copy). */ +export const attemptFile = (phase: string, voice: string, shortAttempt: string, ext: 'md' | 'bound.md') => `${phase}-${voice}.${shortAttempt}.${ext}`; + +/** Point a prompt's RESULT FORMAT at the attempt's own result path, so a superseded reviewer never writes where its successor does. */ +export function bindPromptResult(promptFile: string, resultPath: string): void { + const text = fs.readFileSync(promptFile, 'utf8'); + fs.writeFileSync(promptFile, text.replace(/^3\. Write the whole result to this file: .*$/m, `3. Write the whole result to this file: ${resultPath}`)); +} + /** The installed review skill for a phase: this checkout's generated SKILL.md (same layout as ~/.claude/skills/gstack/<skill>/SKILL.md). */ export function reviewSkillFile(phase: Phase): string { const file = path.join(ROOT, REVIEW_SKILL[phase], 'SKILL.md'); diff --git a/lib/autoplan-reconcile.ts b/lib/autoplan-reconcile.ts index 1deb6a8d4..7dc47302b 100644 --- a/lib/autoplan-reconcile.ts +++ b/lib/autoplan-reconcile.ts @@ -26,10 +26,12 @@ const SEVERITY_ALIASES: Record<string, Severity> = { export interface CanonicalFinding { severity: Severity; title: string; file?: string; line?: number; fix?: string; local_id?: string; user_challenge?: boolean; urgent?: boolean; suggested_owner?: string; plan_items?: string[]; dimension?: string; - /** What a user challenge changes; only these three make it a gate question (plan B2a). */ + /** What a user challenge changes: scope, cost or direction (plan B2a), or packaging/ordering (settled without a plan edit). */ changes?: Changes; } -export const CHANGES = ['scope', 'cost', 'direction'] as const; +export const CHANGES = ['scope', 'cost', 'direction', 'packaging', 'ordering'] as const; +/** Accepted changes of these kinds need no Implementation-plan edit: `revise --settle` records them. */ +export const SETTLEABLE_CHANGES: readonly string[] = ['packaging', 'ordering']; export type Changes = typeof CHANGES[number]; export interface ParsedReviewResult { receipt: { ok: boolean; phase?: string; sha256?: string; line?: string }; @@ -186,7 +188,8 @@ export function canonicalRows(findings: CanonicalFinding[], ctx: { run: string; } as FindingRow)); } -export type ReconciliationDisposition = 'confirmed' | 'disagree' | 'new'; +/** `unverified`: the same file:line with no other shared evidence (titles unalike): associated, agreement unverified. */ +export type ReconciliationDisposition = 'confirmed' | 'unverified' | 'disagree' | 'new'; export interface ReconciliationRow { outside_id: string; native_id: string | null; disposition: ReconciliationDisposition; /** Why this disposition: the matched location or title, or the parent's override reason. */ @@ -208,11 +211,14 @@ function jaccard(a: Set<string>, b: Set<string>): number { const level = (s: Severity) => SEVERITIES.indexOf(s); /** - * Structural reconciliation: a file:line match (±3 lines) is `confirmed` - * unless severities differ by two or more levels (`disagree`); otherwise a - * title overlap of at least half the words is `confirmed`; else `new`. Each - * native finding is matched at most once. Overrides replace the structural - * row for their outside id and must name ids from these two lists. + * Structural reconciliation. Findings associate by a title overlap of at least + * half the words, or by file:line (±3 lines). A title association is + * `confirmed` unless severities differ by two or more levels (`disagree`); a + * location-only association is `unverified` (same location, agreement + * unverified: two findings near one line are not one finding); else `new`. + * Each native finding is matched at most once. Overrides (the parent's + * semantic reconciliation, with an optional shared `proposal`) replace the + * structural row for their outside id and must name ids from these two lists. */ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides: ReconciliationOverride[] = []): { rows: ReconciliationRow[]; native_only: string[]; errors: string[] } { const errors: string[] = []; @@ -222,7 +228,7 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides for (const o of overrides) { if (!outsideIds.has(o.outside_id)) { errors.push(`override names unknown outside id ${o.outside_id}`); continue; } if (o.native_id && !nativeIds.has(o.native_id)) { errors.push(`override for ${o.outside_id} names unknown native id ${o.native_id}`); continue; } - if (!['confirmed', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|disagree|new`); continue; } + if (!['confirmed', 'unverified', 'disagree', 'new'].includes(o.disposition)) { errors.push(`override for ${o.outside_id}: disposition ${JSON.stringify(o.disposition)} is not confirmed|unverified|disagree|new`); continue; } if (o.disposition !== 'new' && !o.native_id) { errors.push(`override for ${o.outside_id}: ${o.disposition} needs a native_id`); continue; } if (typeof o.resolution !== 'string' || !o.resolution.trim()) { errors.push(`override for ${o.outside_id}: resolution is required`); continue; } byOutside.set(o.outside_id, o); @@ -238,28 +244,29 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides ...(typeof override.proposal === 'string' && override.proposal.trim() ? { proposal: override.proposal.trim() } : {}) }); continue; } + const near = (n: FindingRow) => !!o.file && o.line !== undefined && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line) <= 3; + const ot = tokens(o.title); let match: FindingRow | undefined; - let basis: ReconciliationRow['basis'] = 'none'; - if (o.file && o.line !== undefined) { - match = native.find(n => !used.has(n.id) && n.file === o.file && n.line !== undefined && Math.abs(n.line - o.line!) <= 3); - if (match) basis = 'file_line'; - } - if (!match) { - const ot = tokens(o.title); - let best = 0; - for (const n of native) { - if (used.has(n.id)) continue; - const score = jaccard(ot, tokens(n.title)); - if (score >= 0.5 && score > best) { best = score; match = n; } - } - if (match) basis = 'title'; + let best = 0; + for (const n of native) { + if (used.has(n.id)) continue; + const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0); + if (score >= 0.5 && score > best) { best = score; match = n; } } + const titled = !!match; + match ??= native.find(n => !used.has(n.id) && near(n)); if (!match) { rows.push({ outside_id: o.id, native_id: null, disposition: 'new', resolution: 'not raised by the native voice', basis: 'none', severity: { outside: o.severity, native: null } }); continue; } used.add(match.id); + const basis: ReconciliationRow['basis'] = near(match) ? 'file_line' : 'title'; + const where = near(match) ? `${o.file}:${o.line}` : 'same title'; + if (!titled) { + rows.push({ outside_id: o.id, native_id: match.id, disposition: 'unverified', resolution: `same location, agreement unverified (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity } }); + continue; + } const delta = Math.abs(level(match.severity) - level(o.severity)); rows.push({ outside_id: o.id, native_id: match.id, disposition: delta >= 2 ? 'disagree' : 'confirmed', - resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'})` : `both voices (${basis === 'file_line' ? `${o.file}:${o.line}` : 'same title'}): ${match.title}`, + resolution: delta >= 2 ? `severity: native ${match.severity}, outside ${o.severity} (${where})` : `both voices (${where}): ${match.title}`, basis, severity: { outside: o.severity, native: match.severity }, }); } @@ -271,6 +278,8 @@ export interface ConsensusRecord { native: { model: string | null; findings: number; by_severity: Record<Severity, number> } | null; outside: { model: string | null; provider: string | null; findings: number; by_severity: Record<Severity, number> } | null; confirmed: number; disagree: number; new: number; native_only: number; + /** Same location, agreement unverified (absent on records written before it existed). */ + unverified?: number; rows: ReconciliationRow[]; native_only_ids: string[]; /** CONFIRMED requires both voices completed. */ coverage: 'both' | 'native_only' | 'outside_only' | 'none'; @@ -288,7 +297,7 @@ export function consensusRecord(phase: string, native: { rows: FindingRow[]; mod schema_version: 1, phase, native: native ? { model: native.model ?? null, findings: native.rows.length, by_severity: bySeverity(native.rows) } : null, outside: outside ? { model: outside.model ?? null, provider: outside.provider ?? null, findings: outside.rows.length, by_severity: bySeverity(outside.rows) } : null, - confirmed: rows.filter(r => r.disposition === 'confirmed').length, disagree: rows.filter(r => r.disposition === 'disagree').length, + confirmed: rows.filter(r => r.disposition === 'confirmed').length, unverified: rows.filter(r => r.disposition === 'unverified').length, disagree: rows.filter(r => r.disposition === 'disagree').length, new: rows.filter(r => r.disposition === 'new').length, native_only: native && outside ? rec.native_only.length : (native?.rows.length ?? 0), rows, native_only_ids: native && outside ? rec.native_only : (native?.rows.map(r => r.id) ?? []), coverage: native && outside ? 'both' : native ? 'native_only' : outside ? 'outside_only' : 'none', @@ -306,7 +315,7 @@ export function renderConsensus(c: ConsensusRecord, findings: FindingRow[]): str ...SEVERITIES.map(s => ` ${s.padEnd(15)} ${String(c.native?.by_severity[s] ?? '—').padEnd(28)} ${c.outside?.by_severity[s] ?? '—'}`), ` Findings ${String(c.native?.findings ?? '—').padEnd(28)} ${c.outside?.findings ?? '—'}`, c.coverage === 'both' - ? `CONFIRMED ${c.confirmed} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` + ? `CONFIRMED ${c.confirmed} · SAME LOCATION, UNVERIFIED ${c.unverified ?? 0} · DISAGREE ${c.disagree} · NEW (outside only) ${c.new} · native only ${c.native_only}. Both completed.` : `Consensus: N/A (voice coverage: ${c.coverage.replace('_', ' ')}); confirmed counts require both voices.`, '', 'Reconciliation (each outside finding against the native result):', diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index f3452b52f..785a2601c 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -16,13 +16,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { exportRun } from './autoplan-export'; -import { applyRenames, planReopen } from './autoplan-gate'; +import { applyRenames, approve, closeDraftDirections, planReopen } from './autoplan-gate'; +import { SETTLEABLE_CHANGES } from './autoplan-reconcile'; import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text'; import { parseScopeTokens } from './autoplan-scope'; import { PHASES, detectScope, extractHeading, phaseOrder, type Phase, type ScopeFlags } from './autoplan-prompts'; import { FILES, RunError, journal, now, pendingAmendments, readJournal, readState, writeState, type RunState } from './autoplan-state'; -export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; cwd: string } +export interface ReviseOptions { out: string; plan?: string; incorporates?: string[]; withdraw?: string[]; settle?: string[]; cwd: string } export interface ReviseResult { state: RunState; reopened: Phase[]; lines: string[] } const amendmentLine = (state: RunState) => `AMENDMENTS: pending=${pendingAmendments(state).map(a => a.decision).join(',') || 'none'}`; @@ -41,37 +42,74 @@ function revisionText(state: RunState, file: string): string { return diag.text ?? text; } -function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { +function settleAmendments(state: RunState, ids: string[], status: 'incorporated' | 'withdrawn' | 'settled', at: string, extra: Record<string, unknown> = {}): Array<Record<string, unknown>> { return ids.map(id => { const a = pendingAmendments(state).find(x => x.decision === id)!; a.status = status; a.settled_at = at; Object.assign(a, extra); - return { event: status === 'incorporated' ? 'amendment_incorporated' : 'amendment_withdrawn', decision: id, ...extra }; + return { event: `amendment_${status}`, decision: id, ...extra }; }); } +/** A `--settle`/`--incorporates`/`--withdraw` id: the decision id, or the short item id (`d4`) at the current gate_rev. */ +function resolveIds(state: RunState, ids: string[]): string[] { + return ids.map(id => pendingAmendments(state).find(a => a.decision === id || (a.item === id && a.gate_rev === state.gate_rev))?.decision ?? id); +} + +/** + * `--settle` evidence: a packaging/ordering change on a plan whose reviewed Implementation plan is unchanged (the + * active plan and the owner's plan file both hash to the snapshot the phases reviewed). A real change never settles. + */ +function settleEvidence(state: RunState, ids: string[]): { plan_sha256: string; changes: Record<string, string> } { + const reviewed = state.phase_order.map(p => state.phases[p]?.snapshot).find(Boolean); + const active = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); + let input = active; + try { if (state.plan_input && fs.existsSync(state.plan_input)) input = reviewHash(revisionText(state, state.plan_input)); } catch { input = ''; } + const changes: Record<string, string> = {}; + for (const id of ids) { + const a = pendingAmendments(state).find(x => x.decision === id)!; + if (!a.changes || !SETTLEABLE_CHANGES.includes(a.changes)) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `${id} changes ${a.changes ?? 'nothing named'}; only packaging or ordering changes settle without a plan edit`, 3); + changes[id] = a.changes; + } + if (!reviewed || active !== reviewed || input !== reviewed) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the reviewed plan changed (reviewed ${String(reviewed).slice(0, 12)}, active ${active.slice(0, 12)}, plan file ${input.slice(0, 12) || 'unreadable'}); revise --plan instead`, 3); + return { plan_sha256: reviewed, changes }; +} + +/** Every accepted change is discharged and nothing reopened: the answered gate stands and the run is complete. */ +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 }); + return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; +} + export function reviseRun(o: ReviseOptions): ReviseResult { const out = path.resolve(o.out); const state = readState(out); if (['complete', 'incomplete', 'refused'].includes(state.status)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}; revise works on a running or gated run`, 3); + const amending = state.status === 'amend_pending'; const pending = pendingAmendments(state).map(a => a.decision); - for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) { + const incorporates = resolveIds(state, o.incorporates ?? []), withdraw = resolveIds(state, o.withdraw ?? []), settle = resolveIds(state, o.settle ?? []); + for (const id of [...incorporates, ...withdraw, ...settle]) { if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3); } + const evidence = settle.length ? settleEvidence(state, settle) : undefined; const at = now(); if (!o.plan) { - for (const row of settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })) journal(out, row); + for (const row of settleAmendments(state, withdraw, 'withdrawn', at, { by: 'owner' })) journal(out, row); + for (const row of settleAmendments(state, settle, 'settled', at, { plan_sha256: evidence?.plan_sha256 })) journal(out, { ...row, changes: evidence!.changes[row.decision as string] }); + const lines = [...settle.map(id => `SETTLED: ${id} (${evidence!.changes[id]}; plan ${evidence!.plan_sha256.slice(0, 12)} unchanged; nothing reopens)`), amendmentLine(state), ...finishAmendments(state, at)]; writeState(state); - return { state, reopened: [], lines: [amendmentLine(state)] }; + return { state, reopened: [], lines }; } const file = path.resolve(o.plan); const text = revisionText(state, file); let newHash: string; try { newHash = reviewHash(text); } catch (e: any) { throw new RunError('PLAN_SECTIONS_INVALID', `${file}: ${e.message}`, 3); } const oldHash = reviewHash(fs.readFileSync(state.active_plan, 'utf8')); - if (o.incorporates?.length && newHash === oldHash) { - throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${o.incorporates.join(', ')} needs the edit in ${file}`, 3); + if (incorporates.length && newHash === oldHash) { + throw new RunError('PLAN_AMEND_PENDING', `the Implementation plan did not change (${oldHash.slice(0, 12)}); incorporating ${incorporates.join(', ')} needs the edit in ${file}`, 3); } + if (settle.length && newHash !== oldHash) throw new RunError('AMENDMENT_NOT_SETTLEABLE', `the revised plan changes the Implementation plan (${oldHash.slice(0, 12)} -> ${newHash.slice(0, 12)}); incorporate ${settle.join(', ')} instead of settling`, 3); const k = (state.plan_revision ?? 0) + 1; const staging = path.join(out, `.revise-v${k}`); fs.rmSync(staging, { recursive: true, force: true }); @@ -87,8 +125,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { 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 atGate = state.status === 'gate_pending' ? state.gate_rev : undefined; - const incorporated = o.incorporates ?? []; - journal(out, { event: 'revise_intent', k, staging, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, at_gate_rev: atGate ?? null, incorporates: incorporated, withdraw: o.withdraw ?? [] }); + journal(out, { event: 'revise_intent', k, 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 p of added) state.phases[p] = {}; @@ -97,11 +134,19 @@ export function reviseRun(o: ReviseOptions): ReviseResult { if (reopen.length || added.length) state.status = 'running'; state.plan_input = file; state.plan_source = path.join(out, `plan-source.v${k}.md`); state.plan_revision = k; delete state.plan_reality; - const settled = [...settleAmendments(state, incorporated, 'incorporated', at, { old_plan_sha256: oldHash, new_plan_sha256: newHash }), ...settleAmendments(state, o.withdraw ?? [], 'withdrawn', at, { by: 'owner' })]; + 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'; 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); - return { state, reopened: reopen, 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(',')}` : ''}`, amendmentLine(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); + writeState(state); + } + return { state, reopened: reopen, lines }; } /** The commit half of the transaction; recoverRevise replays it from the stage. Every step is idempotent. */ diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 41f24d371..1f06ebd9c 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -19,10 +19,10 @@ 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 { closePhase, finishGate } from './autoplan-gate'; +import { applyGuidance, closePhase, finishGate } from './autoplan-gate'; import { GSTACK_VERSION } from './autoplan-migrate'; import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text'; -import { PHASES, SNAPSHOT_TOOL, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts'; +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'; import { recoverRevise } from './autoplan-revise'; import { FILES, RunError, journal, now, pendingAmendments, readDecisions, readJournal, readState, voiceTerminal, writeState, type AttemptState, type PhaseState, type RunState, type Voice, type VoiceState } from './autoplan-state'; import { checkPlanReality, checkRealityRows, PLAN_REALITY_CHECKER } from './plan-reality'; @@ -178,7 +178,8 @@ export function nextStep(o: NextOptions): NextResult { if (state.deadline_at && Date.parse(state.deadline_at) < Date.now()) { state.status = 'interrupted'; writeState(state); lines.push(`DEADLINE: passed at ${state.deadline_at}`); return result; } const pending = pendingAmendments(state).map(a => a.decision); if (pending.length) { - throw new RunError('PLAN_AMEND_PENDING', `accepted at the gate but not in the plan: ${pending.join(', ')}; edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --withdraw <id>)`, 3); + 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}); edit the plan, then gstack-autoplan revise --out ${state.out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${pending.join(',')} (or --settle <id> for a packaging/ordering change, --withdraw <id>)`, 3); } const phase = currentPhase(state); if (!phase) { finishGate(state); writeState(state); return result; } @@ -210,7 +211,8 @@ export function nextStep(o: NextOptions): NextResult { const estimate = o.estimateUsd ?? 0; try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { 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: prompts[voice === 'native' ? 'nativeResult' : 'outsideResult'], 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, dispatched_at: now() }; + bindPromptResult(row.prompt, row.result); journal(state.out, { event: 'dispatched', attempt, phase, voice, snapshot: ps.snapshot, prompt: row.prompt, prompt_sha256: sha256File(row.prompt), result: row.result, estimate_usd: estimate }); (v.attempts ??= []).push(row); v.attempt = attempt; v.status = 'running'; @@ -292,6 +294,10 @@ export function submitResult(o: SubmitOptions): SubmitResult { } const open = resolveAttempt(v, o.attempt, phase, o.voice); const file = (o.record ?? o.result)!; + const owner = new RegExp(`^${phase}-${o.voice}\\.(a\\d+)\\.md$`).exec(path.basename(file)); + if (owner && path.dirname(path.resolve(file)) === state.out && `${state.run}-${owner[1]}` !== open.attempt) { + throw new RunError('ATTEMPT_MISMATCH', `${path.basename(file)} is the result path of ${state.run}-${owner[1]}, not of the open attempt ${open.attempt} (result=${open.result})`, 3); + } if (!fs.existsSync(file)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${file}`); const text = fs.readFileSync(file, 'utf8'); let bind: ReturnType<typeof readRecord>; @@ -309,11 +315,13 @@ export function submitResult(o: SubmitOptions): SubmitResult { else if (modelFamily(model) === 'unknown') conflict = `native model ${JSON.stringify(model)} has no recognized family`; if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3); - // Bind: immutable copy, per-voice canonical rows, journal, ledger, manifest. Nothing above this line wrote. - const bound = open.result; + // Bind: a sealed copy of the reviewer's bytes under an attempt-owned name the reviewer never writes, per-voice + // canonical rows, journal, ledger, manifest. Nothing above this line wrote; a later edit of `file` changes nothing bound. + const bound = path.join(state.out, attemptFile(phase, o.voice, open.attempt.slice(state.run.length + 1), 'bound.md')); const tmp = `${bound}.${process.pid}.tmp`; - fs.writeFileSync(tmp, text); + fs.writeFileSync(tmp, text, process.platform === 'win32' ? {} : { mode: 0o444 }); fs.renameSync(tmp, bound); + 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 }); @@ -340,7 +348,7 @@ export function submitResult(o: SubmitOptions): SubmitResult { function closeAndAdvance(state: RunState, phase: Phase, reconcile?: string): string[] { const close = closePhase(state, phase, { overridesFile: reconcile }); if (!currentPhase(state)) finishGate(state); else state.status = 'running'; - return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; + return [`PHASE_CLOSED: ${phase} confirmed=${close.consensus.confirmed}${close.consensus.unverified ? ` unverified=${close.consensus.unverified}` : ''} disagree=${close.consensus.disagree} new=${close.consensus.new} native_only=${close.consensus.native_only} coverage=${close.consensus.coverage}`]; } /** @@ -419,7 +427,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, {}); } - if (!currentPhase(state) && !['gate_pending', 'complete', 'incomplete', 'refused'].includes(state.status)) finishGate(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)))); state.status = anyOpen ? 'awaiting_result' : 'running'; @@ -440,6 +448,7 @@ export function statusLines(state: RunState): string[] { 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}` : ''}`); } + 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) { const open = state.phases[p]?.[voice] && openAttempt(state.phases[p]![voice]!); if (open) lines.push(`PENDING: ${open.attempt} phase=${p} voice=${voice} result=${open.result}`); @@ -447,6 +456,6 @@ export function statusLines(state: RunState): string[] { if (fs.existsSync(path.join(state.out, FILES.spend))) lines.push(renderSpend(summarize(readLedger(path.join(state.out, FILES.spend))))); return lines; } -export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'incomplete', 'refused', 'interrupted']; +export const TERMINAL: readonly RunStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'refused', 'interrupted']; export { SNAPSHOT_TOOL, RunError, FILES, readState, writeState, readJournal, journal }; export type { RunState, Voice, VoiceState, AttemptState }; diff --git a/lib/autoplan-state.ts b/lib/autoplan-state.ts index 0085bc8fb..c71d51e36 100644 --- a/lib/autoplan-state.ts +++ b/lib/autoplan-state.ts @@ -20,7 +20,11 @@ export interface VoiceState extends VoiceOutcome { receipt?: 'verified' | 'attested'; attest_reason?: string; findings_source?: 'fence' | 'table' | 'list' | 'none'; reviewed_sha256?: string; } /** An accepted plan change (gate option with changes_plan) the plan must absorb before the run continues. */ -export interface Amendment { decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn'; at: string; settled_at?: string } +export interface Amendment { + decision: string; title: string; chosen: string; text: string; gate_rev: number; status: 'pending' | 'incorporated' | 'withdrawn' | 'settled'; at: string; settled_at?: string; + /** The gate item's `changes` and phase: packaging/ordering changes settle without a plan edit (`revise --settle`). */ + changes?: string; phase?: string; item?: string; +} 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<string, unknown>; skipped?: string; diff --git a/lib/gate-list.ts b/lib/gate-list.ts index dc4a9c48e..680fbde5c 100644 --- a/lib/gate-list.ts +++ b/lib/gate-list.ts @@ -3,18 +3,21 @@ * A gate list is numbered items, each with a stable id, a recommended default * and lettered options; `auto` items take their recommendation when * unanswered, `approval` items stay pending until answered. One reply - * grammar: `all` (every item takes its recommendation), `<id><option>` tokens - * (`d3b uc1a`), or `all except <tokens>` (the same operation as listing the - * overrides, so there is no exclusion-vs-override ambiguity). Bare yes/no is - * ambiguous across multi-option items and is reported as unparsed, never - * guessed. A reply binds to the gate revision it answers (`gate_rev`). - * `all as-is` keeps every direction: each item takes its option that does not - * change the plan (`changes_plan: false`), so agreeing user challenges whose - * recommendation is the change can be declined in one token. Earlier answers - * at the same revision (`prior`) are kept unless a token names the item again: - * only unresolved items default. + * grammar: `rec` (every item takes its recommendation, plan changes + * included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>` + * (the same operation as listing the overrides). `all`, `approve all`, + * `accept all` and `take all recommendations` are silent aliases of `rec`; + * `as-is` (alias `all as-is`) keeps every direction: each item takes its + * option that does not change the plan (`changes_plan: false`). Bare yes/no + * is ambiguous across multi-option items and is reported as unparsed, never + * guessed. A reply binds to the gate revision it answers (`gate_rev`), and a + * `rec` reply to the recommendations it saw (`rec_hash`, bumped by + * `gstack-autoplan recommend`). Earlier answers at the same revision + * (`prior`) are kept unless a token names the item again: only unresolved + * items default. * Rendered by bin/gstack-gate; rows land in decisions.jsonl (lib/headless-artifacts.ts). */ +import { createHash } from 'node:crypto'; import type { DecisionRow } from './headless-artifacts'; /** `changes_plan`: choosing this option is an accepted plan change (reopens its phase plus Eng, prints APPLY:). */ @@ -24,8 +27,10 @@ export interface GateItem { phase?: string; cost?: string; why?: string; /** false: an agent decision; no option reopens a phase. */ reopens?: boolean; - /** Why `recommended` (e.g. `both voices; remedies shown`, `plan direction; voices disagree`). */ + /** Why `recommended` (e.g. `both voices; shared proposal`, `plan direction; voices disagree`). */ recommended_basis?: string; changes?: string; + /** Set when the relaying parent chose this recommendation (`gstack-autoplan recommend`); shown in the render. */ + recommended_by?: string; /** Stable carry-forward identity (phase, finding ids, phase close, snapshot, option text). */ origin?: string; /** Advisory lines rendered under the item (PLAN_REALITY: a plan term with no number, arm list or rule body). */ @@ -33,15 +38,21 @@ export interface GateItem { /** Answered at an earlier revision on an unchanged proposal from a phase that did not rerun. */ carried?: { from: string; chosen: string; gate_rev: number }; } -export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: 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`. */ + rec_rev?: number } export interface GateChoice { id: string; chosen: string; kind: 'auto' | 'approval'; how: 'recommended' | 'override' | 'auto_default'; prior?: boolean } export interface ParsedReply { valid: boolean; gate_rev: number; reply: string; choices: GateChoice[]; pending: string[]; unparsed: string[]; + /** `rec`: the reply took the recommendations (any alias, with or without `except`). */ + mode?: 'rec' | 'as-is' | 'tokens'; error?: 'GATE_REV_STALE' | 'GATE_REPLY_UNPARSED' | 'GATE_EMPTY'; } export const PAGE_SIZE = 7; +/** Reply openers that take every recommendation: `rec` and its silent aliases. */ +const REC_ALIASES: string[][] = [['rec'], ['all'], ['approve', 'all'], ['accept', 'all'], ['take', 'all', 'recommendations']]; const ID_RE = /^[a-z]*[0-9]+$/; export function validateGateList(list: unknown): string[] { @@ -70,25 +81,17 @@ export function validateGateList(list: unknown): string[] { return problems; } -/** - * An approval item whose recommended option changes the plan (both voices - * recommend the change) is answered only by its own token: `all` must never - * flip the owner's stated direction on their behalf (field gate rev 1, d3). - */ -export function heldFromAll(item: GateItem): boolean { - return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true); +/** The recommendations a `rec` reply takes, as a short hash the render prints and `answer --rec-hash` checks. */ +export function recHash(list: GateList): string { + const recs = list.items.map(i => [i.id, i.recommended, i.recommended_basis ?? '', i.recommended_by ?? '']); + return createHash('sha256').update(JSON.stringify([list.gate_rev, list.rec_rev ?? 0, recs])).digest('hex').slice(0, 12); } -/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */ -export function resolveAll(list: GateList): Record<string, string> { - return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended])); -} - -/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */ +/** The `GATE_PENDING` hint: exact tokens per pending item, or `rec`. */ export function pendingHint(list: GateList, pending: string[]): string { const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i); const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' '); - return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`; + return `reply with ${tokens || 'p1a..p1e'} or rec`; } export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string { @@ -100,7 +103,7 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri lines.push(''); list.items.slice(start, start + pageSize).forEach((item, i) => { const n = start + i + 1; - lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); + lines.push(`${n}. [${item.id}] ${item.title} (${item.kind}; recommended: ${item.recommended}${item.recommended_basis ? ` (${item.recommended_basis}${item.recommended_by ? `; set by ${item.recommended_by}` : ''})` : ''}${item.phase ? `; from ${item.phase}` : ''})`); if (item.why) lines.push(` ${item.why}`); if (item.carried) lines.push(` decided at gate_rev ${item.carried.gate_rev}: ${item.id}${item.carried.chosen} (carried forward; name it again to change it)`); for (const o of item.options) lines.push(` ${item.id}${o.key}) ${o.text}${o.key === item.recommended ? ' (recommended)' : ''}`); @@ -109,10 +112,10 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri }); lines.push(''); if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`, or every page with \`--all\`. Relay every page.`); - lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).'); + lines.push('Reply with exactly one of: `rec` (default: take every recommendation, plan changes included) · `rec except <tokens>` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `as-is` (the plan as written).'); lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.'); - for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`); - lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`); + const rec = parseReply('rec', list); + lines.push(`Reply binds to gate_rev=${list.gate_rev} rec_hash=${recHash(list)}. \`rec\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, rec_hash: recHash(list), choices: Object.fromEntries(rec.choices.map(c => [c.id, c.chosen])) })}`); return lines.join('\n') + '\n'; } @@ -122,7 +125,7 @@ function exampleTokens(list: GateList): string { /** Tokenize a reply: lowercase, strip separators, merge `<id> <option>` pairs into one token. */ export function tokenize(reply: string, list: GateList): string[] { - const raw = reply.toLowerCase().replace(/[,;:=)]/g, ' ').split(/\s+/).filter(Boolean); + const raw = reply.toLowerCase().replace(/[,;:=)`"'\u2018\u2019\u201c\u201d]/g, ' ').split(/\s+/).filter(Boolean); const ids = new Set(list.items.map(i => i.id)); const out: string[] = []; for (let i = 0; i < raw.length; i++) { @@ -145,13 +148,14 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio const tokens = tokenize(reply, list); if (tokens.length === 0) return { ...base, error: 'GATE_EMPTY' }; - let all = false; + let rec = false; let asIs = false; let rest = tokens; - if (tokens[0] === 'all') { - all = true; - rest = tokens.slice(1); - if (rest[0] === 'as-is') { asIs = true; rest = rest.slice(1); } + const lead = REC_ALIASES.find(a => a.every((w, i) => tokens[i] === w)); + if (lead && tokens[lead.length] === 'as-is' && lead.length === 1 && lead[0] === 'all') { asIs = true; rest = tokens.slice(2); } + else if (lead) { rec = true; rest = tokens.slice(lead.length); } + else if (tokens[0] === 'as-is') { asIs = true; rest = tokens.slice(1); } + if (rec || asIs) { if (rest[0] === 'except') rest = rest.slice(1); else if (rest.length > 0) return { ...base, unparsed: rest, error: 'GATE_REPLY_UNPARSED' }; } @@ -176,11 +180,11 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) }); else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true }); else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); } - else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); + else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' }); else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' }); else pending.push(item.id); } - return { ...base, valid: true, choices, pending }; + return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' }; } /** The option that keeps the plan's direction: the first with `changes_plan: false`; plan approval `a`; else the recommendation. */ @@ -208,14 +212,14 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}): /** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */ export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/; -export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] { +export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string, extra: Partial<DecisionRow> = {}): DecisionRow[] { if (!parsed.valid) return rows; const byId = new Map(parsed.choices.map(c => [c.id, c])); return rows.map(row => { const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, '')); if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row; return { - ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), + ...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}), ...extra, status: choice.how === 'override' ? 'overridden' : 'approved', }; }); diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 846cddaad..90ac727ee 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -23,7 +23,7 @@ export type DecisionKind = 'auto' | 'approval' | 'user_challenge'; export type DecisionStatus = 'pending' | 'approved' | 'overridden' | 'rejected'; export type TaskStatus = 'pending' | 'ready' | 'blocked_on_dependency' | 'blocked_on_decision' | 'in_progress' | 'done'; export type VoiceStatus = 'completed' | 'unavailable' | 'disabled' | 'skipped' | 'failed' | 'pending' | 'running'; -export type ResultStatus = 'complete' | 'gate_pending' | 'incomplete' | 'interrupted' | 'refused'; +export type ResultStatus = 'complete' | 'gate_pending' | 'amend_pending' | 'incomplete' | 'interrupted' | 'refused'; export type RunStatus = ResultStatus | 'awaiting_result' | 'running'; export interface FindingRow { @@ -39,6 +39,8 @@ export interface DecisionRow { answered_by?: string; source?: string; cost?: string; /** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */ recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string; + /** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */ + owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string; } export interface TaskRow { schema_version: 2; run: string; id: string; title: string; status: TaskStatus; depends_on: string[]; @@ -84,8 +86,8 @@ export interface ShipReceipt { issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> }; } -export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused']; -const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'refused', 'incomplete']; +export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; +const TERMINAL_RUN: readonly string[] = ['complete', 'gate_pending', 'amend_pending', 'refused', 'incomplete']; const TERMINAL_VOICE: readonly string[] = ['completed', 'unavailable', 'disabled', 'skipped', 'failed']; const RESOLVED: readonly string[] = ['accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed']; @@ -131,7 +133,7 @@ export const ARTIFACT_SCHEMAS = { voice: { enum: ['native', 'outside'] }, model: str, severity: { enum: ['Critical', 'High', 'Medium', 'Low', 'Informational'] }, title: str, file: str, line: { type: 'integer' }, disposition: { enum: ['open', 'accepted', 'partially_accepted', 'rejected', 'deferred', 'fixed'] }, resolution: str, plan_items: strList, native_counterpart: nullableStr, source: str, - changes: { enum: ['scope', 'cost', 'direction'] }, + changes: { enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }, }, }, decisions: { @@ -169,7 +171,7 @@ export const ARTIFACT_SCHEMAS = { required: ['schema_version', 'run', 'status', 'required_phases', 'phases', 'artifacts'], properties: { schema_version: { const: 1 }, run: str, - status: { enum: ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, + status: { enum: ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused', 'awaiting_result', 'running'] }, base: str, host: str, session_kind: str, required_phases: strList, phases: { type: 'object', description: 'phase id → { snapshot, native, outside }', properties: {} }, counts: { type: 'object', description: 'decisions, decisions_pending, findings, tasks: each must equal the rows in its file' }, diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 7a600bc97..3877c4b97 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -509,13 +509,33 @@ export const RESULT_CODES = { PLAN_AMEND_PENDING: { anchor: 'plan-amend-pending', summary: 'the owner accepted plan changes at the gate that the plan has not absorbed yet', - fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id,...>`; settle a packaging or ordering change with `gstack-autoplan revise --out <dir> --settle <id>`; or drop one with `gstack-autoplan revise --out <dir> --withdraw <decision id>`', }, AMENDMENT_UNKNOWN: { anchor: 'amendment-unknown', summary: '`revise --incorporates` or `--withdraw` named a decision that is not a pending amendment (never accepted as a plan change, or already incorporated or withdrawn)', fix: 'name only the ids the refusal lists after `pending:`; with none pending, drop --incorporates and run `gstack-autoplan revise --out <dir> --plan <file>`', }, + AMENDMENT_NOT_SETTLEABLE: { + anchor: 'amendment-not-settleable', + summary: '`revise --settle` named an accepted change that needs a plan edit (its item changes scope, cost or direction, or names no packaging/ordering change), or the reviewed plan changed since the gate', + fix: 'edit the plan, then `gstack-autoplan revise --out <dir> --plan <file> --incorporates <decision id>`; settle only packaging or ordering changes on an unchanged plan', + }, + GATE_REC_STALE: { + anchor: 'gate-rec-stale', + summary: 'a `rec` reply was rendered before `gstack-autoplan recommend` changed the recommendations it takes', + fix: 'render the gate again (`gstack-gate render <dir>/gate.json --all`), relay it, and answer with the printed `--rec-hash`', + }, + OWNER_REPLY_REQUIRED: { + anchor: 'owner-reply-required', + summary: 'an agent answered the gate (`--by agent:<id>`) without the owner\'s verbatim reply', + fix: 'pass the owner\'s words exactly with `--owner-reply "<text>"`, `--owner-reply -` (stdin) or `--owner-reply-file <path>`', + }, + GATE_OWNER_REPLY_MISMATCH: { + anchor: 'gate-owner-reply-mismatch', + summary: 'the owner\'s verbatim reply parses under the gate grammar but resolves to different choices than the relayed tokens', + fix: 'relay exactly what the owner chose (or their reply itself, e.g. `--reply rec`); ask the owner when their words and the tokens disagree', + }, GSTACK_VERSION_CHANGED: { anchor: 'gstack-version-changed', summary: 'the run directory was written by a different gstack version', diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index 8266ea5b1..973080e73 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -22,24 +22,26 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' }, { cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' }, - { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>"', does: 'answer the gate with the reply grammar below' }, + { cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' }, + { cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' }, { cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' }, + { cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' }, { cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' }, { cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' }, - { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest; take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, + { cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, ]; /** The reply grammar `gstack-gate` parses, one form per line. */ export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [ - { reply: 'all', means: 'every item takes its recommendation (an agreed change prints APPLY: and needs revise)' }, - { reply: 'all as-is', means: 'keep every direction; no plan change' }, + { reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` is an alias' }, + { reply: 'as-is', means: 'keep every direction; no plan change' }, { reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' }, - { reply: 'all except d3b', means: 'recommendations, with the listed overrides' }, + { reply: 'rec except d3b', means: 'recommendations, with the listed overrides' }, ]; /** Result codes the loop can print, in the order a run meets them. */ export const DRIVER_CODES = ['RUN_NOT_INITIALIZED', 'PLAN_SECTIONS_INVALID', 'RUN_LOCKED', 'EXECUTION_UNKNOWN', 'SPEND_CAP_EXCEEDED', 'PHASE_NOT_AWAITING', 'ATTEMPT_MISMATCH', - 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'PLAN_AMEND_PENDING', 'GSTACK_VERSION_CHANGED'] as const; + 'RESULT_RECEIPT_MISSING', 'RESULT_FINDINGS_MISSING', 'MODEL_FAMILY_CONFLICT', 'CONSENSUS_MISSING', 'GATE_REV_STALE', 'GATE_REPLY_UNPARSED', 'GATE_REC_STALE', 'OWNER_REPLY_REQUIRED', 'GATE_OWNER_REPLY_MISMATCH', 'PLAN_AMEND_PENDING', 'AMENDMENT_NOT_SETTLEABLE', 'GSTACK_VERSION_CHANGED'] as const; /** Fix text for codes the plan specifies and lane B-core adds to lib/result-codes.ts; the table's own fix wins once it exists. */ const PENDING_FIX: Record<string, string> = { PLAN_SECTIONS_INVALID: 'the plan needs one `## Implementation plan` and one `## Review record`; rerun `next --scaffold` to wrap a plan that has neither', diff --git a/test/autoplan-answer-invalidation.test.ts b/test/autoplan-answer-invalidation.test.ts index 0b14209a7..9fd46a63d 100644 --- a/test/autoplan-answer-invalidation.test.ts +++ b/test/autoplan-answer-invalidation.test.ts @@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, journal, readRun, resultOf, review, ROOT, runPhase, SMALL_PLAN, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -31,8 +31,8 @@ function gatedRun(name: string): string { review(path.join(out, 'ceo-native-prompt.md')); review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: disagree }); const ids = attemptsOf(out, 'ceo'); - expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); - const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'gpt-5.5', '--attempt', ids.outside]); + expect(autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]).status).toBe(0); + const close = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'gpt-5.5', '--attempt', ids.outside]); expect(close.stdout).toMatch(/PHASE_CLOSED: ceo confirmed=1 disagree=1 new=1/); const eng = runPhase(out, 'eng'); expect(eng.outside.stdout).toContain('status=gate_pending'); @@ -112,26 +112,31 @@ describe('answer', () => { expect(readRun(out).attempt_counter).toBe(counter); }); - test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => { + test('a non-recommended choice on a ceo item is recorded without reopening; revise reopens the changed phases, archives their bound files, and the next gate is gate_rev 2', () => { const out = gatedRun('reopen'); const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']); expect(r.status, r.stderr).toBe(0); const runId = readRun(out).run; - expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] }); + expect(JSON.parse(r.stdout)).toMatchObject({ status: 'amend_pending', outcome: 'amend_pending', reopened: [], amendments: [`${runId}-d1`] }); + expect(readRun(out).phases.ceo.closed_at).toBeTruthy(); + expect(journal(out).filter(j => j.event === 'reopened')).toEqual([]); + // the accepted change is applied to the plan and named; revise reopens the phases that reviewed the old plan, once + const revised = incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.'); + expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); const run = readRun(out); expect(run.phases.ceo).toEqual({}); expect(run.phases.eng).toEqual({}); expect(run.phases.design.skipped).toBeTruthy(); expect(run.history.map((h: any) => [h.phase, h.gate_rev])).toEqual([['ceo', 1], ['eng', 1]]); - for (const f of ['ceo-native.r1.md', 'ceo-outside.r1.md', 'ceo-consensus.r1.json', 'eng-native.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); - for (const f of ['ceo-native.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); - expect(Object.keys(run.artifacts)).not.toContain('ceo-native.md'); + expect(run.status).toBe('running'); + for (const f of ['ceo-native-prompt.r1.md', 'ceo-outside-prompt.r1.md', 'ceo-consensus.r1.json', 'eng-native-prompt.r1.md', 'gate.r1.json']) expect(fs.existsSync(path.join(out, f))).toBe(true); + for (const f of ['ceo-native-prompt.md', 'eng-consensus.md', 'gate.json']) expect(fs.existsSync(path.join(out, f))).toBe(false); + // sealed copies are attempt-owned: they stay in place and bound across the reopen + const sealed = run.history.flatMap((h: any) => ['native', 'outside'].map(v => h.previous[v].output)); + expect(sealed.every((o: string) => /\.a\d+\.bound\.md$/.test(o) && fs.existsSync(path.join(out, o)) && run.artifacts[o])).toBe(true); const d1 = decisions(out).find((d: any) => d.label === 'D1'); expect(d1).toMatchObject({ status: 'overridden', chosen: 'b', gate_rev: 1 }); expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']); - // the accepted change is applied to the plan and named before any phase reruns; the reopened phases stay reopened once - incorporate(out, [`${runId}-d1`], 'Gate reuse also checks the lockfile hash.'); - expect(journal(out).filter(j => j.event === 'reopened').map(j => j.phase)).toEqual(['ceo', 'eng']); // the loop continues: ceo first, then eng, then a new gate at gate_rev 2 whose rows join the old ones const ceo = runPhase(out, 'ceo'); expect(ceo.next.stdout).toMatch(/^PHASE: ceo /m); @@ -160,16 +165,16 @@ describe('answer', () => { expect(valid.status).toBe(0); }); - test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { + test('an eng-only override reopens nothing at answer; revise reruns every phase that reviewed the old plan; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => { const out = gatedRun('eng-only'); const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']); const runId = readRun(out).run; - expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] }); + expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], amendments: [`${runId}-uc2`] }); expect(readRun(out).phases.ceo.closed_at).toBeTruthy(); - // the revised plan changes the hash ceo reviewed, so revise (not the answer) reruns ceo on v2 before eng + // the revised plan changes the hash both phases reviewed, so revise (not the answer) reruns ceo, then eng, on v2 const revised = incorporate(out, [`${runId}-uc2`], 'Retries stop at the dead-letter queue.'); - expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo$/m); - expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['eng', expect.anything()], ['ceo', expect.stringContaining('revise')]]); + expect(revised.stdout).toMatch(/^REVISED: plan=\S+ reopened=ceo,eng$/m); + expect(journal(out).filter(j => j.event === 'reopened').map(j => [j.phase, j.reason ?? null])).toEqual([['ceo', expect.stringContaining('revise')], ['eng', expect.stringContaining('revise')]]); expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/); const eng = runPhase(out, 'eng'); expect(eng.outside.stdout).toContain('status=gate_pending'); diff --git a/test/autoplan-capy-e2e.test.ts b/test/autoplan-capy-e2e.test.ts index 0c174d1f6..f559646b8 100644 --- a/test/autoplan-capy-e2e.test.ts +++ b/test/autoplan-capy-e2e.test.ts @@ -12,7 +12,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; -import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan, type CapyRunner } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -138,14 +138,14 @@ describe('conformance: one run through every mechanism', () => { const run = readRunJson(s.out).run; // ceo: native canonical (fixture), outside attested record ok(c.review(path.join(s.out, 'ceo-native-prompt.md'))); - ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(s.out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(s.out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5'])); const hand = path.join(s.dir, 'ceo-outside-hand.md'); fs.writeFileSync(hand, '| Severity | Title |\n|---|---|\n| Medium | Retry storage cost is unpriced |\n'); ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', 'outside', '--record', hand, '--model', 'openai/gpt-6.1-sol', '--attest', 'Capy subagent', '--attempt', readRunJson(s.out).phases.ceo.outside.attempt])); // eng: native challenge with changes, outside unavailable ok(c.ap(['next', '--out', s.out])); ok(c.review(path.join(s.out, 'eng-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'High', title: 'Gate the backoff behind a flag', user_challenge: true, changes: 'scope', fix: 'RETRY_BACKOFF=1' }]) })); - ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', path.join(s.out, 'eng-native.md'), '--model', 'anthropic/claude-opus-5-5'])); + ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'native', '--result', resultOf(s.out, 'eng', 'native'), '--model', 'anthropic/claude-opus-5-5'])); expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending'); const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id; // partial, then full @@ -166,7 +166,7 @@ describe('conformance: one run through every mechanism', () => { const unknown = [...resumed.stdout.matchAll(/^EXECUTION_UNKNOWN: (\S+)$/gm)].map(m => m[1]!); ok(c.ap(['next', '--out', s.out, ...unknown.flatMap(a => ['--redispatch', a])])); for (const voice of ['native', 'outside']) ok(c.review(path.join(s.out, `ceo-${voice}-prompt.md`))); - for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', path.join(s.out, `ceo-${voice}.md`), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol'])); + for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', resultOf(s.out, 'ceo', voice as 'native' | 'outside'), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol'])); phaseVia(c, s.out, 'eng'); expect(readRunJson(s.out).gate_rev).toBe(2); ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d'])); @@ -181,6 +181,6 @@ describe('conformance: one run through every mechanism', () => { expect(final.gate_rev).toBe(3); expectUniqueIds(s.out); expectReviewedHashes(s.out); - expect(fs.readFileSync(path.join(s.out, `ceo-native.md`), 'utf8')).toContain(final.phases.eng.snapshot); + expect(fs.readFileSync(boundOf(s.out, 'ceo', 'native'), 'utf8')).toContain(final.phases.eng.snapshot); }); }); diff --git a/test/autoplan-driver.test.ts b/test/autoplan-driver.test.ts index 6131fd7b0..40adbad48 100644 --- a/test/autoplan-driver.test.ts +++ b/test/autoplan-driver.test.ts @@ -42,7 +42,7 @@ describe('autoplan/DRIVER.md content comes from the code', () => { test('the plan-specified commands and the amendment interlock are present', () => { for (const token of ['submit --out <dir> --phase <p> --voice native|outside --record', '--attest "<reason>"', '--unavailable|--failed', - 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`all as-is`', 'PLAN_AMEND_PENDING', 'APPLY:']) { + 'revise --out <dir> --plan <plan v2> --incorporates <decision ids>', '--withdraw <decision id>', '[--scaffold]', '`--no-dx`', '`as-is`', '`rec`', 'render <dir>/gate.json --all', '--settle <decision id|d4>', '--owner-reply', 'PLAN_AMEND_PENDING', 'APPLY:']) { expect(doc).toContain(token); } }); diff --git a/test/autoplan-gate-judgment.test.ts b/test/autoplan-gate-judgment.test.ts index 0baf3ba19..9c147f7ea 100644 --- a/test/autoplan-gate-judgment.test.ts +++ b/test/autoplan-gate-judgment.test.ts @@ -1,24 +1,23 @@ /** - * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments): - * findings carry `changes`; a user challenge both voices raised on a confirmed - * finding with the same `changes` and both remedies recommends `b`, rendering - * both remedies verbatim and the basis; anything weaker keeps `a` with its - * basis. Reopen and APPLY key on the chosen option's `changes_plan`; accepted - * changes are pending amendments that `next` refuses past (PLAN_AMEND_PENDING) - * until `revise --incorporates` binds them to a changed plan or `--withdraw` - * drops them. `all as-is` keeps every direction; `all` leaves an item - * recommended `b` pending (the owner's own token changes their direction; - * field gate rev 1, d3) and the GATE_PENDING line names the tokens. Partial - * replies resolve - * against persisted decisions. On reruns a challenge without `changes` is an - * agent decision that reopens nothing, rerun prompts carry the decided items, - * and answered items from phases that did not rerun carry forward. + * Gate judgment (plan B2a/B2b, items 8 and 10, with the B-core amendments and + * next-wave B1/B2/B5): findings carry `changes`; a user challenge both voices + * raised on a confirmed finding with the same `changes` recommends `b` only + * with one shared proposal (the parent's override, or the same remedy from + * both), rendering both remedies verbatim and the basis; anything weaker keeps + * `a` with its basis. APPLY keys on the chosen option's `changes_plan`; + * accepted changes are pending amendments (status amend_pending, nothing + * reopens at answer) that `next` refuses past (PLAN_AMEND_PENDING) until + * `revise --incorporates` binds them to a changed plan or `--withdraw` drops + * them. `as-is` keeps every direction; `rec` (alias `all`) takes every + * recommendation. Partial replies resolve against persisted decisions. On + * reruns a challenge without `changes` is an agent decision that reopens + * nothing, and rerun prompts carry the decided items. */ 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 { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture'; +import { ROOT, SMALL_PLAN, boundOf, resultOf } from './helpers/autoplan-run-fixture'; import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; const r = capyRunner(); @@ -33,6 +32,14 @@ const DISAGREE = { native: [{ id: 'F1', severity: 'Critical', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 10, user_challenge: true, changes: 'scope', fix: 'new table' }], outside: [{ id: 'O1', severity: 'Low', title: 'Persist attempts in a separate table', file: 'lib/worker.ts', line: 11, user_challenge: true, changes: 'scope', fix: 'keep the column' }], }; +const SAME = { + native: [{ id: 'F4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'Cap stored attempts at 50 per tenant.' }], + outside: [{ id: 'O4', severity: 'High', title: 'Cap the retry storage per tenant', user_challenge: true, changes: 'scope', fix: 'cap stored attempts at 50 per tenant' }], +}; +const PACKAGING = { + native: [{ id: 'F5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'land the schedule change as its own PR' }], + outside: [{ id: 'O5', severity: 'Medium', title: 'Split the backoff into its own PR', user_challenge: true, changes: 'packaging', fix: 'Land the schedule change as its own PR.' }], +}; const MISMATCH = { native: [{ id: 'F1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'scope', fix: 'remove jitter' }], outside: [{ id: 'O1', severity: 'Medium', title: 'Drop the jitter requirement', user_challenge: true, changes: 'cost', fix: 'remove jitter' }], @@ -54,34 +61,37 @@ const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md` describe('changes and user-challenge defaults', () => { test('`changes` reaches findings.jsonl, the schema and decisions; an unknown value is refused', () => { const g = gated(AGREE, {}); - expect(readJsonl(path.join(g.out, 'ceo-native.md.findings.jsonl'))[0]).toMatchObject({ changes: 'scope', user_challenge: true }); + expect(readJsonl(`${boundOf(g.out, 'ceo', 'native')}.findings.jsonl`)[0]).toMatchObject({ changes: 'scope', user_challenge: true }); const schema = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'schema', 'findings'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); - expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction'] }); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'b', recommended_basis: 'both voices; remedies shown' }); + expect(JSON.parse(schema.stdout).properties.changes).toEqual({ enum: ['scope', 'cost', 'direction', 'packaging', 'ordering'] }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ changes: 'scope', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); const dir = path.join(r.host.base, 'badchanges'); const out = path.join(dir, 'run'); expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0); - expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'packaging' }]) }).status).toBe(0); - const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ severity: 'Low', title: 'x', user_challenge: true, changes: 'naming' }]) }).status).toBe(0); + const bad = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(bad.status).toBe(1); - expect(bad.stderr).toMatch(/changes "packaging" is not one of scope\|cost\|direction.*\(RESULT_FINDINGS_MISSING\)$/m); + expect(bad.stderr).toMatch(/changes "naming" is not one of scope\|cost\|direction\|packaging\|ordering.*\(RESULT_FINDINGS_MISSING\)$/m); }); - test('agreeing voices recommend b with both remedies verbatim; disagreement and mismatched changes keep a with the basis', () => { - const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' }))], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' }))] }, DISAGREE); + test('one shared remedy recommends b; different remedies show both verbatim and keep a; disagreement and mismatched changes keep a with the basis', () => { + const g = gated({ native: [...AGREE.native, ...MISMATCH.native.map(f => ({ ...f, id: 'F2' })), ...SAME.native], outside: [...AGREE.outside, ...MISMATCH.outside.map(f => ({ ...f, id: 'O2' })), ...SAME.outside] }, DISAGREE); const items = gate(g.out).items; + const same = items.find((i: any) => i.title.startsWith('Cap the retry storage')); + expect(same).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; same remedy' }); const agree = items.find((i: any) => i.title.startsWith('Ship the backoff')); - expect(agree).toMatchObject({ kind: 'approval', recommended: 'b', recommended_basis: 'both voices; remedies shown' }); + expect(agree).toMatchObject({ kind: 'approval', recommended: 'a', recommended_basis: 'plan direction; remedies differ; no shared proposal' }); expect(agree.options.find((o: any) => o.key === 'b')).toMatchObject({ changes_plan: true }); expect(agree.options.find((o: any) => o.key === 'b').text).toContain('native: gate the change behind RETRY_BACKOFF=1; outside: add a kill switch flag for the new schedule'); expect(agree.options.find((o: any) => o.key === 'a').changes_plan).toBe(false); expect(items.find((i: any) => i.title.startsWith('Drop the jitter'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices name different changes' }); expect(items.find((i: any) => i.title.startsWith('Persist attempts'))).toMatchObject({ recommended: 'a', recommended_basis: 'plan direction; voices disagree' }); const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(g.out, 'gate.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); - expect(render.stdout).toMatch(new RegExp(`\\[${agree.id}\\] .*recommended: b \\(both voices; remedies shown\\)`)); + expect(render.stdout).toMatch(new RegExp(`\\[${same.id}\\] .*recommended: b \\(both voices; same remedy\\)`)); expect(render.stdout).toMatch(/recommended: a \(plan direction; voices disagree\)/); const p1 = items.find((i: any) => i.id === 'p1'); - expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${agree.id}b`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).toContain(`${same.id}b`); + expect(p1.options.find((o: any) => o.key === p1.recommended).text).not.toContain(`${agree.id}b`); }); test('a parent reconcile override naming one shared proposal renders that proposal as option b', () => { @@ -91,10 +101,10 @@ describe('changes and user-challenge defaults', () => { r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.native) }); r.review(path.join(out, 'ceo-outside-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify(AGREE.outside) }); const run = readRunJson(out); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); const overrides = path.join(dir, 'reconcile.jsonl'); fs.writeFileSync(overrides, JSON.stringify({ outside_id: `${run.run}-ceo-outside-1`, native_id: `${run.run}-ceo-native-1`, disposition: 'confirmed', resolution: 'same change', proposal: 'ship behind RETRY_BACKOFF=1, default off' }) + '\n'); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6.1-sol', '--reconcile', overrides]).status).toBe(0); phaseVia(r, out, 'eng', {}); const uc = gate(out).items.find((i: any) => i.title.startsWith('Ship the backoff')); expect(uc).toMatchObject({ recommended: 'b', recommended_basis: 'both voices; shared proposal' }); @@ -103,23 +113,27 @@ describe('changes and user-challenge defaults', () => { }); describe('accepted changes are amendments', () => { - test('`all` leaves the agreeing change pending; its own token accepts it: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => { + test('an accepted change prints APPLY and NEXT revise and leaves amend_pending without reopening; next refuses until revise --incorporates binds it; revise reports what reopened', () => { const g = gated(AGREE, {}); - const held = answer(g.out, 1, 'all'); + const held = answer(g.out, 1, 'uc1b'); expect(held.status, held.stderr).toBe(0); - expect(held.stdout).toMatch(/^GATE_PENDING: uc1 unanswered \(reply with uc1a\|uc1b; all does not answer items recommended b\)$/m); + expect(held.stdout).toMatch(/^GATE_PENDING: uc2, p1 unanswered \(reply with uc2a\|uc2b p1a\|p1b\|p1c\|p1d\|p1e or rec\)$/m); expect(held.stdout).not.toMatch(/^APPLY:/m); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ status: 'pending' }); - const res = answer(g.out, 1, 'uc1b'); + const res = answer(g.out, 1, 'rec'); expect(res.status, res.stderr).toBe(0); - expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend`, 'm')); - expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=uc1b$/m); - expect(res.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); - expect(res.stdout).not.toMatch(/^NEXT: gstack-autoplan next/m); - expect(readRunJson(g.out).amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending' })]); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend.*; edit the plan, then revise$`, 'm')); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(res.stdout).toMatch(new RegExp(`^NEXT: edit the plan, then gstack-autoplan revise --out ${esc(g.out)} --plan ${esc(g.plan)} --incorporates ${esc(g.run)}-uc1$`, 'm')); + expect(res.stdout).toContain('status=amend_pending'); + const before = readRunJson(g.out); + expect(before.status).toBe('amend_pending'); + expect(before.phases.ceo.closed_at).toBeTruthy(); + expect(before.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'pending', item: 'uc1', changes: 'scope' })]); + expect(r.ap(['status', '--out', g.out]).stdout).toMatch(new RegExp(`^AMEND_PENDING: ${esc(g.run)}-uc1 uc1 changes=scope -> edit the plan, then revise$`, 'm')); + expect(r.validate(g.out).status).toBe(0); const blocked = r.ap(['next', '--out', g.out]); expect(blocked.status).toBe(3); - expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm')); + expect(blocked.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1 \\(APPLY uc1: edit the plan, then revise\\).*\\(PLAN_AMEND_PENDING\\)$`, 'm')); const runFile = path.join(g.out, 'run.json'); const saved = fs.readFileSync(runFile, 'utf8'); fs.writeFileSync(runFile, JSON.stringify({ ...JSON.parse(saved), status: 'complete' }, null, 2) + '\n'); @@ -127,35 +141,66 @@ describe('accepted changes are amendments', () => { expect(exported.status).toBe(3); expect(exported.stderr).toMatch(new RegExp(`${esc(g.run)}-uc1.*\\(PLAN_AMEND_PENDING\\)$`, 'm')); fs.writeFileSync(runFile, saved); - const unrelated = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s'))]); - expect(unrelated.status, unrelated.stderr).toBe(0); - expect(unrelated.stdout).toMatch(new RegExp(`^AMENDMENTS: pending=${esc(g.run)}-uc1$`, 'm')); - expect(r.ap(['next', '--out', g.out]).status).toBe(3); - const unchanged = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s')), '--incorporates', `${g.run}-uc1`]); + const unchanged = r.ap(['revise', '--out', g.out, '--plan', g.plan, '--incorporates', `${g.run}-uc1`]); expect(unchanged.status).toBe(3); expect(unchanged.stderr).toMatch(/did not change.*\(PLAN_AMEND_PENDING\)$/m); - const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 45 s, behind RETRY_BACKOFF=1')), '--incorporates', `${g.run}-uc1`]); + const bound = r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc1']); expect(bound.status, bound.stderr).toBe(0); + expect(bound.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=ceo,eng$/m); expect(bound.stdout).toMatch(/^AMENDMENTS: pending=none$/m); + expect(bound.stdout).toMatch(new RegExp(`^NEXT: gstack-autoplan next --out ${esc(g.out)}$`, 'm')); const j = readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_incorporated'); expect(j).toMatchObject({ decision: `${g.run}-uc1` }); expect(j.old_plan_sha256).not.toBe(j.new_plan_sha256); + expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'plan_revised')).toMatchObject({ reopened: ['ceo', 'eng'] }); + expect(readRunJson(g.out).status).toBe('running'); expect(r.ap(['next', '--out', g.out]).status).toBe(0); }); - test('`all as-is` keeps every direction; --withdraw is a journaled owner decision', () => { + test('`as-is` keeps every direction; --withdraw is a journaled owner decision that completes the run when nothing else is pending', () => { const g = gated(AGREE, {}); - const asIs = answer(g.out, 1, 'all as-is'); + const asIs = answer(g.out, 1, 'as-is'); expect(asIs.status, asIs.stderr).toBe(0); expect(asIs.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m); expect(asIs.stdout).not.toMatch(/^APPLY:/m); - expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'overridden' }); + expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'approved' }); const w = gated(AGREE, {}); - expect(answer(w.out, 1, 'all except uc1b').stdout).toMatch(/^APPLY:/m); + expect(answer(w.out, 1, 'rec except uc1b').stdout).toMatch(/^APPLY:/m); const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]); expect(withdraw.status, withdraw.stderr).toBe(0); + expect(withdraw.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); expect(readJsonl(path.join(w.out, 'attempts.jsonl')).find(e => e.event === 'amendment_withdrawn')).toMatchObject({ decision: `${w.run}-uc1` }); - expect(r.ap(['next', '--out', w.out]).status).toBe(0); + expect(readRunJson(w.out).status).toBe('complete'); + expect(r.validate(w.out).status).toBe(0); + }); +}); + +describe('record-only changes settle without a plan edit', () => { + test('a packaging change: APPLY says settle, revise --settle reopens nothing and completes, export and validate pass; a scope change never settles', () => { + const g = gated(PACKAGING, {}); + expect(gate(g.out).items.find((i: any) => i.id === 'uc1')).toMatchObject({ recommended: 'b', changes: 'packaging' }); + const res = answer(g.out, 1, 'rec'); + expect(res.status, res.stderr).toBe(0); + expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 .*; no plan edit needed: revise --settle uc1$`, 'm')); + expect(readRunJson(g.out).status).toBe('amend_pending'); + expect(r.ap(['next', '--out', g.out]).stderr).toMatch(/\(PLAN_AMEND_PENDING\)$/m); + const settled = r.ap(['revise', '--out', g.out, '--settle', 'uc1']); + expect(settled.status, settled.stderr).toBe(0); + expect(settled.stdout).toMatch(new RegExp(`^SETTLED: ${esc(g.run)}-uc1 \\(packaging; plan [0-9a-f]{12} unchanged; nothing reopens\\)$`, 'm')); + expect(settled.stdout).toMatch(/^APPROVED: gate_rev=1 \(every accepted change is in the plan or settled\)$/m); + expect(settled.stdout).not.toMatch(/^REOPENED:/m); + const run = readRunJson(g.out); + expect(run.status).toBe('complete'); + expect(run.amendments).toEqual([expect.objectContaining({ decision: `${g.run}-uc1`, status: 'settled', changes: 'packaging' })]); + expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'amendment_settled')).toMatchObject({ decision: `${g.run}-uc1`, changes: 'packaging' }); + expect(r.ap(['export', '--out', g.out]).status).toBe(0); + expect(r.validate(g.out).status).toBe(0); + const scope = gated(AGREE, {}); + expect(answer(scope.out, 1, 'rec except uc1b').stdout).toMatch(/; edit the plan, then revise$/m); + const refused = r.ap(['revise', '--out', scope.out, '--settle', 'uc1']); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/changes scope; only packaging or ordering changes settle.*\(AMENDMENT_NOT_SETTLEABLE\)$/m); + expect(readRunJson(scope.out).status).toBe('amend_pending'); }); }); @@ -167,7 +212,7 @@ describe('partial replies resolve against persisted decisions', () => { const first = answer(g.out, 1, 'uc1b', true); expect(JSON.parse(first.stdout)).toMatchObject({ outcome: 'gate_pending', pending: ['uc2'] }); const second = answer(g.out, 1, 'all', true); - expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] }); + expect(JSON.parse(second.stdout)).toMatchObject({ outcome: 'amend_pending', reopened: [], status: 'amend_pending' }); expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'b', status: 'overridden' }); }); @@ -184,11 +229,12 @@ describe('partial replies resolve against persisted decisions', () => { }); describe('reruns', () => { - test('rerun prompts carry the decided items, a ceo item answered at rev 1 carries forward, and a rerun challenge without `changes` reopens nothing', () => { + test('rerun prompts carry the decided items, and a rerun challenge without `changes` reopens nothing', () => { const g = gated({}, AGREE); const res = answer(g.out, 1, 'uc1a uc2b p1a'); - expect(res.stdout).toMatch(/^REOPENED: eng \(Eng last\) reason=uc2b$/m); - expect(r.ap(['revise', '--out', g.out, '--withdraw', `${g.run}-uc2`]).status).toBe(0); + expect(res.stdout).not.toMatch(/^REOPENED:/m); + expect(r.ap(['revise', '--out', g.out, '--plan', v(g.dir, SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1')), '--incorporates', 'uc2']).stdout).toMatch(/reopened=ceo,eng/); + phaseVia(r, g.out, 'ceo', {}); const eng = phaseVia(r, g.out, 'eng', { native: [{ id: 'F9', severity: 'Low', title: 'Name the PR branch per lane', user_challenge: true }], outside: [{ id: 'O9', severity: 'Medium', title: 'Price the retry storage before shipping', user_challenge: true, changes: 'cost' }], @@ -198,8 +244,6 @@ describe('reruns', () => { expect(prompt).toMatch(/uc2b .*Ship the backoff behind a flag first/); expect(prompt).toMatch(/uc1a /); expect(eng.outside.stdout).toContain('status=gate_pending'); - const rev2 = decisions(g.out).filter(d => d.gate_rev === 2); - expect(rev2.find(d => d.label === 'UC1')).toMatchObject({ status: 'approved', chosen: 'a', carried_from: `${g.run}-uc1` }); const items = gate(g.out).items; const demoted = items.find((i: any) => i.title.startsWith('Name the PR branch')); expect(demoted).toMatchObject({ kind: 'auto', reopens: false }); diff --git a/test/autoplan-pairing.test.ts b/test/autoplan-pairing.test.ts new file mode 100644 index 000000000..063636bce --- /dev/null +++ b/test/autoplan-pairing.test.ts @@ -0,0 +1,39 @@ +/** + * Pairing negative control (next-wave B2/B-a): finding pairs copied verbatim + * from a real autoplan run. File:line proximity associates findings but never + * confirms them; only a title match or the parent's override does, and `b` + * needs one shared proposal or the same remedy. + */ +import { describe, expect, test } from 'bun:test'; +import fixture from './fixtures/autoplan-pairing/run-20261014-pairs.json'; +import { reconcile } from '../lib/autoplan-reconcile'; +import { challengeDefault } from '../lib/autoplan-gate'; + +const pair = (name: string) => fixture.pairs.find(p => p.name.startsWith(name))! as any; +const challenge = (rows: any[], changes = 'direction') => rows.map(r => ({ ...r, user_challenge: true, changes })); + +describe('pairing negative control from a real run', () => { + test('two findings three lines apart on one file are associated but never confirmed, and never recommend b', () => { + const p = pair('ceo r1 outside-1'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row).toMatchObject({ disposition: 'unverified', basis: 'file_line', native_id: p.native[0].id }); + expect(row!.resolution).toStartWith('same location, agreement unverified (bin/gstack-capy-install:197)'); + const [n] = challenge(p.native), [o] = challenge(p.outside); + expect(challengeDefault(row!, n, o)).toMatchObject({ recommended: 'a', basis: 'plan direction; same location, agreement unverified' }); + }); + + test('a location match with different titles stays unverified until the parent confirms it with one proposal', () => { + const p = pair('dx r2 outside-2'); + const [row] = reconcile(p.native, p.outside).rows; + expect(row!.disposition).toBe('unverified'); + const [confirmed] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'same trailer grammar break', proposal: 'keep `gstack: fail <components>` and specify the pins exit' }]).rows; + expect(challengeDefault(confirmed!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'b', basis: 'both voices; shared proposal' }); + }); + + test('the same problem on different files is new until the parent associates it; different remedies keep a', () => { + const p = pair('dx r2 native-2'); + expect(reconcile(p.native, p.outside).rows[0]).toMatchObject({ disposition: 'new', native_id: null }); + const [row] = reconcile(p.native, p.outside, [{ outside_id: p.outside[0].id, native_id: p.native[0].id, disposition: 'confirmed', resolution: 'both: a recommendation edit changes what a seen reply resolves to' }]).rows; + expect(challengeDefault(row!, challenge(p.native, 'scope')[0], challenge(p.outside, 'scope')[0])).toMatchObject({ recommended: 'a', basis: 'plan direction; remedies differ; no shared proposal' }); + }); +}); diff --git a/test/autoplan-record.test.ts b/test/autoplan-record.test.ts index 9de8316c6..345eef799 100644 --- a/test/autoplan-record.test.ts +++ b/test/autoplan-record.test.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { RESULT_CODES } from '../lib/result-codes'; import { capyRunner, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy'; +import { boundOf, resultOf } from './helpers/autoplan-run-fixture'; const r = capyRunner(); afterAll(() => r.cleanup()); @@ -64,8 +65,8 @@ describe('submit --record', () => { expect(ok.stdout).toMatch(new RegExp(`^BOUND: ${attempt} phase=ceo voice=native .*findings=2 .*receipt=attested`, 'm')); const v = readRunJson(out).phases.ceo.native; expect(v).toMatchObject({ status: 'completed', receipt: 'attested', attest_reason: 'subagent ran from the prompt file', findings_source: 'table' }); - expect(fs.readFileSync(path.join(out, 'ceo-native.md'), 'utf8')).toBe(TABLE); - const rows = readJsonl(path.join(out, 'ceo-native.md.findings.jsonl')); + expect(fs.readFileSync(boundOf(out, 'ceo', 'native'), 'utf8')).toBe(TABLE); + const rows = readJsonl(`${boundOf(out, 'ceo', 'native')}.findings.jsonl`); expect(rows.map(x => [x.severity, x.title, x.file ?? null, x.line ?? null])).toEqual([['High', 'Retry cap is unbounded on restart', 'lib/worker.ts', 10], ['Medium', 'Split persistence change into its own plan', null, null]]); expect(readRunJson(out).deviations.some((d: string) => /attested.*ceo native/.test(d))).toBe(true); }); @@ -107,7 +108,7 @@ describe('submit --record', () => { test('the canonical --result path binds without --attempt when exactly one attempt is open', () => { const out = start(); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + const ok = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(ok.status, ok.stderr).toBe(0); expect(ok.stdout).toMatch(/^BOUND: \S+-a1 phase=ceo voice=native .*findings=3/m); }); @@ -139,14 +140,14 @@ describe('submit --record', () => { expect(resumed.stdout).not.toContain(outsideAttempt); expect(readRunJson(out).phases.ceo.outside.status).toBe('unavailable'); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']); + const close = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']); expect(close.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m); }); test('--failed records a failed outside voice and closes the phase once native is bound', () => { const out = start(); expect(r.review(path.join(out, 'ceo-native-prompt.md')).status).toBe(0); - expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); + expect(r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5']).status).toBe(0); const failed = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--failed', 'runner exited 1']); expect(failed.status, failed.stderr).toBe(0); expect(failed.stdout).toMatch(/^PHASE_CLOSED: ceo .*coverage=native_only/m); diff --git a/test/autoplan-residuals.test.ts b/test/autoplan-residuals.test.ts index 43ee6497b..54d57769f 100644 --- a/test/autoplan-residuals.test.ts +++ b/test/autoplan-residuals.test.ts @@ -1,23 +1,27 @@ /** * Field-wave residuals (gstack fix wave review, gate revs 1–2, 2026-10-12/13): - * R1 `all` never flips the owner's stated direction: a user-challenge item - * whose recommended option changes the plan stays pending under `all` and - * is answered only by its own token; the rendering says which ids `all` - * leaves open. + * R1 (superseded by next-wave B1, owner decision gate_rev 1): `rec` and its + * silent alias `all` take every recommendation, plan changes included; + * `as-is` keeps every direction; the render prints the `rec` resolution + * from the parser that applies it. * R2 every later phase sees its predecessors' consensus: the DX prompt * carries the CEO consensus, not only the Eng prompt. * R3 lib code spawns the Bun that runs it (`process.execPath`), never `bun` * by PATH name, so the snapshot tool and restamp cannot silently run the * Capy image's 1.3.14 when a parent forgot the launch PATH. * R5 gate answers carry provenance: `answer --by <human|agent:<id>>` is - * required in an unattended run and lands on every decision row. + * required in an unattended run and lands on every decision row; an agent + * answer also carries the owner's verbatim reply (next-wave B-c), and a + * parseable owner reply that resolves differently from the relay is refused. + * B-b `recommend` changes what `rec` resolves to, so it bumps rec_rev and a + * `rec` must carry the rec_hash of the render the owner saw. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseReply, renderGate, type GateList } from '../lib/gate-list'; -import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { attemptsOf, autoplan, readRun, resultOf, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-residuals-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -34,7 +38,7 @@ const agreed: GateList = { }; const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); -/** Both voices challenge the same item with the same `changes` and a remedy each: the gate recommends `b`. */ +/** Both voices challenge the same item with the same `changes` and the same remedy: the gate recommends `b`. */ function agreedRun(name: string): string { const out = startSmallRun(path.join(TMP, name)); const native = JSON.stringify([ @@ -42,51 +46,59 @@ function agreedRun(name: string): string { { id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }]); const outside = JSON.stringify([ { id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL' }, - { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes A then B' }]); + { id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'Stack the lanes.' }]); for (const phase of ['ceo', 'eng']) { expect(autoplan(['next', '--out', out]).status).toBe(0); review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native }); review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside }); const ids = attemptsOf(out, phase); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); - expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0); + expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0); } expect(readRun(out).status).toBe('gate_pending'); return out; } -describe('R1: `all` never flips the owner\'s stated direction', () => { - test('an approval item recommended b (changes the plan) stays pending under `all`; its own token answers it', () => { - const r = parseReply('all', agreed); - expect(r.valid).toBe(true); - expect(r.pending).toEqual(['uc1']); - expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc2a', 'd1a', 'p1a']); - const explicit = parseReply('all except uc1b', agreed); - expect(explicit.pending).toEqual([]); - expect(explicit.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'b', how: 'recommended' }); - const kept = parseReply('all except uc1a', agreed); - expect(kept.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); - expect(parseReply('all as-is', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); +describe('R1/B1: `rec` (and its alias `all`) takes every recommendation, plan changes included', () => { + test('an approval item recommended b is answered by `rec`/`all`; `rec except uc1a` and `as-is` keep the direction', () => { + for (const reply of ['rec', 'all', 'approve all', 'Accept all', 'take all recommendations']) { + const r = parseReply(reply, agreed); + expect(r.valid).toBe(true); + expect(r.mode).toBe('rec'); + expect(r.pending).toEqual([]); + expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc1b', 'uc2a', 'd1a', 'p1a']); + } + expect(parseReply('rec except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + expect(parseReply('all except uc1a', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' }); + for (const reply of ['as-is', 'all as-is']) { + const r = parseReply(reply, agreed); + expect(r.mode).toBe('as-is'); + expect(r.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' }); + } + for (const bare of ['yes', 'no', 'rec d1', 'rec yes']) expect(parseReply(bare, agreed).valid).toBe(false); }); - test('the rendering names the ids `all` leaves open and their exact tokens', () => { + test('the rendering offers `rec` first and prints its resolution from the same parser, with the rec_hash', () => { const text = renderGate(agreed); - expect(text).toMatch(/`all` does not answer uc1 \(recommended b changes the plan\); reply uc1a or uc1b/); - expect(text).toMatch(/"choices":\{"uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/Reply with exactly one of: `rec` \(default: take every recommendation, plan changes included\)/); + expect(text).toMatch(/"choices":\{"uc1":"b","uc2":"a","d1":"a","p1":"a"\}/); + expect(text).toMatch(/rec_hash=[0-9a-f]{12}/); + expect(text).not.toMatch(/does not answer/); }); - test('end to end: both voices agree, the gate recommends b, and `all` keeps the gate pending on that item', () => { + test('end to end: both voices agree with one remedy, the gate recommends b, and `rec` accepts it as a pending amendment', () => { const out = agreedRun('agreed'); const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList; const uc = gate.items.filter(i => i.id.startsWith('uc')); expect(uc.length).toBeGreaterThan(0); - expect(uc.every(i => i.recommended === 'b')).toBe(true); - const all = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all', '--by', 'human']); - expect(all.status, all.stderr).toBe(0); - expect(all.stdout).toMatch(new RegExp(`GATE_PENDING: ${uc.map(i => i.id).join(', ')} unanswered \\(reply with ${uc.map(i => `${i.id}a|${i.id}b`).join(' ')}; all does not answer items recommended b\\)`)); - expect(readRun(out).status).toBe('gate_pending'); + expect(uc.every(i => i.recommended === 'b' && i.recommended_basis === 'both voices; same remedy')).toBe(true); + const rec = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human']); + expect(rec.status, rec.stderr).toBe(0); + for (const i of uc) expect(rec.stdout).toMatch(new RegExp(`^APPLY: ${readRun(out).run}-${i.id} .*; edit the plan, then revise$`, 'm')); + expect(rec.stdout).toContain('GSTACK_RESULT: skill=autoplan status=amend_pending'); + expect(readRun(out).status).toBe('amend_pending'); const rows = decisions(out); - for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase()).status).toBe('pending'); + for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase())).toMatchObject({ status: 'approved', chosen: 'b' }); }); }); @@ -101,15 +113,54 @@ describe('R5: gate answers carry provenance', () => { const bad = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'me']); expect(bad.status).toBe(3); expect(bad.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/); - const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + const noOwner = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']); + expect(noOwner.status).toBe(3); + expect(noOwner.stderr).toMatch(/\(OWNER_REPLY_REQUIRED\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29', '--owner-reply', 'keep the plan as written']); expect(ok.status, ok.stderr).toBe(0); const answered = decisions(out).filter(r => r.status !== 'pending'); expect(answered.length).toBeGreaterThan(0); - for (const r of answered) expect(r.answered_by).toBe('agent:GSTA-29'); + for (const r of answered) expect(r).toMatchObject({ answered_by: 'agent:GSTA-29', owner_reply: 'keep the plan as written', owner_reply_parsed: false }); expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/answered by agent:GSTA-29/); }); }); +describe('B-b/B-c: recommend binds rec to the render the owner saw; the owner reply must match the relay', () => { + test('recommend bumps rec_rev and rewrites p1 and decisions; a rec without the new rec_hash is refused; a mismatched owner reply is refused', () => { + const out = agreedRun('recommend'); + const gateFile = path.join(out, 'gate.json'); + const uc = (JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList).items.find(i => i.id.startsWith('uc'))!; + const oldHash = renderGate(JSON.parse(fs.readFileSync(gateFile, 'utf8'))).match(/rec_hash=([0-9a-f]{12})/)![1]!; + const rec = autoplan(['recommend', '--out', out, '--gate-rev', '1', '--item', uc.id, '--option', 'a', '--basis', 'the lanes already split by file', '--by', 'parent']); + expect(rec.status, rec.stderr).toBe(0); + const hash = rec.stdout.match(/^RECOMMENDED: .* rec_hash=([0-9a-f]{12})$/m)![1]!; + expect(hash).not.toBe(oldHash); + const gate = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + expect(gate.rec_rev).toBe(1); + expect(gate.items.find(i => i.id === uc.id)).toMatchObject({ recommended: 'a', recommended_basis: 'the lanes already split by file', recommended_by: 'parent' }); + expect(renderGate(gate)).toContain(`rec_hash=${hash}`); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ recommended: 'a', recommended_by: 'parent' }); + const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'); + for (const extra of [[], ['--rec-hash', oldHash]]) { + const stale = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', ...extra]); + expect(stale.status).toBe(3); + expect(stale.stderr).toMatch(/\(GATE_REC_STALE\)/); + } + const mismatch = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply', `${uc.id}b`]); + expect(mismatch.status).toBe(3); + expect(mismatch.stderr).toMatch(/\(GATE_OWNER_REPLY_MISMATCH\)/); + expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before); + const ownerFile = path.join(TMP, 'owner-reply.txt'); + fs.writeFileSync(ownerFile, 'rec'); + const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--rec-hash', hash, '--by', 'agent:GSTA-29', '--owner-reply-file', ownerFile]); + expect(ok.status, ok.stderr).toBe(0); + expect(ok.stdout).not.toMatch(new RegExp(`^APPLY: \\S+-${uc.id} `, 'm')); + expect(ok.stdout).toMatch(/^APPLY: \S+-uc2 /m); + expect(decisions(out).find(r => r.label === uc.id.toUpperCase())).toMatchObject({ chosen: 'a', owner_reply: 'rec', owner_reply_parsed: true, recommended_by: 'parent' }); + }); +}); + describe('R2: later phases see their predecessors\' consensus', () => { test('the DX prompt carries the CEO consensus block', () => { const out = startSmallRun(path.join(TMP, 'dx-consensus'), ['--developer-tool']); diff --git a/test/autoplan-revise.test.ts b/test/autoplan-revise.test.ts index 9b00f151a..d3566df66 100644 --- a/test/autoplan-revise.test.ts +++ b/test/autoplan-revise.test.ts @@ -58,7 +58,11 @@ describe('revise', () => { expect(new Set(['ceo', 'dx', 'eng'].map(p => run.phases[p].snapshot)).size).toBe(1); expect(run.phases.ceo.snapshot).not.toBe(v1); expect(readJsonl(path.join(s.out, 'attempts.jsonl')).filter(e => e.event === 'plan_revised')).toHaveLength(1); - expect(fs.existsSync(path.join(s.out, 'ceo-native.r0.md'))).toBe(true); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r0.md'))).toBe(true); + const sealed = run.history.find((h: any) => h.phase === 'ceo').previous.native.output; + expect(sealed).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.existsSync(path.join(s.out, sealed))).toBe(true); + expect(run.artifacts[sealed]).toBeTruthy(); }); test('a Review-record-only edit keeps every phase; an open phase whose hash changed has its attempts superseded', () => { @@ -95,11 +99,11 @@ describe('revise', () => { test('a second revise never overwrites the first archive', () => { const s = gated(); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir)]).status).toBe(0); - const first = fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')); + const first = fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')); phaseVia(r, s.out, 'ceo', { native: [], outside: [] }); expect(r.ap(['revise', '--out', s.out, '--plan', v2File(s.dir, V2.replace('jitter 10%', 'jitter 20%'))]).status).toBe(0); - expect(fs.readFileSync(path.join(s.out, 'ceo-native.r1.md')).equals(first)).toBe(true); - expect(fs.existsSync(path.join(s.out, 'ceo-native.r1.v2.md'))).toBe(true); + expect(fs.readFileSync(path.join(s.out, 'ceo-native-prompt.r1.md')).equals(first)).toBe(true); + expect(fs.existsSync(path.join(s.out, 'ceo-native-prompt.r1.v2.md'))).toBe(true); }); test('revise adds a newly in-scope phase and never removes one', () => { diff --git a/test/autoplan-run-concurrency.test.ts b/test/autoplan-run-concurrency.test.ts index b8ac8aa68..b2d6a4340 100644 --- a/test/autoplan-run-concurrency.test.ts +++ b/test/autoplan-run-concurrency.test.ts @@ -10,7 +10,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, attemptsOf, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-concurrency-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -44,7 +44,7 @@ describe('two writers', () => { const out = startSmallRun(path.join(TMP, 'submit'), ['--estimate-usd', '1']); const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); - const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; const results = await parallel(Array.from({ length: 6 }, () => args)); const ok = results.filter(r => r.status === 0); expect(ok).toHaveLength(1); @@ -78,8 +78,8 @@ describe('two writers', () => { review(path.join(out, 'ceo-outside-prompt.md')); const before = readRun(out).revision; const results = await parallel([ - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native], - ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native], + ['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside], ['status', '--out', out], ]); const bound = results.slice(0, 2).filter(r => r.status === 0).length; diff --git a/test/autoplan-run-recovery.test.ts b/test/autoplan-run-recovery.test.ts index e67885016..d2c0db080 100644 --- a/test/autoplan-run-recovery.test.ts +++ b/test/autoplan-run-recovery.test.ts @@ -7,13 +7,16 @@ * refuses a wrong receipt, a missing findings fence, a same-family outside * model and a stale attempt; the whole wave plan runs through the loop with a * fixture reviewer and validates. Pins tokens, codes and exit codes. + * Next-wave B4: each attempt writes `<phase>-<voice>.<attempt>.md`, and submit + * binds a sealed `.bound.md` copy, so a late write or an interruption between + * sealing and the journal binds nothing twice. */ import { afterAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; +import { AUTOPLAN, ROOT, WAVE_PLAN, attemptsOf, autoplan, env, journal, readRun, resultOf, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-recovery-')); afterAll(() => fs.rmSync(TMP, { recursive: true, force: true })); @@ -46,36 +49,36 @@ describe('next/submit loop', () => { const ids = attemptsOf(out, 'ceo'); const before = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'receipt' }); - let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + let r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_RECEIPT_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'fence' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_BREAK: 'row' }); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/\(RESULT_FINDINGS_MISSING\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', 'nope-a9']); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(ATTEMPT_MISMATCH\)$/m); - r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'dx', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before); expect(journal(out).filter(j => j.event === 'submitted')).toEqual([]); // a good native, then an outside model from the same family review(path.join(out, 'ceo-native-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status, r.stderr).toBe(0); expect(r.stdout).toMatch(/^BOUND: \S+ phase=ceo voice=native model=claude-opus-4-7 findings=3/m); review(path.join(out, 'ceo-outside-prompt.md')); - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', path.join(out, 'ceo-outside.md'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'outside', '--result', resultOf(out, 'ceo', 'outside'), '--model', 'claude-sonnet-5', '--attempt', ids.outside]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/both anthropic.*\(MODEL_FAMILY_CONFLICT\)$/m); // exactly once: the bound native attempt cannot be submitted again - r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + r = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(r.status).toBe(3); expect(r.stderr).toMatch(/\(PHASE_NOT_AWAITING\)$/m); }); @@ -140,7 +143,7 @@ describe('recovery', () => { expect(readRun(out).attempt_counter).toBe(2); // the provider had finished after all: the late result binds under the same attempt id review(path.join(out, 'ceo-native-prompt.md')); - const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const late = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(late.status, late.stderr).toBe(0); // the outside attempt is redispatched only when named explicitly; its charge stays as unknown, never zero const redo = autoplan(['next', '--out', out, '--redispatch', ids.outside, '--estimate-usd', '1']); @@ -157,7 +160,7 @@ describe('recovery', () => { const ids = attemptsOf(out, 'ceo'); review(path.join(out, 'ceo-native-prompt.md')); const snapshotBefore = fs.readFileSync(path.join(out, 'run.json'), 'utf8'); - const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', path.join(out, 'ceo-native.md'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); + const ok = autoplan(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]); expect(ok.status, ok.stderr).toBe(0); // simulate the crash: the journal and the ledger have the submit, run.json does not fs.writeFileSync(path.join(out, 'run.json'), snapshotBefore); @@ -173,6 +176,52 @@ describe('recovery', () => { expect(spend.reserved.map((r: any) => r.attempt)).toEqual([ids.outside]); }); + test('killed after sealing, before the journal: nothing is bound, resume reports the attempt unknown, and the same submit then binds once', () => { + const out = startSmallRun(path.join(TMP, 'kill-sealed')); + const ids = attemptsOf(out, 'ceo'); + review(path.join(out, 'ceo-native-prompt.md')); + const args = ['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'claude-opus-4-7', '--attempt', ids.native]; + const faulted = autoplan(args, { env: env({ GSTACK_AUTOPLAN_FAULT: 'submit-after-seal' }) }); + expect(faulted.status).not.toBe(0); + const sealed = path.join(out, `ceo-native.${ids.native.split('-').pop()}.bound.md`); + expect(fs.existsSync(sealed)).toBe(true); + expect(journal(out).some(r => r.event === 'submitted')).toBe(false); + expect(readRun(out).phases.ceo.native.status).toBe('running'); + const resumed = JSON.parse(autoplan(['resume', '--out', out, '--json']).stdout); + expect(resumed).toMatchObject({ rebound: [], execution_unknown: [ids.native, ids.outside] }); + const ok = autoplan(args); + expect(ok.status, ok.stderr).toBe(0); + expect(journal(out).filter(r => r.event === 'submitted')).toHaveLength(1); + expect(readRun(out).phases.ceo.native).toMatchObject({ status: 'completed', output: path.basename(sealed), attempt: ids.native }); + }); + + test('the bound result is a sealed attempt-owned copy: a late write to the reviewer file changes nothing bound, and another attempt\'s file is refused', () => { + const out = startSmallRun(path.join(TMP, 'sealed')); + runPhase(out, 'ceo'); + const result = resultOf(out, 'ceo', 'native'); + const run = readRun(out); + const bound = path.join(out, run.phases.ceo.native.output); + expect(path.basename(bound)).toMatch(/^ceo-native\.a\d+\.bound\.md$/); + expect(fs.readFileSync(bound, 'utf8')).toBe(fs.readFileSync(result, 'utf8')); + if (process.platform !== 'win32') expect(fs.statSync(bound).mode & 0o222).toBe(0); + fs.appendFileSync(result, '\nLate edit after submit.\n'); + runPhase(out, 'eng'); + expect(readRun(out).status).toBe('gate_pending'); + const v = validate(out); + expect(v.status, v.stdout + v.stderr).toBe(0); + expect(fs.readFileSync(bound, 'utf8')).not.toContain('Late edit'); + const eng = attemptsOf(out, 'eng'); + expect(path.basename(resultOf(out, 'eng', 'native'))).toBe(`eng-native.${eng.native.split('-').pop()}.md`); + const other = startSmallRun(path.join(TMP, 'sealed-other')); + const otherIds = attemptsOf(other, 'ceo'); + review(path.join(other, 'ceo-native-prompt.md')); + const foreign = path.join(other, `ceo-native.a999.md`); + fs.copyFileSync(resultOf(other, 'ceo', 'native'), foreign); + const refused = autoplan(['submit', '--out', other, '--phase', 'ceo', '--voice', 'native', '--result', foreign, '--model', 'claude-opus-4-7', '--attempt', otherIds.native]); + expect(refused.status).toBe(3); + expect(refused.stderr).toMatch(/\(ATTEMPT_MISMATCH\)/); + }); + test('a runner process killed mid-command leaves no lock behind that a later command cannot reclaim; a live owner is never stolen', () => { const out = startSmallRun(path.join(TMP, 'lock')); fs.writeFileSync(path.join(out, 'run.lock'), JSON.stringify({ pid: 999999, host: os.hostname(), at: new Date().toISOString() })); diff --git a/test/fixtures/autoplan-pairing/run-20261014-pairs.json b/test/fixtures/autoplan-pairing/run-20261014-pairs.json new file mode 100644 index 000000000..d0fac6775 --- /dev/null +++ b/test/fixtures/autoplan-pairing/run-20261014-pairs.json @@ -0,0 +1,94 @@ +{ + "source": "autoplan run run-20261014225954 (next-wave plan review, 2026-10-14): ceo gate_rev 1 findings (*.findings.r1.jsonl) and dx gate_rev 2 findings (*.findings.jsonl), copied verbatim", + "pairs": [ + { + "name": "ceo r1 outside-1 vs native-11", + "expect": "negative", + "why": "same file, lines 3 apart; different problems and remedies (project-aware Bun launching vs a single verdict owner plus a fresh-machine job); reconciled confirmed on file:line alone at 1.91.101.0", + "outside": [ + { + "id": "run-20261014225954-ceo-outside-1", + "phase": "ceo", + "voice": "outside", + "severity": "High", + "title": "A fixed home-wide Bun link cannot preserve per-project runtime ownership", + "file": "bin/gstack-capy-install", + "line": 197, + "resolution": "Replace A2's mutable fixed-version global link with project-aware launching; bind install verdicts to the calling project and resolved runtime, reconcile pregate's separate resolver, and exercise alternating exact-pin projects plus unrelated Bun commands.", + "user_challenge": true, + "changes": "direction" + } + ], + "native": [ + { + "id": "run-20261014225954-ceo-native-11", + "phase": "ceo", + "voice": "native", + "severity": "Medium", + "title": "Fourth install-honesty wave in about two weeks: add a single verdict owner and a pre-merge fresh-machine acceptance job instead of a post-ship manual rerun", + "file": "bin/gstack-capy-install", + "line": 200, + "resolution": "Route install, upgrade, repair and session-start through one final step: gstack-capy-install --check from ORIG_PWD, printing only its trailer. Make the GBRA-60 reproduction (image Bun on PATH, verified Bun off PATH, project pinned to 1.4.3, fresh shells) an automated job on a fresh Capy or Ubicloud machine that gates Lane A's merge." + } + ] + }, + { + "name": "dx r2 outside-2 vs native-1", + "expect": "positive", + "why": "both find that the revised trailer grammar breaks the existing fail head and leaves the pins exit unspecified", + "outside": [ + { + "id": "run-20261014225954-dx-outside-2", + "phase": "dx", + "voice": "outside", + "severity": "Medium", + "title": "The revised failure trailer has conflicting shapes and an unspecified pins exit code", + "file": "bin/gstack-doctor-check.sh", + "line": 459, + "resolution": "Define one backward-compatible status grammar with failed-component information, specify A7's numeric pins-readiness exit and partial-upgrade state, and synchronize text/JSON/verdict/docs with success and multi-failure note-composition tests." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-1", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "A9's trailer grammar would replace the existing 'gstack: fail <components>' head that the Capy skill (and its lagging Drive copies) dispatch repairs on", + "file": "bin/gstack-doctor-check.sh", + "line": 460, + "resolution": "Scope the grammar to `gstack: ok <rev> for=<skill>[ (<notes>)]`. Keep failures as `gstack: fail <component>[,<component>] \u2014 <detail>; fix: <cmd> (<CODE>; <url>#<anchor>)`, with notes appended in the same fixed order. Add a test that runs the current capy/skill/gstack/SKILL.md dispatch examples (fail revision, fail runtime,pins, fail pins) against the new output." + } + ] + }, + { + "name": "dx r2 native-2 vs outside-4", + "expect": "positive", + "why": "same problem by meaning: a recommendation edit after the owner saw the gate changes what the reply resolves to; different files and titles", + "outside": [ + { + "id": "run-20261014225954-dx-outside-4", + "phase": "dx", + "voice": "outside", + "severity": "High", + "title": "Recommendation edits can change a displayed gate's defaults without invalidating its reply", + "file": "lib/autoplan-gate.ts", + "line": 154, + "resolution": "Version semantic recommendation changes, reject old replies, and refresh plan-approval summaries, decisions and artifact hashes; return the new gate identity and test concurrent/repeated edits, answered/carried choices and restore." + } + ], + "native": [ + { + "id": "run-20261014225954-dx-native-2", + "phase": "dx", + "voice": "native", + "severity": "High", + "title": "The new recommend verb can change what rec resolves to after the owner has seen the gate", + "file": "lib/gate-list.ts", + "line": 112, + "resolution": "Hash the rendered recommendations into the relay (for example rec_hash in the render's resolution line). Make answer reject a rec whose hash no longer matches (re-render and re-relay). Make recommend refuse after a render has been relayed unless it bumps to a fresh render. Test recommend-after-relay, then rec." + } + ] + } + ] +} \ No newline at end of file diff --git a/test/gate-list.test.ts b/test/gate-list.test.ts index 10a72c836..c4e1aa775 100644 --- a/test/gate-list.test.ts +++ b/test/gate-list.test.ts @@ -9,7 +9,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { applyReply, decisionRows, parseReply, renderGate, resolveAll, validateGateList, type GateList } from '../lib/gate-list'; +import { applyReply, decisionRows, parseReply, recHash, renderGate, validateGateList, type GateList } from '../lib/gate-list'; import { EXIT, validateFile } from '../lib/headless-artifacts'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -116,7 +116,7 @@ describe('reply grammar', () => { }); describe('rendering', () => { - test('ids and numbering are stable across pages; each page prints the reply shape and the `all` resolution', () => { + test('ids and numbering are stable across pages; each page prints the reply shape and the `rec` resolution with its rec_hash', () => { const p1 = renderGate(big, 1); const p5 = renderGate(big, 5); expect(p1).toContain('page=1/5'); @@ -127,10 +127,10 @@ describe('rendering', () => { expect(p5).toContain('29. [d29] decision 29'); expect(p5).toContain('33. [d33] decision 33'); for (const page of [p1, p5]) { - expect(page).toContain('`all`'); - expect(page).toContain('`all except <tokens>`'); - expect(page).toContain(`gate_rev=1`); - expect(page).toContain(JSON.stringify({ gate_rev: 1, choices: resolveAll(big) })); + expect(page).toContain('`rec`'); + expect(page).toContain('`rec except <tokens>`'); + expect(page).toContain(`gate_rev=1 rec_hash=${recHash(big)}`); + expect(page).toContain(JSON.stringify({ gate_rev: 1, rec_hash: recHash(big), choices: Object.fromEntries(big.items.map(i => [i.id, i.recommended])) })); } expect(renderGate(list)).toContain(' d1a) option A (recommended)'); expect(renderGate(list)).not.toContain('page=1/2'); @@ -145,6 +145,33 @@ describe('rendering', () => { }); }); +describe('next wave B1/B-b: rec, aliases, quotes and the rec_hash', () => { + const held: GateList = { ...list, items: [{ ...list.items[0]!, recommended: 'b', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }, { key: 'c', text: 'other' }] }, ...list.items.slice(1)] }; + test('every alias resolves like `rec`, plan changes included; `except` and earlier answers combine; quotes, backticks and newlines are separators', () => { + const want = { uc1: 'b', d1: 'a', d2: 'a', d3: 'a' }; + for (const reply of ['rec', 'REC', 'all', 'approve all', 'accept all', 'take all recommendations', '`rec`', '"rec"', '\u201crec\u201d', 'rec\n']) { + const p = parseReply(reply, held, 1); + expect(p.valid, reply).toBe(true); + expect(p.mode).toBe('rec'); + expect(chosen(p)).toEqual(want); + } + expect(chosen(parseReply('rec except uc1a d3b', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'b' }); + expect(chosen(parseReply('rec', held, 1, { d1: 'c' }))).toEqual({ uc1: 'b', d1: 'c', d2: 'a', d3: 'a' }); + expect(parseReply('rec', held, 1, { d1: 'c' }).choices.find(c => c.id === 'd1')!.prior).toBe(true); + expect(chosen(parseReply('as-is', held, 1))).toEqual({ uc1: 'a', d1: 'a', d2: 'a', d3: 'a' }); + expect(parseReply('d3b', held, 1).pending).toEqual(['uc1', 'd1']); + for (const bad of ['approve', 'accept', 'take all', 'rec rec', 'rec as-is', 'yes']) expect(parseReply(bad, held, 1).valid, bad).toBe(false); + }); + test('rec_hash changes with a recommendation, its basis or rec_rev, and the render shows who set it', () => { + const h = recHash(held); + expect(recHash({ ...held })).toBe(h); + expect(recHash({ ...held, rec_rev: 1 })).not.toBe(h); + const moved = { ...held, items: held.items.map(i => (i.id === 'd1' ? { ...i, recommended: 'b', recommended_basis: 'both reviewers name it', recommended_by: 'agent:relay' } : i)) }; + expect(recHash(moved)).not.toBe(h); + expect(renderGate(moved)).toContain('[d1] Delivery shape (approval; recommended: b (both reviewers name it; set by agent:relay))'); + }); +}); + describe('decisions.jsonl rows', () => { test('pending rows carry gate_rev, kind, recommended and the auto default; applying a reply sets chosen/status', () => { const rows = decisionRows(list); diff --git a/test/helpers/autoplan-capy.ts b/test/helpers/autoplan-capy.ts index fbf2c54d4..00e6bd589 100644 --- a/test/helpers/autoplan-capy.ts +++ b/test/helpers/autoplan-capy.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import { capyHost, type CapyHost } from './capy-host'; import { SKILL_REQUIREMENTS } from '../../lib/doctor-components'; import type { InstallVerdict } from '../../lib/install-verdict'; -import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN } from './autoplan-run-fixture'; +import { AUTOPLAN, REVIEWER, ROOT, SMALL_PLAN, resultOf } from './autoplan-run-fixture'; const SESSION_KIND_VAR = ['GSTACK', 'SESSION', 'KIND'].join('_'); const RUNNER_KIND = 'un' + 'attended'; @@ -75,7 +75,7 @@ export function phaseVia(r: CapyRunner, out: string, phase: string, findings: { if (rv.status !== 0) throw new Error(`fixture reviewer failed: ${rv.stderr}`); } const run = readRunJson(out); - const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']); - const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']); + const native = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', run.phases[phase].native.attempt, '--runner', 'host-subagent']); + const outside = r.ap(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', 'openai/gpt-6.1-sol', '--attempt', run.phases[phase].outside.attempt, '--runner', 'host-subagent']); return { next, native, outside }; } diff --git a/test/helpers/autoplan-run-fixture.ts b/test/helpers/autoplan-run-fixture.ts index 1d478423b..c602886f6 100644 --- a/test/helpers/autoplan-run-fixture.ts +++ b/test/helpers/autoplan-run-fixture.ts @@ -49,6 +49,15 @@ export function readRun(out: string): any { export function journal(out: string): any[] { return fs.readFileSync(path.join(out, 'attempts.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } +/** The result path the open (else the latest) attempt of a voice owns: `<phase>-<voice>.<attempt>.md`. */ +export function resultOf(out: string, phase: string, voice: 'native' | 'outside'): string { + const attempts: Array<{ state: string; result: string }> = readRun(out).phases[phase]?.[voice]?.attempts ?? []; + const attempt = attempts.find(a => a.state === 'dispatched' || a.state === 'execution_unknown') ?? attempts[attempts.length - 1]; + if (!attempt) throw new Error(`${phase} ${voice} has no attempt in ${out}`); + return attempt.result; +} +/** The sealed copy a completed voice is bound to. */ +export const boundOf = (out: string, phase: string, voice: 'native' | 'outside'): string => path.join(out, readRun(out).phases[phase][voice].output); export function attemptsOf(out: string, phase: string): { native: string; outside: string } { const run = readRun(out); return { native: run.phases[phase].native.attempt, outside: run.phases[phase].outside.attempt }; @@ -65,8 +74,8 @@ export function runPhase(out: string, phase: string, o: PhaseRunOptions = {}): { if (r.status !== 0) throw new Error(`fixture reviewer failed: ${r.stderr}`); } const ids = attemptsOf(out, phase); - const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); - const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); + const native = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultOf(out, phase, 'native'), '--model', o.nativeModel ?? 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']); + const outside = autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultOf(out, phase, 'outside'), '--model', o.outsideModel ?? 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent', ...(o.reconcile ? ['--reconcile', o.reconcile] : [])]); return { next, native, outside }; }