Make autoplan's rec and all replies apply every recommended plan change (GST-2139) (#4225)

* Make autoplan's rec and all replies apply every recommended plan change (GST-2139)

The Phase 4 reply table sent `all` to "approve as written" while gstack-gate
parse takes every recommendation, plan changes included, and it let `p1a`
approve with a user challenge still pending. The table now matches the gate:
`rec`/`all` is the one-word default and routes plan changes through B/B2,
a pending item approves nothing until the owner answers it, and p1 recommends
b when a recommended option changes the plan. A new test reads the table and
checks it against bin/gstack-gate parse for all, rec and p1a.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Rename the changelog fragment to the branch's fragment name

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-09 18:30:21 +00:00
1 parent e9de3a9d8b
commit cfedd05bb9
5 files changed
+147 -42

No files matched your search

+19 -15
View File
@@ -625,8 +625,10 @@ in an unattended run, never a warning; interactive runs follow the repair rule a
Write `$_AP/gate.json` (shape: `gstack-gate --help`): one item per User Challenge
(`uc<n>`, approval; `why` = you said / both recommend / why / blind spots / cost if
wrong), per taste decision (`d<n>`, auto; `why` = principle and the alternative's
impact) and `p1` (approval: a) approve as-is (recommended) b) with overrides
c) interrogate d) revise e) reject). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json`
impact) and `p1` (approval: a) approve as-is b) take the recommended changes c) interrogate
d) revise e) reject), recommending `b` if a recommended option changes the plan, else `a`.
Every `uc<n>`/`d<n>` option sets `changes_plan` (`true` if it changes the plan,
else `false`). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json`
and append its `decisions` output to `$_AP/decisions.jsonl`. Write the full review
to `$_AP/review-record.md` and its Plan Summary alone to `$_AP/summary.md`:
@@ -641,7 +643,7 @@ findings outside this plan, or `None.`); also written above `## Implementation p
[1-3 sentence summary]
### Decisions Made: [N] total ([M] auto-decided, [K] taste choices, [J] user challenges)
Auto-decided: see the plan file's Decision Audit Trail; the rest are the brief's decisions.
Auto-decided: see the plan's Decision Audit Trail.
### Review Scores
CEO, Design, DX and Eng: phase summary plus Codex, Claude
@@ -649,7 +651,7 @@ and consensus status; say skipped where a phase did not run. Then the CYCLE line
from `~/.claude/skills/gstack/bin/gstack-autoplan-timing summary --run <RUN_ID> --out "$_AP"`.
### Cross-Phase Themes
Concerns independently raised in 2+ phases, or "No cross-phase themes — each phase's concerns were distinct."
Concerns raised independently in 2+ phases, or "None."
### Deferred to TODOS.md
[Items auto-deferred with reasons]
@@ -659,21 +661,23 @@ Concerns independently raised in 2+ phases, or "No cross-phase themes — each p
```
Present `~/.claude/skills/gstack/bin/gstack-owner-brief render --out "$_AP" --write`
verbatim (URGENT block, plain summary, numbered decisions seven per page with each
auto default, review-record path; `--page N` for later pages), never the full review.
verbatim (`--page N` for later pages), never the full review.
Parse the reply: `~/.claude/skills/gstack/bin/gstack-gate parse $_AP/gate.json --reply "<text>" --gate-rev <rev>`;
unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `all` or `p1a`
without overrides = A; `d<n>` overrides = B; `uc<n>` overrides = B2; `p1c`/`p1d`/`p1e`
= C/D/E. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here
unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `rec` (alias
`all`), the one-word default, takes every recommendation, plan changes included; `as-is` keeps
the plan as first written. First match: any `pending` id = approve nothing (bare `p1a` leaves
`uc<n>` pending; re-parse the owner's whole reply);
`p1c`/`p1d`/`p1e` = C/D/E; a chosen `changes_plan` option (or an unflagged override)
= B on a `d<n>`, B2 on a `uc<n>`; else = A. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here
with its `GSTACK_RESULT` line and never approve.
**Option handling:**
- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan <ACTIVE_PLAN>` (draft-direction lines become the decided option and date), write review logs, suggest /ship
- B: ask which overrides, apply, then follow D's affected-phase rerun rule (Eng last) before re-presenting the gate; same 3-cycle cap as D.
- B2: accept/reject User Challenges one at a time; rejected ones preserve the user's direction. Re-run Eng, then re-present the gate.
- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan <ACTIVE_PLAN>`, write review logs, suggest /ship
- B: apply each chosen change, rerun as in D before re-presenting the gate; same 3-cycle cap as D.
- B2: apply each accepted User Challenge (rejected ones keep the user's direction), re-run Eng, re-present the gate.
- C: answer freeform, re-present the gate
- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3; a re-run of any earlier phase re-runs Eng after it — the gate always reviews the final plan). Max 3 cycles.
- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3), then Eng; max 3 cycles.
- E: start over
**Starting an affected-phase rerun:** Keep the current Implementation plan and all
@@ -681,9 +685,9 @@ prior accepted obligations intact. Move that phase's applied `autoplan-baseline-
record verbatim into fenced history in Review record, keeping its original source SHA.
Create a fresh amendment checkpoint. New baseline edits use `create`'s
`baselineEdits.record` and `sourceSha256`; the review projection hash is not baseline
identity. Carry forward unchanged accepted requirements. Never replay old replacements
identity. Never replay old replacements
or rewrite a historical source SHA. This is a new phase invocation; compaction
resumes the existing one and its checkpoint. Eng still runs last.
resumes the existing one and its checkpoint.
---
+19 -15
View File
@@ -360,8 +360,10 @@ there with each incomplete item.
Write `$_AP/gate.json` (shape: `gstack-gate --help`): one item per User Challenge
(`uc<n>`, approval; `why` = you said / both recommend / why / blind spots / cost if
wrong), per taste decision (`d<n>`, auto; `why` = principle and the alternative's
impact) and `p1` (approval: a) approve as-is (recommended) b) with overrides
c) interrogate d) revise e) reject). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json`
impact) and `p1` (approval: a) approve as-is b) take the recommended changes c) interrogate
d) revise e) reject), recommending `b` if a recommended option changes the plan, else `a`.
Every `uc<n>`/`d<n>` option sets `changes_plan` (`true` if it changes the plan,
else `false`). Run `~/.claude/skills/gstack/bin/gstack-gate render $_AP/gate.json`
and append its `decisions` output to `$_AP/decisions.jsonl`. Write the full review
to `$_AP/review-record.md` and its Plan Summary alone to `$_AP/summary.md`:
@@ -376,7 +378,7 @@ findings outside this plan, or `None.`); also written above `## Implementation p
[1-3 sentence summary]
### Decisions Made: [N] total ([M] auto-decided, [K] taste choices, [J] user challenges)
Auto-decided: see the plan file's Decision Audit Trail; the rest are the brief's decisions.
Auto-decided: see the plan's Decision Audit Trail.
### Review Scores
CEO, Design, DX and Eng: phase summary plus {{OUTSIDE_LABEL}}, {{NATIVE_LABEL}}
@@ -384,7 +386,7 @@ and consensus status; say skipped where a phase did not run. Then the CYCLE line
from `~/.claude/skills/gstack/bin/gstack-autoplan-timing summary --run <RUN_ID> --out "$_AP"`.
### Cross-Phase Themes
Concerns independently raised in 2+ phases, or "No cross-phase themes — each phase's concerns were distinct."
Concerns raised independently in 2+ phases, or "None."
### Deferred to TODOS.md
[Items auto-deferred with reasons]
@@ -394,21 +396,23 @@ Concerns independently raised in 2+ phases, or "No cross-phase themes — each p
```
Present `~/.claude/skills/gstack/bin/gstack-owner-brief render --out "$_AP" --write`
verbatim (URGENT block, plain summary, numbered decisions seven per page with each
auto default, review-record path; `--page N` for later pages), never the full review.
verbatim (`--page N` for later pages), never the full review.
Parse the reply: `~/.claude/skills/gstack/bin/gstack-gate parse $_AP/gate.json --reply "<text>" --gate-rev <rev>`;
unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `all` or `p1a`
without overrides = A; `d<n>` overrides = B; `uc<n>` overrides = B2; `p1c`/`p1d`/`p1e`
= C/D/E. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here
unparsed or stale replies are re-presented (`gate_rev`+1), never guessed. `rec` (alias
`all`), the one-word default, takes every recommendation, plan changes included; `as-is` keeps
the plan as first written. First match: any `pending` id = approve nothing (bare `p1a` leaves
`uc<n>` pending; re-parse the owner's whole reply);
`p1c`/`p1d`/`p1e` = C/D/E; a chosen `changes_plan` option (or an unflagged override)
= B on a `d<n>`, B2 on a `uc<n>`; else = A. Under `SESSION_KIND: unattended` Read `sections/unattended.md`, end here
with its `GSTACK_RESULT` line and never approve.
**Option handling:**
- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan <ACTIVE_PLAN>` (draft-direction lines become the decided option and date), write review logs, suggest /ship
- B: ask which overrides, apply, then follow D's affected-phase rerun rule (Eng last) before re-presenting the gate; same 3-cycle cap as D.
- B2: accept/reject User Challenges one at a time; rejected ones preserve the user's direction. Re-run Eng, then re-present the gate.
- A: mark APPROVED, run `~/.claude/skills/gstack/bin/gstack-owner-brief close --out "$_AP" --plan <ACTIVE_PLAN>`, write review logs, suggest /ship
- B: apply each chosen change, rerun as in D before re-presenting the gate; same 3-cycle cap as D.
- B2: apply each accepted User Challenge (rejected ones keep the user's direction), re-run Eng, re-present the gate.
- C: answer freeform, re-present the gate
- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3; a re-run of any earlier phase re-runs Eng after it — the gate always reviews the final plan). Max 3 cycles.
- D: make changes, re-run affected phases (scope→1, design→2, dx→2.5, test plan→3, arch→3), then Eng; max 3 cycles.
- E: start over
**Starting an affected-phase rerun:** Keep the current Implementation plan and all
@@ -416,9 +420,9 @@ prior accepted obligations intact. Move that phase's applied `autoplan-baseline-
record verbatim into fenced history in Review record, keeping its original source SHA.
Create a fresh amendment checkpoint. New baseline edits use `create`'s
`baselineEdits.record` and `sourceSha256`; the review projection hash is not baseline
identity. Carry forward unchanged accepted requirements. Never replay old replacements
identity. Never replay old replacements
or rewrite a historical source SHA. This is a new phase invocation; compaction
resumes the existing one and its checkpoint. Eng still runs last.
resumes the existing one and its checkpoint.
---
@@ -0,0 +1 @@
- **/autoplan's `rec` and `all` replies now apply every recommended plan change, as `gstack-gate` already did.** The skill's reply table sent `all` (and left `rec` unmapped) straight to "approve as written", while `gstack-gate parse` resolves both to every item's recommendation, plan changes included, so decisions.jsonl and the approved plan could disagree. `rec` is now the one-word default that takes those changes through the affected-phase rerun before the gate comes back; `as-is` stays the plan as first written. The plan-approval item now recommends `b` (take the recommended changes) whenever a recommended option changes the plan. A bare `p1a` with a user challenge unanswered no longer approves anything: the gate waits for the owner's own answer to that `uc` item and re-parses their whole reply. Every `uc`/`d` option in a hand-written gate now sets `changes_plan`, and an explicit override of an option with no flag still counts as a plan change, as `gstack-autoplan answer` already treats it. A test reads the skill's reply table and checks it against what `gstack-gate parse` returns for `all`, `rec`, `p1a` and an override.
+12 -12
View File
@@ -2,53 +2,53 @@
"_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.",
"limit": 100000,
"pins": {
"agy/gstack-autoplan": 109575,
"agy/gstack-autoplan": 109540,
"agy/gstack-plan-design-review": 109096,
"agy/gstack-plan-devex-review": 114924,
"agy/gstack-plan-eng-review": 143580,
"agy/gstack-review": 104735,
"codex/gstack-autoplan": 108318,
"codex/gstack-autoplan": 108283,
"codex/gstack-plan-design-review": 110453,
"codex/gstack-plan-devex-review": 116432,
"codex/gstack-plan-eng-review": 144745,
"codex/gstack-review": 104119,
"copilot/gstack-autoplan": 109018,
"copilot/gstack-autoplan": 108983,
"copilot/gstack-plan-design-review": 109362,
"copilot/gstack-plan-devex-review": 114386,
"copilot/gstack-plan-eng-review": 143745,
"copilot/gstack-review": 104316,
"cursor/gstack-autoplan": 108250,
"cursor/gstack-autoplan": 108215,
"cursor/gstack-plan-design-review": 107904,
"cursor/gstack-plan-devex-review": 113656,
"cursor/gstack-plan-eng-review": 142335,
"cursor/gstack-review": 122788,
"factory/gstack-autoplan": 110528,
"factory/gstack-autoplan": 110493,
"factory/gstack-plan-design-review": 111002,
"factory/gstack-plan-devex-review": 117019,
"factory/gstack-plan-eng-review": 145443,
"factory/gstack-review": 126561,
"gbrain/gstack-autoplan": 108657,
"gbrain/gstack-autoplan": 108622,
"gbrain/gstack-plan-eng-review": 124963,
"gemini/gstack-autoplan": 108808,
"gemini/gstack-autoplan": 108773,
"gemini/gstack-plan-design-review": 109190,
"gemini/gstack-plan-devex-review": 114214,
"gemini/gstack-plan-eng-review": 143578,
"gemini/gstack-review": 104138,
"hermes/gstack-autoplan": 108611,
"hermes/gstack-autoplan": 108576,
"hermes/gstack-plan-eng-review": 124999,
"kiro/gstack-autoplan": 108024,
"kiro/gstack-autoplan": 107989,
"kiro/gstack-plan-design-review": 107750,
"kiro/gstack-plan-devex-review": 113508,
"kiro/gstack-plan-eng-review": 142197,
"kiro/gstack-review": 122612,
"openclaw/gstack-autoplan": 108480,
"openclaw/gstack-autoplan": 108445,
"openclaw/gstack-plan-eng-review": 123478,
"opencode/gstack-autoplan": 108999,
"opencode/gstack-autoplan": 108964,
"opencode/gstack-plan-design-review": 109218,
"opencode/gstack-plan-devex-review": 114264,
"opencode/gstack-plan-eng-review": 143604,
"opencode/gstack-review": 123410,
"slate/gstack-autoplan": 108137,
"slate/gstack-autoplan": 108102,
"slate/gstack-plan-design-review": 107827,
"slate/gstack-plan-devex-review": 113582,
"slate/gstack-plan-eng-review": 142266,
+96
View File
@@ -279,3 +279,99 @@ describe('the /autoplan Phase 4 gate as documented (#4181)', () => {
expect(results.slice(1).map(r => chosen(JSON.parse(r.out))[id])).toEqual(['a', 'c', 'd', 'e']);
});
});
describe('the /autoplan Phase 4 reply table agrees with gstack-gate (GST-2139)', () => {
// Value: protects=the reply-to-option table /autoplan acts on matches what bin/gstack-gate parse resolves for `all`, `rec` and `p1a`;
// fails_when=`all`/`rec` send the run to approve-as-written while the gate took every recommended plan change, `p1a` approves while a uc item is pending, or an override of an unflagged option approves (answerGate counts it as a plan change); why_new=the #4181 case proves the replies parse, not what the skill does with them; seam=none
const tmpl = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md.tmpl'), 'utf8').replace(/\s+/g, ' ');
const ticks = (s: string) => [...s.matchAll(/`([^`]+)`/g)].map(m => m[1]!);
const topLevel = (text: string): string[] => {
const out: string[] = []; let depth = 0; let code = false; let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '`') code = !code;
else if (!code && ch === '(') depth++;
else if (!code && ch === ')') depth--;
else if (!code && depth === 0 && (ch === ';' || ch === '.') && text[i + 1] === ' ') { out.push(text.slice(start, i).trim()); start = i + 2; }
}
return [...out, text.slice(start).trim()].filter(Boolean);
};
const table = topLevel(/Parse the reply:.*?never guessed\. (.*?) Under `SESSION_KIND/.exec(tmpl)?.[1] ?? '');
const handling = Object.fromEntries([...tmpl.matchAll(/- ([A-E]2?): (.*?)(?= - [A-E]2?: |\*\*Starting)/g)].map(m => [m[1]!, m[2]!]));
const write_ = /Write `\$_AP\/gate\.json`.*?gstack-gate render/.exec(tmpl)?.[0] ?? '';
const p1 = /and `p1` \(approval: (a\) .*? e\) \w+)\)/.exec(write_)?.[1] ?? '';
const parts = p1.split(/(?:^| )([a-e])\) /).slice(1);
const p1Options = parts.flatMap((key, i) => (i % 2 ? [] : [{ key, text: parts[i + 1]!.replace(/ \(recommended\)$/, '') }]));
const p1Recommended = parts.flatMap((key, i) => (i % 2 === 0 && / \(recommended\)$/.test(parts[i + 1]!) ? [key] : []))[0]
?? /recommending `([a-e])`/.exec(tmpl.slice(tmpl.indexOf(p1)))?.[1];
const keep = { key: 'a', text: 'keep your direction', changes_plan: false };
const change = { key: 'b', text: 'take the reviewers\' change', changes_plan: true };
const gate = (): GateList => ({ schema_version: 1, run: 'autoplan-2139', gate_rev: 1, items: [
{ id: 'uc1', title: 'User challenge both voices raised', options: [keep, change], recommended: 'b', kind: 'approval' },
{ id: 'd1', title: 'Taste decision', options: [keep, change], recommended: 'b', kind: 'auto' },
{ id: 'p1', title: 'Plan approval', options: p1Options, recommended: p1Recommended ?? 'a', kind: 'approval' },
] });
/** The skill's options for a reply: the first table row whose condition the gate's parse meets. */
function documented(reply: string, g: GateList, parsed: ReturnType<typeof parseReply>) {
const said = reply.toLowerCase().split(/\s+/);
const optionOf = (c: { id: string; chosen: string }) => g.items.find(i => i.id === c.id)!.options.find(o => o.key === c.chosen)!;
for (const row of table) {
const changed = parsed.choices.filter(c => c.id !== 'p1' && (optionOf(c).changes_plan ?? (/unflagged override/.test(row) && c.how === 'override')));
const at = row.indexOf(' = ');
if (at < 0) continue;
const lhs = row.slice(0, at), rhs = row.slice(at + 3), names = ticks(lhs);
const hit = /\belse\b/.test(lhs) ? true
: names.includes('pending') ? parsed.pending.length > 0
: names.includes('changes_plan') ? changed.length > 0
: names.some(t => said.includes(t) || (/^[a-z]+<n>$/.test(t) && parsed.choices.some(c => c.how === 'override' && said.includes(c.id + c.chosen) && c.id.startsWith(t.slice(0, -3)))));
if (!hit) continue;
const perKind = [...rhs.matchAll(/([A-E]2?) on an? `([a-z]+)<n>`/g)];
const letters = perKind.length ? perKind.filter(m => changed.some(c => c.id.startsWith(m[2]!) && c.id !== 'p1')).map(m => m[1]!)
: /^[A-E](\/[A-E])+$/.test(rhs.trim()) ? [rhs.trim().split('/')[names.findIndex(t => said.includes(t))]!]
: [...rhs.replace(/`[^`]*`/g, '').replace(/\([^)]*\)/g, '').matchAll(/(?:^|[\s,])([A-E]2?)(?=$|[\s,.])/g)].map(m => m[1]!);
return { row, letters: [...new Set(letters)].sort() };
}
return { row: '(no row)', letters: [] as string[] };
}
const approves = (letters: string[]) => letters.filter(l => /mark APPROVED/.test(handling[l] ?? ''));
test('`all` and `rec` apply every recommended plan change, never the plan as first written', () => {
expect(Object.keys(handling)).toEqual(['A', 'B', 'B2', 'C', 'D', 'E']);
expect(p1Options.map(o => o.key)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(write_).toContain('Every `uc<n>`/`d<n>` option sets `changes_plan`');
const g = gate();
const file = write('gate.json', g);
const seen = ['all', 'rec'].map(reply => {
const r = run(['parse', file, '--reply', reply, '--gate-rev', '1', '--json']);
const parsed = JSON.parse(r.out) as ReturnType<typeof parseReply>;
const doc = documented(reply, g, parsed);
return { reply, code: r.code, gate: chosen(parsed), p1: p1Options.find(o => o.key === chosen(parsed).p1)?.text, letters: doc.letters, approves: approves(doc.letters), row: doc.row };
});
expect(seen.map(({ row: _row, p1, ...rest }) => ({ ...rest, p1_as_written: /as-is|as written/.test(p1 ?? '') })))
.toEqual(['all', 'rec'].map(reply => ({ reply, code: EXIT.ok, gate: { uc1: 'b', d1: 'b', p1: 'b' }, letters: ['B', 'B2'], approves: [], p1_as_written: false })));
});
test('`p1a` with a user challenge unanswered approves nothing', () => {
const g = gate();
const file = write('gate.json', g);
const r = run(['parse', file, '--reply', 'p1a', '--gate-rev', '1', '--json']);
const parsed = JSON.parse(r.out) as ReturnType<typeof parseReply>;
const doc = documented('p1a', g, parsed);
expect({ code: r.code, pending: parsed.pending, p1: chosen(parsed).p1, approves: approves(doc.letters), row_names_pending: ticks(doc.row.split(' = ')[0]!).includes('pending') })
.toEqual({ code: EXIT.ok, pending: ['uc1'], p1: 'a', approves: [], row_names_pending: true });
});
test('an override of an unflagged option changes the plan, as answerGate counts it', () => {
const unflagged = [{ key: 'a', text: 'keep your direction' }, { key: 'b', text: 'the alternative' }];
const g: GateList = { schema_version: 1, run: 'autoplan-2139', gate_rev: 1, items: [
{ id: 'd1', title: 'Taste decision', options: unflagged, recommended: 'a', kind: 'auto' },
{ id: 'p1', title: 'Plan approval', options: p1Options, recommended: 'a', kind: 'approval' },
] };
const r = run(['parse', write('gate.json', g), '--reply', 'p1a d1b', '--gate-rev', '1', '--json']);
const parsed = JSON.parse(r.out) as ReturnType<typeof parseReply>;
const doc = documented('p1a d1b', g, parsed);
expect({ code: r.code, d1: parsed.choices.find(c => c.id === 'd1'), letters: doc.letters, approves: approves(doc.letters) })
.toEqual({ code: EXIT.ok, d1: { id: 'd1', chosen: 'b', kind: 'auto', how: 'override' }, letters: ['B'], approves: [] });
});
});