@a-t-h-i/bot-lobby 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Task planning mode: a planning panel. The user describes an idea (or picks a
3
+ * GitHub issue) and every seat questions them from its own domain — DEV,
4
+ * DESIGN, QA and RESEARCH, each on the model and thinking level its settings
5
+ * name — while the oracle chairs: it reads the seats' questions and notes,
6
+ * folds every answer into the draft plan and asks what no single domain owns.
7
+ * The user answers everyone in one conversation, so every agent that later
8
+ * works on the task starts from the same decisions.
9
+ *
10
+ * Each round runs the seats in parallel (read-only; RESEARCH may also use the
11
+ * web tools), then the oracle over their output. Rounds replay the whole
12
+ * conversation, so one can be stopped or retried without losing the session.
13
+ * The agreed plan is saved as a pending task.
14
+ */
15
+ import { loadPrompt } from "../prompts/loader.ts";
16
+ import { compilePrompt } from "../prompts/compiler.ts";
17
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
18
+ import { describeToolCall } from "../pi/activity.ts";
19
+ import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
20
+ import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
21
+ import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
22
+ import { truncate } from "../text.ts";
23
+ import type { LobbyFeed } from "./feed.ts";
24
+ import type { QuickFixProfile } from "./quickfix.ts";
25
+
26
+ /** Seats and the oracle read the repository to ask informed questions; they never edit. */
27
+ export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
28
+ /** RESEARCH also gets the web tools (from pi-web-access; pi ignores them when it is not installed). */
29
+ export const RESEARCH_PANEL_TOOLS: readonly string[] = [...PLANNER_TOOLS, "web_search", "fetch_content", "source_check", "get_search_content"];
30
+
31
+ /** The chair of the panel, as the Plan tab, the feed and the metrics name it. */
32
+ export const ORACLE_LABEL = "ORACLE";
33
+
34
+ export const MEMBER_LABELS: Record<PanelMember, string> = { backend: "DEV", designer: "DESIGN", qa: "QA", researcher: "RESEARCH" };
35
+
36
+ /** What each seat owns; appended to the seat's system prompt. */
37
+ const MEMBER_SEATS: Record<PanelMember, string> = {
38
+ backend: "You are DEV: the backend and the code — APIs and contracts, data and migrations, errors and edge cases, security and permissions, performance, dependencies, and how the change fits the existing architecture.",
39
+ designer: "You are DESIGN: the user's experience and the frontend — flows, screens and components, empty/loading/error states, copy, the visual language, responsive behavior and accessibility.",
40
+ qa: "You are QA: how the work will be judged — acceptance criteria, the test strategy and where tests live, edge cases and failure modes, environments and browsers, regression risk and the definition of done.",
41
+ researcher: "You are RESEARCH: facts outside the repository — libraries and versions, standards, APIs and documentation, known pitfalls and prior art. Use the web tools when you have them and cite a URL for every external claim; ask the user to choose where the options you found really differ.",
42
+ };
43
+
44
+ export interface PanelQuestion {
45
+ /** DEV, DESIGN, QA, RESEARCH or ORACLE. */
46
+ from: string;
47
+ text: string;
48
+ }
49
+
50
+ export interface PlannerMessage {
51
+ role: "you" | "planner";
52
+ text: string;
53
+ at: number;
54
+ /** The round's questions, attributed, when the panel asked any. */
55
+ questions?: PanelQuestion[];
56
+ }
57
+
58
+ /** The oracle's reply: its verdict, title, own questions and the draft plan. */
59
+ export interface PlannerReply {
60
+ status: "grilling" | "ready";
61
+ title?: string;
62
+ questions: string[];
63
+ plan?: string;
64
+ }
65
+
66
+ /** A seat's reply: whether its domain is settled, its questions and what the plan must respect. */
67
+ export interface MemberReply {
68
+ status: "open" | "ready";
69
+ questions: string[];
70
+ notes: string[];
71
+ }
72
+
73
+ export interface MemberState {
74
+ member: PanelMember;
75
+ status: "thinking" | "done" | "failed";
76
+ step?: string;
77
+ reply?: MemberReply;
78
+ error?: string;
79
+ }
80
+
81
+ export interface PanelNote {
82
+ from: string;
83
+ text: string;
84
+ }
85
+
86
+ /** An issue the session was seeded from, with its text. */
87
+ export interface PlannerSeed {
88
+ issue: IssueRef;
89
+ body: string;
90
+ }
91
+
92
+ export interface PlannerDeps {
93
+ cwd: string;
94
+ root: string;
95
+ configDir: string;
96
+ /** The oracle chairing the panel (the Planner settings entry). */
97
+ profile: () => QuickFixProfile;
98
+ /** A seat's model, thinking and time limit; the oracle's profile when absent. */
99
+ memberProfile?: (member: PanelMember) => QuickFixProfile;
100
+ /** Seats on the panel when the session starts; every seat when absent. */
101
+ panel?: readonly PanelMember[];
102
+ stallTimeoutMs?: number;
103
+ toolStallTimeoutMs?: number;
104
+ feed?: LobbyFeed;
105
+ runProcess?: ProcessRunner;
106
+ onChange?: () => void;
107
+ }
108
+
109
+ /** Split a reply into its top-level `## Section` bodies, keyed by lower-case title. */
110
+ function sections(text: string): Map<string, string> {
111
+ const found = new Map<string, string>();
112
+ let current: string | undefined;
113
+ let body: string[] = [];
114
+ const flush = () => {
115
+ if (current && !found.has(current)) found.set(current, body.join("\n").trim());
116
+ };
117
+ for (const line of text.split("\n")) {
118
+ const match = /^##\s+(.+?)\s*$/.exec(line);
119
+ if (match) {
120
+ flush();
121
+ current = match[1]!.toLowerCase();
122
+ body = [];
123
+ } else if (current) body.push(line);
124
+ }
125
+ flush();
126
+ return found;
127
+ }
128
+
129
+ function listItems(body: string | undefined): string[] {
130
+ if (!body) return [];
131
+ const items: string[] = [];
132
+ for (const line of body.split("\n")) {
133
+ const match = /^\s*(?:\d+[.)]|[-*])\s+(.*\S)\s*$/.exec(line);
134
+ if (match) items.push(match[1]!);
135
+ else if (line.trim() && items.length > 0 && /^\s+/.test(line)) items[items.length - 1] += ` ${line.trim()}`;
136
+ }
137
+ return items;
138
+ }
139
+
140
+ /**
141
+ * Read the oracle's reply. Missing sections degrade gracefully: no status
142
+ * reads as grilling, and a reply with no sections at all becomes one question
143
+ * so the user always sees what was said.
144
+ */
145
+ export function parsePlannerReply(text: string): PlannerReply {
146
+ const parts = sections(text);
147
+ const status = /\bready\b/i.test(parts.get("status") ?? "") ? "ready" : "grilling";
148
+ const title = parts.get("title")?.split("\n").find((line) => line.trim())?.replace(/^[#*\s]+|[*\s]+$/g, "");
149
+ const plan = parts.get("plan") ?? parts.get("draft plan");
150
+ let questions = listItems(parts.get("questions"));
151
+ if (parts.size === 0 && text.trim()) questions = [text.trim()];
152
+ return { status, questions, ...(title ? { title } : {}), ...(plan ? { plan } : {}) };
153
+ }
154
+
155
+ /** Read a seat's reply the same forgiving way: a READY seat asks nothing. */
156
+ export function parseMemberReply(text: string): MemberReply {
157
+ const parts = sections(text);
158
+ const ready = /\bready\b/i.test(parts.get("status") ?? "");
159
+ let questions = ready ? [] : listItems(parts.get("questions"));
160
+ if (parts.size === 0 && text.trim()) questions = [text.trim()];
161
+ return { status: ready ? "ready" : "open", questions, notes: listItems(parts.get("notes")) };
162
+ }
163
+
164
+ function questionLine(question: PanelQuestion, index: number): string {
165
+ return `${index + 1}. [${question.from}] ${question.text}`;
166
+ }
167
+
168
+ /** The conversation, the source issue and the current draft: what every seat and the oracle read. */
169
+ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: PlannerSeed, draft?: string, closing = "Continue: grill the user on what is still open, or declare the plan READY. Reply in the required output format."): string {
170
+ const lines: string[] = [];
171
+ if (seed) {
172
+ lines.push(`## Source: GitHub issue #${seed.issue.number} — ${seed.issue.title}`, "", truncate(seed.body.trim() || "(no description)", 6000), "");
173
+ }
174
+ lines.push("## Conversation so far (oldest first)", "");
175
+ for (const message of messages) {
176
+ const body = message.questions && message.questions.length > 0 ? message.questions.map(questionLine).join("\n") : message.text.trim();
177
+ lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
178
+ }
179
+ if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
180
+ lines.push(closing);
181
+ return lines.join("\n");
182
+ }
183
+
184
+ export interface MemberOutcome {
185
+ member: PanelMember;
186
+ reply?: MemberReply;
187
+ error?: string;
188
+ }
189
+
190
+ /** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
191
+ export function panelSection(outcomes: readonly MemberOutcome[]): string {
192
+ if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
193
+ const blocks = outcomes.map((outcome) => {
194
+ const label = MEMBER_LABELS[outcome.member];
195
+ if (!outcome.reply) return `### ${label} — no answer this round (${outcome.error ?? "failed"})`;
196
+ const { status, questions, notes } = outcome.reply;
197
+ return [
198
+ `### ${label} — ${status === "ready" ? "READY" : "OPEN"}`,
199
+ questions.length > 0 ? `Questions asked of the user:\n${questions.map((question) => `- ${question}`).join("\n")}` : "",
200
+ notes.length > 0 ? `Notes:\n${notes.map((note) => `- ${note}`).join("\n")}` : "",
201
+ ].filter(Boolean).join("\n");
202
+ });
203
+ return ["## Panel this round", "", ...blocks].join("\n\n");
204
+ }
205
+
206
+ /** What the panel said in a round, as the conversation shows it. */
207
+ export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
208
+ if (ready) return "The panel agrees the plan is clear. Press s to save it as a pending task, or keep refining.";
209
+ if (questions.length === 0) return "No open questions this round. Press s to save the draft, or add detail.";
210
+ return questions.map(questionLine).join("\n");
211
+ }
212
+
213
+ export function plannerPrompt(instructions?: string): string {
214
+ const custom = instructions?.trim();
215
+ return custom ? `${loadPrompt("planner.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("planner.md");
216
+ }
217
+
218
+ /** A seat's system prompt: its domain's layers (or the global one for RESEARCH), the panel role and its seat. */
219
+ export function memberPrompt(member: PanelMember, instructions?: string): string {
220
+ const base = member === "researcher"
221
+ ? [loadPrompt("global.md"), instructions?.trim() ? `## Custom Instructions\n\n${instructions.trim()}` : ""].filter(Boolean).join("\n\n---\n\n")
222
+ : compilePrompt({ domain: member, task: "", instructions });
223
+ return [base, loadPrompt("panel.md"), `## Your seat\n\n${MEMBER_SEATS[member]}`].join("\n\n---\n\n");
224
+ }
225
+
226
+ interface RunOutcome {
227
+ status: MetricRecord["status"];
228
+ output: string;
229
+ error?: string;
230
+ model?: string;
231
+ usage: { input: number; output: number; cost: number; turns: number };
232
+ }
233
+
234
+ export class PlanningSession {
235
+ messages: PlannerMessage[] = [];
236
+ seed?: PlannerSeed;
237
+ /** The oracle's latest reply; its plan is the current draft. */
238
+ reply?: PlannerReply;
239
+ /** The latest round's questions, attributed; empty once the panel agrees. */
240
+ questions: PanelQuestion[] = [];
241
+ /** What each seat said the plan must respect, from its latest answer. */
242
+ notes: PanelNote[] = [];
243
+ /** This round's seats (or the last round's once it finished). */
244
+ members: MemberState[] = [];
245
+ /** Seats that sit on the panel next round. */
246
+ readonly seats: Set<PanelMember>;
247
+ status: "idle" | "thinking" = "idle";
248
+ /** What the oracle is doing during its part of the round. */
249
+ step?: string;
250
+ error?: string;
251
+ turns = 0;
252
+ saved?: PlannedTask;
253
+ private controller?: AbortController;
254
+ private readonly deps: PlannerDeps;
255
+ private readonly memberNotes = new Map<PanelMember, string[]>();
256
+
257
+ constructor(deps: PlannerDeps, seed?: PlannerSeed) {
258
+ this.deps = deps;
259
+ this.seats = new Set(deps.panel ?? PANEL_MEMBERS);
260
+ if (seed) this.seed = seed;
261
+ }
262
+
263
+ get title(): string | undefined {
264
+ return this.reply?.title ?? this.seed?.issue.title;
265
+ }
266
+
267
+ get busy(): boolean {
268
+ return this.status === "thinking";
269
+ }
270
+
271
+ /** Seat or unseat a member for the next round; returns whether it now sits. */
272
+ toggle(member: PanelMember): boolean {
273
+ if (this.seats.has(member)) this.seats.delete(member);
274
+ else this.seats.add(member);
275
+ this.deps.onChange?.();
276
+ return this.seats.has(member);
277
+ }
278
+
279
+ /** Add the user's message (the idea, or answers) and run a round. */
280
+ async send(text: string): Promise<void> {
281
+ const body = text.trim();
282
+ if (!body) return;
283
+ if (this.busy) throw new Error("the panel is still thinking");
284
+ this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
285
+ await this.turn();
286
+ }
287
+
288
+ /** Start from the seed alone (an issue) without a user message. */
289
+ async open(): Promise<void> {
290
+ if (this.busy || this.messages.length > 0) return;
291
+ await this.turn();
292
+ }
293
+
294
+ /** Whether `retry` has something to do: the last round failed, was stopped, or lost a seat. */
295
+ get retryable(): boolean {
296
+ if (this.busy) return false;
297
+ const last = this.messages.at(-1);
298
+ if (last?.role === "you") return true;
299
+ return last?.role === "planner" && (Boolean(this.error) || this.members.some((member) => member.status === "failed"));
300
+ }
301
+
302
+ /** Run the last round again, without a new message (replacing its questions when it had any). */
303
+ async retry(): Promise<void> {
304
+ if (!this.retryable) return;
305
+ if (this.messages.at(-1)?.role === "planner") this.messages = this.messages.slice(0, -1);
306
+ await this.turn();
307
+ }
308
+
309
+ cancel(): void {
310
+ this.controller?.abort();
311
+ }
312
+
313
+ /** Save the latest draft as a pending task. */
314
+ save(now = new Date()): PlannedTask {
315
+ const plan = this.reply?.plan;
316
+ if (!plan) throw new Error("there is no draft plan to save yet");
317
+ this.saved = savePlannedTask(this.deps.root, this.deps.configDir, {
318
+ title: this.title ?? this.messages[0]?.text.split("\n")[0] ?? "planned task",
319
+ brief: plan,
320
+ ...(this.seed ? { issue: this.seed.issue } : {}),
321
+ }, now);
322
+ this.deps.feed?.log(ORACLE_LABEL, `saved ${this.saved.id} to pending tasks`, "success");
323
+ this.deps.onChange?.();
324
+ return this.saved;
325
+ }
326
+
327
+ private stepKey(who: string): string {
328
+ return `plan-${who}-${this.turns}`;
329
+ }
330
+
331
+ /** Stream one agent's steps and thoughts into its state and the lobby feed. */
332
+ private onEvent(label: string, event: PiStreamEvent, setStep: (step: string) => void): void {
333
+ if (event.type === "tool_execution_start") {
334
+ const step = describeToolCall(event.toolName, event.args);
335
+ setStep(step);
336
+ this.deps.feed?.step(label, step, this.stepKey(label));
337
+ } else if (event.type === "thought") this.deps.feed?.thought(label, event.text);
338
+ else if (event.type === "thinking" || event.type === "writing") setStep(event.type);
339
+ else return;
340
+ this.deps.onChange?.();
341
+ }
342
+
343
+ private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void): Promise<RunOutcome> {
344
+ const startedAt = Date.now();
345
+ let outcome: RunOutcome;
346
+ try {
347
+ const result = await runPiAgent(
348
+ {
349
+ cwd: this.deps.cwd,
350
+ ...request,
351
+ model: profile.model,
352
+ thinking: profile.thinking,
353
+ timeoutMs: profile.timeoutMs,
354
+ signal,
355
+ stallTimeoutMs: this.deps.stallTimeoutMs,
356
+ toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
357
+ onEvent: (event) => this.onEvent(label, event, setStep),
358
+ },
359
+ this.deps.runProcess ?? spawnPiProcess,
360
+ );
361
+ outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
362
+ } catch (error) {
363
+ outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
364
+ }
365
+ this.deps.feed?.end(this.stepKey(label), outcome.status !== "success");
366
+ appendMetrics(this.deps.root, this.deps.configDir, [planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage)]);
367
+ return outcome;
368
+ }
369
+
370
+ private async runMember(state: MemberState, transcript: string, signal: AbortSignal): Promise<MemberOutcome> {
371
+ const member = state.member;
372
+ const label = MEMBER_LABELS[member];
373
+ const profile = this.deps.memberProfile?.(member) ?? this.deps.profile();
374
+ const outcome = await this.run(label, "panel", profile, {
375
+ task: `${transcript}\n\nYou are ${label} on the planning panel: ask your seat's open questions, or declare READY.`,
376
+ systemPrompt: memberPrompt(member, profile.instructions),
377
+ tools: member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS,
378
+ }, signal, (step) => (state.step = step));
379
+ if (outcome.status === "success") {
380
+ state.reply = parseMemberReply(outcome.output);
381
+ state.status = "done";
382
+ this.memberNotes.set(member, state.reply.notes);
383
+ } else {
384
+ state.status = "failed";
385
+ state.error = outcome.error ?? outcome.status;
386
+ this.deps.feed?.log(label, `planning round failed — ${state.error.split("\n")[0]}`, "error");
387
+ }
388
+ state.step = undefined;
389
+ this.deps.onChange?.();
390
+ return { member, ...(state.reply ? { reply: state.reply } : {}), ...(state.error ? { error: state.error } : {}) };
391
+ }
392
+
393
+ private async turn(): Promise<void> {
394
+ const controller = new AbortController();
395
+ this.controller = controller;
396
+ this.status = "thinking";
397
+ this.error = undefined;
398
+ this.turns += 1;
399
+ this.members = PANEL_MEMBERS.filter((member) => this.seats.has(member)).map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
400
+ this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
401
+ this.deps.onChange?.();
402
+ try {
403
+ const transcript = plannerTranscript(this.messages, this.seed, this.reply?.plan, "");
404
+ const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal)));
405
+ if (controller.signal.aborted) throw new Error("stopped");
406
+ this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
407
+ this.step = "writing the plan";
408
+ this.deps.onChange?.();
409
+ const profile = this.deps.profile();
410
+ const lead = await this.run(ORACLE_LABEL, "planner", profile, {
411
+ task: `${plannerTranscript(this.messages, this.seed, this.reply?.plan, "")}\n\n${panelSection(outcomes)}\n\nContinue: fold the panel's notes and the user's answers into the plan, ask what no seat owns, or declare the plan READY. Reply in the required output format.`,
412
+ systemPrompt: plannerPrompt(profile.instructions),
413
+ tools: PLANNER_TOOLS,
414
+ }, controller.signal, (step) => (this.step = step));
415
+ if (controller.signal.aborted) throw new Error("stopped");
416
+ this.finishRound(outcomes, lead);
417
+ } catch (error) {
418
+ this.error = (error as Error).message;
419
+ } finally {
420
+ this.status = "idle";
421
+ this.step = undefined;
422
+ this.controller = undefined;
423
+ if (this.error) this.deps.feed?.log(ORACLE_LABEL, `planning round failed — ${this.error.split("\n")[0]}`, "error");
424
+ this.deps.onChange?.();
425
+ }
426
+ }
427
+
428
+ /** Merge the seats' and the oracle's answers into the round's questions, verdict and draft. */
429
+ private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
430
+ const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
431
+ const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((text) => ({ from: MEMBER_LABELS[outcome.member], text })));
432
+ const questions = [...(reply?.questions ?? []).map((text) => ({ from: ORACLE_LABEL, text })), ...seatQuestions];
433
+ const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
434
+ const ready = Boolean(reply && reply.status === "ready" && seatsReady);
435
+ if (reply) {
436
+ // A round without a plan keeps the previous draft.
437
+ const plan = reply.plan ?? this.reply?.plan;
438
+ this.reply = { ...reply, status: ready ? "ready" : "grilling", ...(plan ? { plan } : {}) };
439
+ } else {
440
+ this.error = `the oracle's part of the round failed — ${lead.error ?? lead.status}`;
441
+ }
442
+ if (!reply && seatQuestions.length === 0) return;
443
+ this.questions = ready ? [] : questions;
444
+ this.messages = [...this.messages, { role: "planner", text: plannerSays(ready, this.questions), at: Date.now(), ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
445
+ }
446
+ }
447
+
448
+ export function planningMetric(
449
+ kind: "planner" | "panel",
450
+ agent: string,
451
+ turn: number,
452
+ startedAt: number,
453
+ status: MetricRecord["status"],
454
+ profile: QuickFixProfile,
455
+ model: string | undefined,
456
+ usage: { input: number; output: number; cost: number; turns: number },
457
+ now = Date.now(),
458
+ ): MetricRecord {
459
+ const served = model ?? profile.model;
460
+ return {
461
+ id: `${kind}-${agent.toLowerCase()}-${startedAt}-${turn}`,
462
+ kind,
463
+ agent,
464
+ ...(served ? { model: served } : {}),
465
+ thinking: profile.thinking,
466
+ status,
467
+ startedAt: new Date(startedAt).toISOString(),
468
+ durationMs: Math.max(0, now - startedAt),
469
+ ...(usage.turns ? { turns: usage.turns } : {}),
470
+ input: usage.input,
471
+ output: usage.output,
472
+ cost: usage.cost,
473
+ };
474
+ }