diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md index d9bd9b7a5..dbae2e25c 100644 --- a/capy/skill/gstack/captain.md +++ b/capy/skill/gstack/captain.md @@ -120,7 +120,8 @@ one reviewer from another model family reads the unreviewed delta first (`gstack else prints `CARRY: no ()` 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 "`, 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 `, and render again. For a decision that is not an autoplan gate, write the same five parts by hand. diff --git a/lib/captain-brief.ts b/lib/captain-brief.ts index 91e2f19ae..181b00a19 100644 --- a/lib/captain-brief.ts +++ b/lib/captain-brief.ts @@ -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 "\`${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: --owner-reply ""\`, then \`gstack-approvals import --autoplan ${o.outDir} --by \`.`; // 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 })), ''] : []; diff --git a/test/captain-brief.test.ts b/test/captain-brief.test.ts index a71f73c97..da2fc6dfa 100644 --- a/test/captain-brief.test.ts +++ b/test/captain-brief.test.ts @@ -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 "` (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', () => {