From 0736955c8546aaccb79babe2190c315c765a3ec2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 13 Oct 2026 01:46:07 +0000 Subject: [PATCH] fix(autoplan): revise naming a non-pending decision refuses with AMENDMENT_UNKNOWN, not PLAN_AMEND_PENDING Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- bin/gstack-doctor-components.sh | 1 + docs/troubleshooting.md | 12 ++++++++++++ lib/autoplan-revise.ts | 2 +- lib/result-codes.ts | 5 +++++ test/autoplan-revise.test.ts | 12 ++++++++++++ 6 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcc94b2d..5e85a1915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - **`submit --record ` binds a review a subagent wrote by hand.** It accepts the canonical findings fence, one findings table (Severity and Title columns), one severity-tagged list (`- [High] title`) or a `No findings.` line, and it never guesses a severity. A record whose `INPUT:` line is present but wrong is refused. A record without one binds only with `--attest "" --attempt `; it is labeled `receipt: attested`, `validate` counts it (`attested=`), and it is refused if the plan was revised after that attempt was dispatched. `--attempt` is optional when exactly one attempt is open. `submit --voice outside --unavailable|--failed ""` closes a phase whose outside voice cannot run, and `next` and `resume` never redispatch it. - **`revise --plan ` reviews a revised plan in the same run.** Every phase whose reviewed Implementation-plan hash differs reopens (plus Eng) on the new snapshot; at the gate it starts the next gate revision. The revise is one recoverable step (`resume` finishes an interrupted one), and archives never overwrite each other. `next` prints `PLAN_CHANGED` and `answer` prints `PLAN_SOURCE_CHANGED` when the plan file moved since its snapshot. -- **Accepted plan changes must reach the plan.** When the owner accepts a change at the gate, `answer` prints one `APPLY:` line per change and `NEXT: gstack-autoplan revise …`. `next` refuses with `PLAN_AMEND_PENDING` until `revise --incorporates ` binds the edit, or the owner withdraws a change with `--withdraw `. A run cannot export as complete with a change pending. +- **Accepted plan changes must reach the plan.** When the owner accepts a change at the gate, `answer` prints one `APPLY:` line per change and `NEXT: gstack-autoplan revise …`. `next` refuses with `PLAN_AMEND_PENDING` until `revise --incorporates ` binds the edit, or the owner withdraws a change with `--withdraw `. A run cannot export as complete with a change pending. Naming an id that is not pending (never accepted as a plan change, or already settled) refuses with `AMENDMENT_UNKNOWN` instead of asking for the edit you just made. - **User-challenge defaults follow agreeing voices, and say why.** When both voices flag the same finding as a change to scope, cost or direction (the same `changes` value) and both give a remedy, the item now recommends the change. Option b shows both remedies word for word, and the default is labeled `recommended: b (both voices; remedies shown)`. Anything weaker keeps your direction, labeled `(plan direction; voices disagree)`. **`all` now accepts those agreed changes**; reply `all as-is` to keep every direction in one token. p1's recommended text names the changes it would apply. - **Reruns stop churning.** Reviewers set `user_challenge` only for a change to scope, cost or direction (`changes`). On a rerun, a challenge without one becomes the agent's own decision and does not reopen anything. Rerun prompts carry a `DECIDED AT THE GATE` block, and answers to items from phases that did not rerun carry forward instead of being asked again. A partial reply never overwrites an earlier answer, and the gate stays open until every approval is settled. - **A plan without the two sections is scaffolded, not refused with empty output.** The first `next` explains `PLAN_SECTIONS_INVALID`; `next --scaffold` reviews a copy with the missing heading added and never edits your file. diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 7e75b54e8..1af3c3904 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -176,6 +176,7 @@ gstack_result_anchor() { DRAFT_DIRECTION_UNRESOLVED) printf '%s' 'draft-direction-unresolved' ;; PLAN_SECTIONS_INVALID) printf '%s' 'plan-sections-invalid' ;; PLAN_AMEND_PENDING) printf '%s' 'plan-amend-pending' ;; + AMENDMENT_UNKNOWN) printf '%s' 'amendment-unknown' ;; 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 39257008e..5911eba3a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2570,6 +2570,18 @@ not change; an id you do not name stays pending. To drop an accepted change instead, `gstack-autoplan revise --out --withdraw ` records the withdrawal in the journal. + +### `AMENDMENT_UNKNOWN` + +**Meaning.** `gstack-autoplan revise --incorporates` or `--withdraw` named a +decision id that is not a pending amendment: the owner never accepted it as a +plan change at the gate (for example a gate answered before gstack recorded +amendments), or it was already incorporated or withdrawn. Nothing was changed. + +**Fix.** Name only the ids the refusal lists after `pending:`. With none +pending, revise without `--incorporates`: +`gstack-autoplan revise --out --plan `. + ### `GSTACK_VERSION_CHANGED` diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index 0cc64c5bf..f3452b52f 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -56,7 +56,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult { 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 pending = pendingAmendments(state).map(a => a.decision); for (const id of [...(o.incorporates ?? []), ...(o.withdraw ?? [])]) { - if (!pending.includes(id)) throw new RunError('PLAN_AMEND_PENDING', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3); + if (!pending.includes(id)) throw new RunError('AMENDMENT_UNKNOWN', `${id} is not a pending amendment (pending: ${pending.join(', ') || 'none'})`, 3); } const at = now(); if (!o.plan) { diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 58c793fcf..0ae353e76 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -476,6 +476,11 @@ export const RESULT_CODES = { 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 --plan --incorporates `; or drop one with `gstack-autoplan revise --out --withdraw `', }, + 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 --plan `', + }, GSTACK_VERSION_CHANGED: { anchor: 'gstack-version-changed', summary: 'the run directory was written by a different gstack version', diff --git a/test/autoplan-revise.test.ts b/test/autoplan-revise.test.ts index 6ae23fc36..55c920c40 100644 --- a/test/autoplan-revise.test.ts +++ b/test/autoplan-revise.test.ts @@ -124,6 +124,18 @@ describe('revise', () => { expect(res.status).toBe(1); expect(res.stderr).toMatch(/revised after .*dispatched.*\(RESULT_RECEIPT_MISSING\)$/m); }); + test('naming a decision that is not a pending amendment refuses with AMENDMENT_UNKNOWN and never asks for the edit just made', () => { + const s = start(); + const before = fs.readFileSync(path.join(s.out, 'run.json'), 'utf8'); + for (const args of [['--plan', v2File(s.dir), '--incorporates', 'd1b'], ['--withdraw', 'd1b']]) { + const res = r.ap(['revise', '--out', s.out, ...args]); + expect(res.status).toBe(3); + expect(res.stderr).toMatch(/d1b is not a pending amendment \(pending: none\).*\(AMENDMENT_UNKNOWN\)$/m); + expect(res.stderr).not.toContain('PLAN_AMEND_PENDING'); + expect(res.stderr).not.toContain('edit the plan'); + } + expect(fs.readFileSync(path.join(s.out, 'run.json'), 'utf8')).toBe(before); + }); }); describe('drift hints', () => {