From 5d5ed39fea8b40cf065d6046fc51e9e38b29f56e Mon Sep 17 00:00:00 2001 From: Felipe Date: Thu, 7 May 2026 20:29:37 -0300 Subject: [PATCH 1/2] feat(done): make --report mandatory on every close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: when an agent closes a turn (or a team-lead marks a wish group done), the only audit-trail entry today is "Agent killed" with no actor, no rationale, no summary. When auto-cleanup cascades on wish completion the parent/orchestrator sees N agents vanish with zero context. The "Run \`genie team done\` to clean up" message in notifyWaveCompletion is also misleading — the next line in doneCommand calls autoCleanupTeam() unconditionally, so the message implies cleanup is pending while the team is already disbanded. Change: add -r/--report as a required option on both \`genie done [ref]\` and \`genie wish done \`. Validation lives in the action handlers (not Commander's requiredOption) so we emit a multi-line friendly hint with examples instead of Commander's generic missing-option error. The report flows through: - turnClose() reason for agent-session closes (already supported, was unused for outcome=done) - notifyWaveCompletion mailbox message so the orchestrator sees WHAT shipped, not just WHICH groups closed - console output of doneCommand for terminal observers Wave/wish-complete notification now also names auto-cleanup honestly: "Team will be auto-cleaned. Run \`genie team done\` to confirm or override." instead of implying nothing has happened. This does not change the auto-cleanup behavior itself — that's a separate over-reach (kills team members unrelated to the completed wish, including workspace primary agents) worth a follow-up that scopes killTeamMembers to the wish_slug of the completed wish. Tests: 14 done.test.ts cases pass (12 existing + 2 new for the mandatory-report path). Bundle builds clean. --- src/genie.ts | 8 ++- src/term-commands/done.test.ts | 126 +++++++++++++++++++++++---------- src/term-commands/done.ts | 41 ++++++++--- src/term-commands/state.ts | 14 ++-- src/term-commands/wish.ts | 18 ++++- 5 files changed, 153 insertions(+), 54 deletions(-) diff --git a/src/genie.ts b/src/genie.ts index dc1c2f340..1478a3ecf 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -298,9 +298,13 @@ registerApprovalCommands(program); program .command('done [ref]') .description('Close the current turn (inside an agent session) or mark a wish group done (team-lead, #)') - .action(async (ref: string | undefined) => { + .option( + '-r, --report ', + 'One-line summary of what was completed (REQUIRED — your handoff note for the audit trail and orchestrator)', + ) + .action(async (ref: string | undefined, options: { report?: string }) => { const { doneAction } = await import('./term-commands/done.js'); - await doneAction(ref); + await doneAction(ref, options); }); program diff --git a/src/term-commands/done.test.ts b/src/term-commands/done.test.ts index 092badb0e..39472f7f0 100644 --- a/src/term-commands/done.test.ts +++ b/src/term-commands/done.test.ts @@ -1,6 +1,7 @@ /** * `genie done` command dispatch — agent-session path vs wish-group path - * vs the Group 6 permanent-agent rejection guard. + * vs the Group 6 permanent-agent rejection guard, plus the mandatory + * --report nudge that gives every close a one-line audit trail. * * Uses injected deps (no DB) to isolate routing behavior. */ @@ -9,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import type { TurnCloseResult } from '../lib/turn-close.js'; import { PermanentAgentDoneRejected, doneAction } from './done.js'; +const REPORT = { report: 'shipped foo + bar; smoke green' }; + describe('doneAction dispatch', () => { const originalAgent = process.env.GENIE_AGENT_NAME; @@ -20,11 +23,11 @@ describe('doneAction dispatch', () => { process.env.GENIE_AGENT_NAME = originalAgent; }); - test('agent session + no ref → calls turnClose(outcome=done)', async () => { + test('agent session + no ref → calls turnClose(outcome=done) with report as reason', async () => { process.env.GENIE_AGENT_NAME = 'engineer-g2'; - const calls: Array<{ outcome: string }> = []; - const turnCloseFn = async (opts: { outcome: string }): Promise => { - calls.push({ outcome: opts.outcome }); + const calls: Array<{ outcome: string; reason?: string }> = []; + const turnCloseFn = async (opts: { outcome: string; reason?: string }): Promise => { + calls.push({ outcome: opts.outcome, reason: opts.reason }); return { noop: false, executorId: 'exec-1', outcome: 'done', closedAt: new Date().toISOString() }; }; let wishCalls = 0; @@ -33,28 +36,28 @@ describe('doneAction dispatch', () => { }; const lookupCallingAgent = async () => ({ id: 'agent-task', kind: 'task' as const }); - await doneAction(undefined, { turnCloseFn: turnCloseFn as never, wishDone, lookupCallingAgent }); + await doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, wishDone, lookupCallingAgent }); - expect(calls).toEqual([{ outcome: 'done' }]); + expect(calls).toEqual([{ outcome: 'done', reason: REPORT.report }]); expect(wishCalls).toBe(0); }); - test('positional ref → delegates to wish-group doneCommand', async () => { + test('positional ref → delegates to wish-group doneCommand with report', async () => { process.env.GENIE_AGENT_NAME = 'engineer-g2'; let turnCalls = 0; const turnCloseFn = async (): Promise => { turnCalls++; return { noop: false, executorId: 'x', outcome: 'done', closedAt: null }; }; - const wishRefs: string[] = []; - const wishDone = async (ref: string) => { - wishRefs.push(ref); + const wishCalls: Array<{ ref: string; report: string }> = []; + const wishDone = async (ref: string, report: string) => { + wishCalls.push({ ref, report }); }; - await doneAction('my-wish#2', { turnCloseFn: turnCloseFn as never, wishDone }); + await doneAction('my-wish#2', REPORT, { turnCloseFn: turnCloseFn as never, wishDone }); expect(turnCalls).toBe(0); - expect(wishRefs).toEqual(['my-wish#2']); + expect(wishCalls).toEqual([{ ref: 'my-wish#2', report: REPORT.report }]); }); test('positional ref without GENIE_AGENT_NAME → still delegates to wish path', async () => { @@ -63,15 +66,15 @@ describe('doneAction dispatch', () => { turnCalls++; return { noop: false, executorId: 'x', outcome: 'done', closedAt: null }; }; - const wishRefs: string[] = []; - const wishDone = async (ref: string) => { - wishRefs.push(ref); + const wishCalls: Array<{ ref: string; report: string }> = []; + const wishDone = async (ref: string, report: string) => { + wishCalls.push({ ref, report }); }; - await doneAction('other-wish#1', { turnCloseFn: turnCloseFn as never, wishDone }); + await doneAction('other-wish#1', REPORT, { turnCloseFn: turnCloseFn as never, wishDone }); expect(turnCalls).toBe(0); - expect(wishRefs).toEqual(['other-wish#1']); + expect(wishCalls).toEqual([{ ref: 'other-wish#1', report: REPORT.report }]); }); test('no ref + no GENIE_AGENT_NAME → exits with error', async () => { @@ -83,7 +86,7 @@ describe('doneAction dispatch', () => { }) as never; try { - await expect(doneAction(undefined)).rejects.toThrow(/process.exit stub/); + await expect(doneAction(undefined, REPORT)).rejects.toThrow(/process.exit stub/); expect(exitCode).toBe(2); } finally { process.exit = originalExit; @@ -101,7 +104,61 @@ describe('doneAction dispatch', () => { const lookupCallingAgent = async () => ({ id: 'agent-task', kind: 'task' as const }); // Should complete without throwing - await doneAction(undefined, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); + await doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); + }); + + // ========================================================================== + // Mandatory --report nudge + // ========================================================================== + + describe('mandatory --report', () => { + test('missing report → exits 2 with friendly hint, never calls turnClose or wishDone', async () => { + process.env.GENIE_AGENT_NAME = 'engineer-g2'; + const originalExit = process.exit; + let exitCode: number | undefined; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('process.exit stub'); + }) as never; + + let turnCalls = 0; + const turnCloseFn = async (): Promise => { + turnCalls++; + return { noop: false, executorId: 'x', outcome: 'done', closedAt: null }; + }; + let wishCalls = 0; + const wishDone = async () => { + wishCalls++; + }; + + try { + await expect(doneAction(undefined, {}, { turnCloseFn: turnCloseFn as never, wishDone })).rejects.toThrow( + /process.exit stub/, + ); + expect(exitCode).toBe(2); + expect(turnCalls).toBe(0); + expect(wishCalls).toBe(0); + } finally { + process.exit = originalExit; + } + }); + + test('whitespace-only report → treated as missing', async () => { + process.env.GENIE_AGENT_NAME = 'engineer-g2'; + const originalExit = process.exit; + let exitCode: number | undefined; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('process.exit stub'); + }) as never; + + try { + await expect(doneAction(undefined, { report: ' \n ' })).rejects.toThrow(/process.exit stub/); + expect(exitCode).toBe(2); + } finally { + process.exit = originalExit; + } + }); }); // ========================================================================== @@ -126,13 +183,10 @@ describe('doneAction dispatch', () => { const lookupCallingAgent = async () => ({ id: 'team-lead-uuid', kind: 'permanent' as const }); try { - await expect(doneAction(undefined, { turnCloseFn: turnCloseFn as never, lookupCallingAgent })).rejects.toThrow( - /process.exit stub/, - ); + await expect( + doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }), + ).rejects.toThrow(/process.exit stub/); expect(exitCode).toBe(4); - // turnClose must NOT be reached on rejection — the side effects - // (state='done', current_executor_id=NULL) are exactly what the - // permanent-agent guard prevents. expect(turnCalls).toBe(0); } finally { process.exit = originalExit; @@ -148,7 +202,7 @@ describe('doneAction dispatch', () => { }; const lookupCallingAgent = async () => ({ id: 'engineer-g6-uuid', kind: 'task' as const }); - await doneAction(undefined, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); + await doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); expect(turnCalls).toBe(1); }); @@ -161,9 +215,7 @@ describe('doneAction dispatch', () => { }; const lookupCallingAgent = async () => null; - await doneAction(undefined, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); - // We could not prove permanence, so the existing turnClose path runs; - // its own ghost-executor recovery / error reporting takes over. + await doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); expect(turnCalls).toBe(1); }); @@ -176,9 +228,7 @@ describe('doneAction dispatch', () => { }; const lookupCallingAgent = async () => ({ id: 'agent-degraded', kind: null }); - await doneAction(undefined, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); - // kind=null defaults to "let it through" — same posture as the - // shouldResume chokepoint, which treats unknown kind as task-bound. + await doneAction(undefined, REPORT, { turnCloseFn: turnCloseFn as never, lookupCallingAgent }); expect(turnCalls).toBe(1); }); @@ -204,14 +254,14 @@ describe('doneAction dispatch', () => { lookupCalls++; return { id: 'team-lead-uuid', kind: 'permanent' as const }; }; - const wishRefs: string[] = []; - const wishDone = async (ref: string) => { - wishRefs.push(ref); + const wishCalls: Array<{ ref: string; report: string }> = []; + const wishDone = async (ref: string, report: string) => { + wishCalls.push({ ref, report }); }; - await doneAction('my-wish#3', { wishDone, lookupCallingAgent }); + await doneAction('my-wish#3', REPORT, { wishDone, lookupCallingAgent }); expect(lookupCalls).toBe(0); - expect(wishRefs).toEqual(['my-wish#3']); + expect(wishCalls).toEqual([{ ref: 'my-wish#3', report: REPORT.report }]); }); }); }); diff --git a/src/term-commands/done.ts b/src/term-commands/done.ts index 976cfa74d..09934178d 100644 --- a/src/term-commands/done.ts +++ b/src/term-commands/done.ts @@ -55,13 +55,27 @@ export interface CallingAgentLookup { interface DoneActionDeps { /** Wish-group-done fallback. Injected for tests. */ - wishDone?: (ref: string) => Promise; + wishDone?: (ref: string, report: string) => Promise; /** Turn-close path. Injected for tests. */ turnCloseFn?: typeof turnClose; /** Lookup the calling agent's id + kind. Injected for tests. */ lookupCallingAgent?: () => Promise; } +/** User-facing nudge when --report is missing. Mandatory so every close + * leaves a one-line trail in events + mailbox notifications, instead of + * the silent "agent vanished" mystery. */ +const REPORT_MISSING_HINT = [ + '❌ genie done requires --report "".', + ' This is your handoff note — it lands in the audit trail and the wave/wish-complete', + ' notification so the orchestrator (and humans) can see WHY a close happened', + ' without having to read your transcript.', + '', + ' Examples:', + " genie done --report 'shipped auth bridge happy-path; CSRF deferred to followup'", + " genie done dev-local-auth-bridge#3 --report 'group 3 done — fixtures + smoke'", +].join('\n'); + /** * Default lookup: resolve the calling agent's id + kind via GENIE_EXECUTOR_ID. * @@ -99,34 +113,45 @@ async function rejectIfPermanent(deps: DoneActionDeps): Promise { } } -async function runAgentSessionPath(deps: DoneActionDeps): Promise { +async function runAgentSessionPath(deps: DoneActionDeps, report: string): Promise { await rejectIfPermanent(deps); const fn = deps.turnCloseFn ?? turnClose; - const result = await fn({ outcome: 'done' }); + const result = await fn({ outcome: 'done', reason: report }); if (result.noop) { console.log(`ℹ️ Executor ${result.executorId} already closed — no-op.`); } else { console.log(`✅ Turn closed: outcome=done, executor=${result.executorId}`); + console.log(` Report: ${report}`); } } -export async function doneAction(ref: string | undefined, deps: DoneActionDeps = {}): Promise { +export async function doneAction( + ref: string | undefined, + options: { report?: string }, + deps: DoneActionDeps = {}, +): Promise { const agentName = process.env.GENIE_AGENT_NAME; + const report = options.report?.trim(); + + if (!report) { + console.error(REPORT_MISSING_HINT); + process.exit(2); + } try { if (!ref && agentName) { - await runAgentSessionPath(deps); + await runAgentSessionPath(deps, report); return; } if (ref) { const fallback = deps.wishDone ?? - (async (r: string) => { + (async (r: string, rpt: string) => { const { doneCommand } = await import('./state.js'); - await doneCommand(r); + await doneCommand(r, rpt); }); - await fallback(ref); + await fallback(ref, report); return; } diff --git a/src/term-commands/state.ts b/src/term-commands/state.ts index d5f793d0f..28ff8055b 100644 --- a/src/term-commands/state.ts +++ b/src/term-commands/state.ts @@ -358,6 +358,7 @@ async function resolveNotificationTargets(): Promise<{ leader: string; spawner?: async function notifyWaveCompletion( waveResult: { waveName: string; waveGroups: string[] }, wishComplete: boolean, + report: string, ): Promise { console.log(` 🌊 ${waveResult.waveName} complete! All groups done: ${waveResult.waveGroups.join(', ')}`); try { @@ -365,9 +366,10 @@ async function notifyWaveCompletion( const repoPath = process.cwd(); const { leader, spawner } = await resolveNotificationTargets(); + const reportLine = `\n Report: ${report}`; const message = wishComplete - ? `WISH COMPLETE — all groups done: [${waveResult.waveGroups.join(', ')}]. Run \`genie team done\` to clean up.` - : `${waveResult.waveName} complete. All groups done: [${waveResult.waveGroups.join(', ')}]. Run /review or advance to next wave.`; + ? `WISH COMPLETE — all groups done: [${waveResult.waveGroups.join(', ')}].${reportLine}\n Team will be auto-cleaned. Run \`genie team done\` to confirm or override.` + : `${waveResult.waveName} complete. All groups done: [${waveResult.waveGroups.join(', ')}].${reportLine}\n Run /review or advance to next wave.`; // Notify leader const result = await protocolRouter.sendMessage(repoPath, 'cli', leader, message); @@ -395,11 +397,15 @@ async function notifyWaveCompletion( * `genie done #` — complete a group, push work, notify team-lead * on wave completion, and auto-kill the calling agent's tmux pane. */ -export async function doneCommand(ref: string): Promise { +export async function doneCommand(ref: string, report: string): Promise { + if (!report?.trim()) { + throw new Error('doneCommand: report is required (one-line summary of what shipped in this group).'); + } try { const { slug, group } = parseRef(ref); const result = await wishState.completeGroup(slug, group); console.log(`✅ Group "${group}" marked as done in wish "${slug}"`); + console.log(` Report: ${report}`); if (result.completedAt) { console.log(` Completed at: ${formatTimestamp(result.completedAt)}`); @@ -426,7 +432,7 @@ export async function doneCommand(ref: string): Promise { // Wave completion detection + team-lead notification const waveResult = await detectWaveCompletion(slug, group); if (waveResult) { - await notifyWaveCompletion(waveResult, wishComplete); + await notifyWaveCompletion(waveResult, wishComplete, report); } // If entire wish is complete, auto-trigger team cleanup diff --git a/src/term-commands/wish.ts b/src/term-commands/wish.ts index 2f3f62d7d..3a0a1f3e2 100644 --- a/src/term-commands/wish.ts +++ b/src/term-commands/wish.ts @@ -457,8 +457,22 @@ export function registerWishCommands(program: Command): void { wish .command('done ') .description('Mark a wish group as done (format: #)') - .action(async (ref: string) => { - await doneCommand(ref); + .option( + '-r, --report ', + 'One-line summary of what was completed (REQUIRED — handoff note for audit trail and orchestrator)', + ) + .action(async (ref: string, options: { report?: string }) => { + const report = options.report?.trim(); + if (!report) { + console.error( + '❌ genie wish done requires --report "".\n' + + ' This is your handoff note — it lands in the audit trail and the wave/wish-complete\n' + + ' notification so the orchestrator can see WHY a close happened.\n' + + " Example: genie wish done my-wish#3 --report 'group 3 done — fixtures + smoke'", + ); + process.exit(2); + } + await doneCommand(ref, report); }); wish From 0e0a1890ea4b669ca3be6a3df3a1c3569270531f Mon Sep 17 00:00:00 2001 From: Felipe Date: Thu, 7 May 2026 20:36:44 -0300 Subject: [PATCH 2/2] feat(done): reframe --report as full handoff, not one-liner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording ("one-line summary of what you did") understated what the report is for. The report is the orchestrator's primary view into a closing turn — the only summary anyone reading later will see without replaying the transcript. A one-liner is almost never enough. - CLI hint now asks for a structured handoff: goal attempted, what shipped, verified vs unverified, what's left or deferred, surprises. - Help text and wish-done error mirror the same structure. - Multi-line reports render as fenced "--- Handoff ---" blocks in both console output and the wave/wish-complete mailbox so the structure survives renderers and the indented "Report:" prefix doesn't mangle line breaks. - Help text explicitly tells the user to pass via heredoc for multi-line reports — the natural path for a real session summary. No API changes; tests still pass (14/14). --- src/genie.ts | 2 +- src/term-commands/done.ts | 40 ++++++++++++++++++++++++++++---------- src/term-commands/state.ts | 16 ++++++++++----- src/term-commands/wish.ts | 25 +++++++++++++++++++----- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/genie.ts b/src/genie.ts index 1478a3ecf..320a57348 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -300,7 +300,7 @@ program .description('Close the current turn (inside an agent session) or mark a wish group done (team-lead, #)') .option( '-r, --report ', - 'One-line summary of what was completed (REQUIRED — your handoff note for the audit trail and orchestrator)', + 'Full session handoff — what shipped, what is verified, what is left, surprises, decisions. REQUIRED. As long as it needs to be (multi-line OK; pass via heredoc).', ) .action(async (ref: string | undefined, options: { report?: string }) => { const { doneAction } = await import('./term-commands/done.js'); diff --git a/src/term-commands/done.ts b/src/term-commands/done.ts index 09934178d..c868dd5e4 100644 --- a/src/term-commands/done.ts +++ b/src/term-commands/done.ts @@ -63,17 +63,35 @@ interface DoneActionDeps { } /** User-facing nudge when --report is missing. Mandatory so every close - * leaves a one-line trail in events + mailbox notifications, instead of - * the silent "agent vanished" mystery. */ + * leaves a full handoff trail in events + mailbox notifications, instead + * of the silent "agent vanished" mystery. The report is the orchestrator's + * primary view into what happened — make it count. */ const REPORT_MISSING_HINT = [ - '❌ genie done requires --report "".', - ' This is your handoff note — it lands in the audit trail and the wave/wish-complete', - ' notification so the orchestrator (and humans) can see WHY a close happened', - ' without having to read your transcript.', + '❌ genie done requires --report "".', '', - ' Examples:', - " genie done --report 'shipped auth bridge happy-path; CSRF deferred to followup'", - " genie done dev-local-auth-bridge#3 --report 'group 3 done — fixtures + smoke'", + ' This is your handoff to the orchestrator. It lands in the audit trail and the', + ' wave/wish-complete notification, and is the ONLY summary anyone reading later', + ' will see without replaying your transcript. Write it like you are briefing the', + ' next person on call.', + '', + ' Cover:', + ' • What you attempted (the actual goal of this turn / group)', + ' • What shipped — files changed, PRs opened, migrations run, services touched', + ' • What is verified vs unverified (tests passed? smoke run? CI green?)', + ' • What is left, blocked, or deferred — and why', + ' • Surprises or decisions a future agent needs to know (data losses, infra', + ' quirks, hooks fired, anything non-obvious)', + '', + ' Length: as long as it needs to be. A one-liner is almost never enough.', + ' Multi-line is fine — pass via heredoc or a file:', + " genie done --report \"$(cat <<'EOF'", + ' Goal: wire dev-local auth bridge for hv tenant.', + ' Shipped: PR #143 (fixtures), PR #144 (smoke). Both green on CI.', + " Verified: 'make smoke' passed locally; tenant-A login round-trip OK.", + ' Left: CSRF rotation deferred to followup (issue #1245).', + ' Notes: had to bump core@1.260507.5 — desktop shell rebuild required.', + ' EOF', + ' )"', ].join('\n'); /** @@ -121,7 +139,9 @@ async function runAgentSessionPath(deps: DoneActionDeps, report: string): Promis console.log(`ℹ️ Executor ${result.executorId} already closed — no-op.`); } else { console.log(`✅ Turn closed: outcome=done, executor=${result.executorId}`); - console.log(` Report: ${report}`); + console.log('--- Handoff ---'); + console.log(report.trimEnd()); + console.log('--- End handoff ---'); } } diff --git a/src/term-commands/state.ts b/src/term-commands/state.ts index 28ff8055b..a4b4db8c8 100644 --- a/src/term-commands/state.ts +++ b/src/term-commands/state.ts @@ -366,10 +366,12 @@ async function notifyWaveCompletion( const repoPath = process.cwd(); const { leader, spawner } = await resolveNotificationTargets(); - const reportLine = `\n Report: ${report}`; + // Render the report as a fenced block so multi-line handoffs survive + // markdown / mailbox renderers without losing the line structure. + const reportBlock = `\n\n--- Handoff ---\n${report.trimEnd()}\n--- End handoff ---\n`; const message = wishComplete - ? `WISH COMPLETE — all groups done: [${waveResult.waveGroups.join(', ')}].${reportLine}\n Team will be auto-cleaned. Run \`genie team done\` to confirm or override.` - : `${waveResult.waveName} complete. All groups done: [${waveResult.waveGroups.join(', ')}].${reportLine}\n Run /review or advance to next wave.`; + ? `WISH COMPLETE — all groups done: [${waveResult.waveGroups.join(', ')}].${reportBlock}\nTeam will be auto-cleaned. Run \`genie team done\` to confirm or override.` + : `${waveResult.waveName} complete. All groups done: [${waveResult.waveGroups.join(', ')}].${reportBlock}\nRun /review or advance to next wave.`; // Notify leader const result = await protocolRouter.sendMessage(repoPath, 'cli', leader, message); @@ -399,13 +401,17 @@ async function notifyWaveCompletion( */ export async function doneCommand(ref: string, report: string): Promise { if (!report?.trim()) { - throw new Error('doneCommand: report is required (one-line summary of what shipped in this group).'); + throw new Error( + 'doneCommand: report is required — full group handoff (what shipped, verified, left, surprises). Not a one-liner.', + ); } try { const { slug, group } = parseRef(ref); const result = await wishState.completeGroup(slug, group); console.log(`✅ Group "${group}" marked as done in wish "${slug}"`); - console.log(` Report: ${report}`); + console.log('--- Handoff ---'); + console.log(report.trimEnd()); + console.log('--- End handoff ---'); if (result.completedAt) { console.log(` Completed at: ${formatTimestamp(result.completedAt)}`); diff --git a/src/term-commands/wish.ts b/src/term-commands/wish.ts index 3a0a1f3e2..db164d3a9 100644 --- a/src/term-commands/wish.ts +++ b/src/term-commands/wish.ts @@ -459,16 +459,31 @@ export function registerWishCommands(program: Command): void { .description('Mark a wish group as done (format: #)') .option( '-r, --report ', - 'One-line summary of what was completed (REQUIRED — handoff note for audit trail and orchestrator)', + 'Full group handoff — what shipped, what is verified, what is left, surprises, decisions. REQUIRED. Multi-line OK (pass via heredoc).', ) .action(async (ref: string, options: { report?: string }) => { const report = options.report?.trim(); if (!report) { console.error( - '❌ genie wish done requires --report "".\n' + - ' This is your handoff note — it lands in the audit trail and the wave/wish-complete\n' + - ' notification so the orchestrator can see WHY a close happened.\n' + - " Example: genie wish done my-wish#3 --report 'group 3 done — fixtures + smoke'", + '❌ genie wish done requires --report "".\n' + + '\n' + + " This is the orchestrator's handoff. It lands in the audit trail and the\n" + + ' wave/wish-complete notification — the ONLY summary anyone reading later\n' + + ' will see without replaying your transcript.\n' + + '\n' + + ' Cover: what was attempted, what shipped, what is verified vs unverified,\n' + + ' what is left or deferred, and any surprises or decisions a future agent\n' + + ' needs to know. Length: as long as it needs to be. Multi-line is fine.\n' + + '\n' + + ' Example (heredoc):\n' + + " genie wish done my-wish#3 --report \"$(cat <<'EOF'\n" + + ' Goal: wire dev-local fixtures + smoke for group 3.\n' + + ' Shipped: PR #143 + PR #144, both merged to dev.\n' + + ' Verified: make smoke passed; tenant-A login round-trip OK.\n' + + ' Left: CSRF rotation deferred to issue #1245.\n' + + ' Notes: bumped core@1.260507.5 — desktop rebuild required.\n' + + ' EOF\n' + + ' )"', ); process.exit(2); }