fix(captain-brief): tell the relaying agent how a named option answers (rec except <token>)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-30 21:52:42 +00:00
1 parent 4400112f70
commit 4e35a11ee4
3 files changed
+9 -4

No files matched your search

+2 -1
View File
@@ -120,7 +120,8 @@ one reviewer from another model family reads the unreviewed delta first (`gstack
else prints `CARRY: no (<why>)` and goes to the person as today (`docs/unattended.md#carry-forward`).
If the person answers in words ("split it into two PRs"), map the words to tokens, answer, and confirm in one
line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A plan missing a
line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A named option ("B2")
answers as `--reply "rec except <token>"`, with the token from the last line's letter map. A plan missing a
section renders "Not ready for approval" with no reply invitation, and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`):
add the sections, run `gstack-autoplan revise --plan <file>`, and render again. For a decision that is not an
autoplan gate, write the same five parts by hand.
+2 -2
View File
@@ -118,6 +118,7 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string; cap
const need: string[] = ['### What we need from you', ''];
const letter = (n: number) => String.fromCharCode(65 + n);
const ordered = (i: GateItem) => [...i.options.filter(x => x.key === recKey(i)), ...i.options.filter(x => x.key !== recKey(i))];
const example = asked.findIndex(i => i.options.length > 1);
if (!pending) need.push('Nothing. This gate is answered.', '');
else if (missing.length) need.push('Nothing to answer yet: the plan goes out again once it is complete.', '');
else {
@@ -129,13 +130,12 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string; cap
const because = i.recommended_basis?.trim() ? ` (why: ${i.recommended_basis.trim()})` : '';
need.push(`- ${letter(n)}: ${ask}. We could: ${opts.map((x, k) => `${k + 1}) ${plainOption(x.text)}`).join('; ')}. Recommendation: ${plainOption(opts[0]?.text ?? recKey(i))}${note}${because}. ${letter(n)}1 to take recommendation.`);
});
const example = asked.findIndex(i => i.options.length > 1);
need.push('', `**do it** takes every recommendation in this list at full scope, the follow-ons the plan names (docs, trains, cleanups) included, carried through to done with no further ask. A named option${example >= 0 ? ` (for example "${letter(example)}2")` : ''} takes that option instead. Anything else, say it in words: a change that alters the plan reopens its review before anything is built.`, '');
need.push(`Reply: **do it**${example >= 0 ? `, or name options such as **${letter(example)}2**` : ''}`, '');
}
const tokens = rec.map(c => `${c.id}${c.chosen}`).join(' ');
const letters = pending && !missing.length ? asked.flatMap((i, n) => ordered(i).map((x, k) => `${letter(n)}${k + 1}=${i.id}${x.key}`)).join(' ') : '';
const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; "do it" answers as rec;${letters ? ` letters: ${letters};` : ''} plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` +
const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; "do it" answers as rec;${letters ? ` letters: ${letters}; a named option answers as \`--reply "rec except <token>"\`${example >= 0 ? ` (${letter(example)}2 = rec except ${asked[example]!.id}${ordered(asked[example]!)[1]!.key})` : ''};` : ''} plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` +
`\`gstack-autoplan answer --out ${o.outDir} --gate-rev ${list.gate_rev} --reply rec --rec-hash ${hash} --by agent:<id> --owner-reply "<their exact words>"\`, then \`gstack-approvals import --autoplan ${o.outDir} --by <name>\`.`;
// The price sits above the trailer: the relaying agent sends everything above that line, so the person approving sees it.
const cost = inputs.run ? ['### Cost', '', gateCostSentence(gateCost(o.outDir, list, { phase_order: inputs.run.phase_order ?? [], outside_enabled: inputs.run.outside_enabled !== false, spend_cap_usd: inputs.run.spend_cap_usd ?? null, scope_flags: inputs.run.scope_flags })), ''] : [];
+5 -1
View File
@@ -102,7 +102,11 @@ describe('renderCaptainBrief', () => {
expect(ownerPart).not.toContain('gate_rev');
expect(ownerPart).not.toContain('gstack-autoplan revise');
expect(agentPart).toContain(`rec_hash=${recHash(b.gate)}`);
expect(agentPart).toContain('rec resolves to d1a d2b p1b; "do it" answers as rec; letters: A1=d1a A2=d1b B1=d2b B2=d2a C1=p1b C2=p1a C3=p1c C4=p1d C5=p1e;');
expect(agentPart).toContain('rec resolves to d1a d2b p1b; "do it" answers as rec; letters: A1=d1a A2=d1b B1=d2b B2=d2a C1=p1b C2=p1a C3=p1c C4=p1d C5=p1e; a named option answers as `--reply "rec except <token>"` (A2 = rec except d1b);');
const named = parseReply('rec except d1b', b.gate);
expect(named.valid).toBe(true);
expect(named.pending).toEqual([]);
expect(named.choices.find(c => c.id === 'd1')?.chosen).toBe('b');
});
test('a plan missing a section is not ready for approval and invites no reply', () => {