@a-t-h-i/bot-lobby 0.6.0 → 0.6.2

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 (49) hide show
  1. package/README.md +177 -799
  2. package/package.json +1 -1
  3. package/prompts/master.md +17 -0
  4. package/prompts/panel.md +3 -1
  5. package/prompts/planner.md +14 -0
  6. package/prompts/quickfix.md +3 -1
  7. package/prompts/scout.md +3 -0
  8. package/prompts/worker.md +3 -0
  9. package/src/classifier/answers.ts +110 -0
  10. package/src/classifier/classifier.ts +171 -0
  11. package/src/classifier/client.ts +239 -0
  12. package/src/classifier/effort.ts +143 -0
  13. package/src/classifier/files.ts +427 -0
  14. package/src/classifier/hosts.ts +157 -0
  15. package/src/classifier/instance.ts +98 -0
  16. package/src/classifier/limits.ts +37 -0
  17. package/src/classifier/seats.ts +102 -0
  18. package/src/classifier/tools.ts +58 -0
  19. package/src/classifier/triage.ts +203 -0
  20. package/src/execution/agent-runner.ts +5 -0
  21. package/src/index.ts +6 -0
  22. package/src/lobby/keys.ts +1 -1
  23. package/src/lobby/planner.ts +305 -27
  24. package/src/lobby/quickfix.ts +120 -8
  25. package/src/lobby/runtime.ts +136 -5
  26. package/src/lobby/session-files.ts +12 -0
  27. package/src/lobby/sessions.ts +28 -2
  28. package/src/lobby/tabs/home.ts +54 -26
  29. package/src/lobby/tabs/metrics.ts +28 -1
  30. package/src/lobby/tabs/plan.ts +27 -4
  31. package/src/lobby/tabs/quickfix.ts +5 -1
  32. package/src/lobby/tabs/tasks.ts +30 -14
  33. package/src/lobby/view.ts +334 -41
  34. package/src/master/master.ts +95 -29
  35. package/src/pi/commands.ts +24 -4
  36. package/src/pi/events.ts +11 -2
  37. package/src/pi/owner.ts +57 -8
  38. package/src/pi/run-summary.ts +3 -1
  39. package/src/pi/settings-ui.ts +138 -2
  40. package/src/pi/start-task.ts +4 -0
  41. package/src/pi/tools.ts +7 -2
  42. package/src/schemas/configuration.ts +125 -1
  43. package/src/schemas/findings.ts +4 -0
  44. package/src/schemas/task.ts +24 -0
  45. package/src/state/archive.ts +99 -0
  46. package/src/state/inbox.ts +49 -15
  47. package/src/state/metrics.ts +74 -1
  48. package/src/state/presence.ts +99 -0
  49. package/src/workflow/workflow.ts +49 -1
@@ -55,6 +55,9 @@ export interface AgentRequest {
55
55
  onStart?: (handle: AgentHandle) => void;
56
56
  /** Called once per attempt after it ends, before any retry. */
57
57
  onAttemptEnd?: (run: AgentRun) => void;
58
+ /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
59
+ routedFrom?: string;
60
+ route?: string;
58
61
  }
59
62
 
60
63
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
@@ -115,6 +118,8 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
115
118
  attempts,
116
119
  startedAt,
117
120
  ...(request.thinking ? { thinking: request.thinking } : {}),
121
+ ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
122
+ ...(request.route ? { route: request.route } : {}),
118
123
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
119
124
  };
120
125
  }
package/src/index.ts CHANGED
@@ -8,8 +8,12 @@ import { isSubagentProcess } from "./pi/quiet.ts";
8
8
  import { registerDeskClient } from "./desk/client-extension.ts";
9
9
  import { registerLobbyEvents } from "./lobby/runtime.ts";
10
10
  import { registerOwner } from "./pi/owner.ts";
11
+ import { registerClassifier } from "./classifier/instance.ts";
12
+ import { registerClassifierTools } from "./classifier/tools.ts";
11
13
 
12
14
  export default function (pi: ExtensionAPI): void {
15
+ // First, so every session_start handler below finds the classifier bound to this session's keys.
16
+ registerClassifier(pi, CONFIG_DIR_NAME);
13
17
  registerLifecycle(pi, CONFIG_DIR_NAME);
14
18
  // After the lifecycle, so the lobby opens over a task the widget state already knows.
15
19
  registerLobbyEvents(pi, CONFIG_DIR_NAME);
@@ -19,4 +23,6 @@ export default function (pi: ExtensionAPI): void {
19
23
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
20
24
  // Parallel workers share files through the master's file desk.
21
25
  if (isSubagentProcess()) registerDeskClient(pi);
26
+ // Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
27
+ if (isSubagentProcess()) registerClassifierTools(pi);
22
28
  }
package/src/lobby/keys.ts CHANGED
@@ -12,7 +12,7 @@ export const LOBBY_ACTIONS = {
12
12
  settings: { key: "alt+s", help: "bot-lobby settings: each agent's model and thinking, the lobby" },
13
13
  search: { key: "ctrl+f", help: "search the current tab" },
14
14
  savePlan: { key: "ctrl+s", help: "save the plan being made in the Plan tab to the pending tasks" },
15
- sessions: { key: "alt+o", help: "switch session: this window, background sessions, other terminals" },
15
+ sessions: { key: "alt+o", help: "browse sessions: view one, message it, or switch this window to it" },
16
16
  newSession: { key: "alt+n", help: "start a task in a new session, named after it" },
17
17
  toggleAuto: { key: "alt+g", help: "auto mode: the oracle drives the task to completion without asking" },
18
18
  nextTab: { key: "tab", help: "next tab" },
@@ -10,6 +10,12 @@
10
10
  * Each round runs the seats in parallel (read-only; RESEARCH may also use the
11
11
  * web tools), then the oracle over their output. Rounds replay the whole
12
12
  * conversation, so one can be stopped or retried without losing the session.
13
+ * A round limit bounds the grilling: the last round skips the seats and the
14
+ * oracle decides whatever is still open, and later replies only revise.
15
+ * With the classifier on, each round seats only the members whose domain the
16
+ * idea or the latest answers touch (a key press pins a seat on or off), and
17
+ * questions whose recommended option the conversation already makes clearly
18
+ * right are answered without asking.
13
19
  * The agreed plan is saved as a pending task.
14
20
  */
15
21
  import { loadPrompt } from "../prompts/loader.ts";
@@ -23,6 +29,11 @@ import { truncate } from "../text.ts";
23
29
  import type { LobbyFeed } from "./feed.ts";
24
30
  import type { QuickFixProfile } from "./quickfix.ts";
25
31
  import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
32
+ import type { Classifier } from "../classifier/classifier.ts";
33
+ import { chooseSeats } from "../classifier/seats.ts";
34
+ import { autoAnswer, type AutoAnswer } from "../classifier/answers.ts";
35
+ import type { FileHinter } from "../classifier/files.ts";
36
+ import { fellShort, profileLabel, type EffortRouter, type EffortScore } from "../classifier/effort.ts";
26
37
 
27
38
  /** Seats and the oracle read the repository to ask informed questions; they never edit. */
28
39
  export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
@@ -65,6 +76,8 @@ export interface PlannerMessage {
65
76
  at: number;
66
77
  /** The round's questions, attributed, when the panel asked any. */
67
78
  questions?: PanelQuestion[];
79
+ /** Questions the classifier answered with their recommended option instead of asking. */
80
+ decided?: AutoAnswer[];
68
81
  }
69
82
 
70
83
  /** The oracle's reply: its verdict, title, own questions and the draft plan. */
@@ -118,6 +131,46 @@ export interface PlannerDeps {
118
131
  onChange?: () => void;
119
132
  /** Called when a round ends (answered, failed or stopped), so the lobby can put the questions to the user. */
120
133
  onRound?: (session: PlanningSession) => void;
134
+ /** Rounds before the oracle finalizes alone (0 or absent = unlimited); read as each round starts. */
135
+ maxRounds?: () => number;
136
+ /** Chooses the round's seats and answers obvious questions when it is on; absent, every seat sits and you answer everything. */
137
+ classifier?: Classifier;
138
+ /** Likely files for the round (idea and latest answers), and the lookup tool, while file hints are on. */
139
+ hints?: FileHinter;
140
+ /** Lowers a seat's thinking (or model) when the classifier judges the idea simple or trivial; the oracle is never routed. */
141
+ effort?: EffortRouter;
142
+ }
143
+
144
+ /** What the user's turn says when the classifier settled every question of a round. */
145
+ export const CLASSIFIER_CONTINUE = "(No answers needed from me this round: the classifier settled the questions above. Continue.)";
146
+
147
+ /**
148
+ * How a round runs under the limit: `normal` grilling with the seats, the
149
+ * `final` round (the oracle alone, deciding what is open), or `revise` once
150
+ * past the limit (the oracle alone, revising for the user's latest message).
151
+ */
152
+ export type RoundMode = "normal" | "final" | "revise";
153
+
154
+ export function roundMode(round: number, limit: number): RoundMode {
155
+ if (limit <= 0 || round < limit) return "normal";
156
+ return round === limit ? "final" : "revise";
157
+ }
158
+
159
+ /** What the oracle is told to do at the end of its task, by round mode. */
160
+ export function oracleClosing(mode: RoundMode, round: number, limit: number): string {
161
+ if (mode === "final") {
162
+ return `Final round (${round} of ${limit}): no seat runs this round and nothing more is asked. Fold the user's answers into the plan, decide every point still open with its recommended option and list each under ### Assumptions, and set Status READY. Omit the Questions section. Reply in the required output format.`;
163
+ }
164
+ if (mode === "revise") {
165
+ return `The planning round limit (${limit}) is reached: revise the plan for the user's latest message and comments. Ask nothing; decide anything open with its recommended option under ### Assumptions, and keep Status READY. Omit the Questions section. Reply in the required output format.`;
166
+ }
167
+ const bound = limit > 0 ? `Round ${round} of ${limit}; in round ${limit} you settle whatever is still open alone, so ask the decisive questions now. ` : "";
168
+ return `${bound}Continue: 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.`;
169
+ }
170
+
171
+ /** `round 2 of 5`, for a seat's task; empty without a limit. */
172
+ function roundNote(round: number, limit: number): string {
173
+ return limit > 0 ? ` (round ${round} of ${limit})` : "";
121
174
  }
122
175
 
123
176
  /** Split a reply into its top-level `## Section` bodies, keyed by lower-case title. */
@@ -158,6 +211,36 @@ export function parseOption(text: string): PanelOption {
158
211
  return split ? { label: split[1]!.trim(), description: split[2]!.trim() } : { label: flat, description: "" };
159
212
  }
160
213
 
214
+ /** The option the asker recommends: the one marked `(Recommended)`, else the first. */
215
+ export function recommendedOption(question: AskedQuestion): PanelOption | undefined {
216
+ return question.options.find((option) => /\(recommended\)/i.test(option.label)) ?? question.options[0];
217
+ }
218
+
219
+ /** An option's label without its `(Recommended)` marker. */
220
+ export function optionLabel(option: PanelOption): string {
221
+ return option.label.replace(/\s*\(recommended\)\s*/i, " ").trim();
222
+ }
223
+
224
+ /**
225
+ * Record questions nobody will ask any more as assumptions in the plan, each
226
+ * decided with its recommended option: under the plan's `### Assumptions`
227
+ * section when it has one, otherwise in a new one at the end.
228
+ */
229
+ export function appendAssumptions(plan: string, questions: readonly PanelQuestion[], why: string): string {
230
+ if (questions.length === 0) return plan;
231
+ const lines = questions.map((question) => {
232
+ const pick = recommendedOption(question);
233
+ return `- [${question.from}] ${question.text} → ${pick ? optionLabel(pick) : "the oracle's call"} (${why})`;
234
+ });
235
+ const rows = plan.split("\n");
236
+ const heading = rows.findIndex((row) => /^#{2,3}\s+assumptions\s*$/i.test(row.trim()));
237
+ if (heading < 0) return `${plan.trimEnd()}\n\n### Assumptions\n${lines.join("\n")}`;
238
+ let end = rows.findIndex((row, index) => index > heading && /^#{1,3}\s/.test(row));
239
+ if (end < 0) end = rows.length;
240
+ while (end > heading + 1 && rows[end - 1]!.trim() === "") end -= 1;
241
+ return [...rows.slice(0, end), ...lines, ...rows.slice(end)].join("\n");
242
+ }
243
+
161
244
  /** Inline `a) … b) …` choices, when a question carries its options in its own text. */
162
245
  function inlineOptions(question: AskedQuestion): AskedQuestion {
163
246
  if (question.options.length > 0) return question;
@@ -251,6 +334,7 @@ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: Pl
251
334
  for (const message of messages) {
252
335
  const body = message.questions && message.questions.length > 0 ? message.questions.map(questionLine).join("\n") : message.text.trim();
253
336
  lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
337
+ if (message.decided && message.decided.length > 0) lines.push(decidedBlock(message.decided), "");
254
338
  }
255
339
  if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
256
340
  lines.push(closing);
@@ -263,6 +347,14 @@ export interface MemberOutcome {
263
347
  error?: string;
264
348
  }
265
349
 
350
+ /** Questions the classifier answered, as every seat and the oracle read them. */
351
+ export function decidedBlock(decided: readonly AutoAnswer[]): string {
352
+ return [
353
+ "Decided by the classifier (each is the recommended option, which the conversation already makes clearly right; list them under Assumptions, the user can overrule them):",
354
+ ...decided.map((entry) => `- [${entry.from}] ${entry.question} → ${entry.answer} (${entry.probability.toFixed(2)})`),
355
+ ].join("\n");
356
+ }
357
+
266
358
  /** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
267
359
  export function panelSection(outcomes: readonly MemberOutcome[]): string {
268
360
  if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
@@ -293,7 +385,12 @@ export function commentBlock(comments: readonly LineComment[]): string {
293
385
  }
294
386
 
295
387
  /** What the panel said in a round, as the conversation shows it. */
296
- export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
388
+ export function plannerSays(ready: boolean, questions: readonly PanelQuestion[], mode: RoundMode = "normal"): string {
389
+ if (mode !== "normal") {
390
+ return ready
391
+ ? "The round limit is reached: the oracle decided what was still open (see Assumptions). Save the plan, or comment on a line to revise it."
392
+ : "The round limit is reached, but there is no draft yet. Reply or retry to let the oracle write one.";
393
+ }
297
394
  if (ready) return "The panel agrees the plan is clear. Save it as a pending task, or keep refining.";
298
395
  if (questions.length === 0) return "No open questions this round. Save the draft, or add detail.";
299
396
  return questions.map(questionLine).join("\n");
@@ -343,7 +440,19 @@ export class PlanningSession {
343
440
  answered: AskResult[] = [];
344
441
  /** Comments on draft lines, sent with the user's next turn. */
345
442
  lineComments: LineComment[] = [];
443
+ /** How the latest round ran under the round limit. */
444
+ mode: RoundMode = "normal";
445
+ /** Seats pinned on with a key press: they sit every round, whatever the classifier says. */
446
+ readonly pins = new Set<PanelMember>();
447
+ /** Seats the classifier left out of the latest round, with how likely it judged them needed. */
448
+ satOut = new Map<PanelMember, number>();
449
+ /** How each seat left the last round it sat. */
450
+ private readonly lastStatus = new Map<PanelMember, "ready" | "open">();
451
+ /** The last round was started by the classifier settling every question; the next one waits for the user. */
452
+ private autoContinued = false;
346
453
  private controller?: AbortController;
454
+ /** Round attempts, retries included, so each attempt's feed steps stay apart. */
455
+ private attempts = 0;
347
456
  private readonly deps: PlannerDeps;
348
457
  private readonly memberNotes = new Map<PanelMember, string[]>();
349
458
 
@@ -361,16 +470,40 @@ export class PlanningSession {
361
470
  return this.status === "thinking";
362
471
  }
363
472
 
364
- /** Seat or unseat a member for the next round; returns whether it now sits. */
473
+ /** Rounds before the oracle finalizes alone; 0 = unlimited. */
474
+ get limit(): number {
475
+ return Math.max(0, this.deps.maxRounds?.() ?? 0);
476
+ }
477
+
478
+ /** How the next round will run. */
479
+ get nextMode(): RoundMode {
480
+ return roundMode(this.turns + 1, this.limit);
481
+ }
482
+
483
+ /**
484
+ * Seat or unseat a member for the next round; returns whether it now sits.
485
+ * Seating one pins it (it sits every round, whatever the classifier says);
486
+ * unseating keeps it off until seated again.
487
+ */
365
488
  toggle(member: PanelMember): boolean {
366
- if (this.seats.has(member)) this.seats.delete(member);
367
- else this.seats.add(member);
489
+ if (this.seats.has(member)) {
490
+ this.seats.delete(member);
491
+ this.pins.delete(member);
492
+ } else {
493
+ this.seats.add(member);
494
+ this.pins.add(member);
495
+ }
368
496
  this.deps.onChange?.();
369
497
  return this.seats.has(member);
370
498
  }
371
499
 
372
500
  /** Add the user's message (the idea, or answers), with any line comments, and run a round. */
373
501
  async send(text: string): Promise<void> {
502
+ this.autoContinued = false;
503
+ await this.post(text);
504
+ }
505
+
506
+ private async post(text: string): Promise<void> {
374
507
  const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
375
508
  if (!body) return;
376
509
  if (this.busy) throw new Error("the panel is still thinking");
@@ -417,6 +550,8 @@ export class PlanningSession {
417
550
  async retry(): Promise<void> {
418
551
  if (!this.retryable) return;
419
552
  if (this.messages.at(-1)?.role === "planner") this.messages = this.messages.slice(0, -1);
553
+ // A retry replays the same round; it does not use up another one.
554
+ this.turns = Math.max(0, this.turns - 1);
420
555
  await this.turn();
421
556
  }
422
557
 
@@ -439,7 +574,7 @@ export class PlanningSession {
439
574
  }
440
575
 
441
576
  private stepKey(who: string): string {
442
- return `plan-${who}-${this.turns}`;
577
+ return `plan-${who}-${this.attempts}`;
443
578
  }
444
579
 
445
580
  /** Stream one agent's steps and thoughts into its state and the lobby feed. */
@@ -454,7 +589,7 @@ export class PlanningSession {
454
589
  this.deps.onChange?.();
455
590
  }
456
591
 
457
- 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> {
592
+ private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
458
593
  const startedAt = Date.now();
459
594
  let outcome: RunOutcome;
460
595
  try {
@@ -477,23 +612,55 @@ export class PlanningSession {
477
612
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
478
613
  }
479
614
  this.deps.feed?.end(this.stepKey(label), outcome.status !== "success");
480
- appendMetrics(this.deps.root, this.deps.configDir, [planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage)]);
615
+ const metric = planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage);
616
+ appendMetrics(this.deps.root, this.deps.configDir, [routedFrom ? { ...metric, routedFrom } : metric]);
481
617
  return outcome;
482
618
  }
483
619
 
484
- private async runMember(state: MemberState, transcript: string, signal: AbortSignal): Promise<MemberOutcome> {
620
+ /** The read-only tools a seat (or the oracle) gets, plus the file lookup while hints are on. */
621
+ private tools(base: readonly string[]): readonly string[] {
622
+ const extra = this.deps.hints?.tools() ?? [];
623
+ return extra.length > 0 ? [...base, ...extra] : base;
624
+ }
625
+
626
+ /** The round's Likely files, ranked once against the idea and the latest answers. */
627
+ private async likely(signal: AbortSignal): Promise<string> {
628
+ if (!this.deps.hints) return "";
629
+ try {
630
+ return await this.deps.hints.block(`${this.idea()}\n\n${this.latest()}`, signal);
631
+ } catch {
632
+ return "";
633
+ }
634
+ }
635
+
636
+ /** How hard the round's idea is, scored once and shared by every seat. */
637
+ private async scoreRound(signal: AbortSignal): Promise<EffortScore | undefined> {
638
+ if (!this.deps.effort) return undefined;
639
+ try {
640
+ return await this.deps.effort.score(`Plan this with the user, asking only what changes how it is built:\n${this.idea()}\n\n${this.latest()}`, { signal });
641
+ } catch {
642
+ return undefined;
643
+ }
644
+ }
645
+
646
+ private async runMember(state: MemberState, transcript: string, signal: AbortSignal, effort?: EffortScore): Promise<MemberOutcome> {
485
647
  const member = state.member;
486
648
  const label = MEMBER_LABELS[member];
487
649
  const profile = this.deps.memberProfile?.(member) ?? this.deps.profile();
488
- const outcome = await this.run(label, "panel", profile, {
489
- task: `${transcript}\n\nYou are ${label} on the planning panel: ask your seat's open questions, or declare READY.`,
650
+ const request = {
651
+ task: `${transcript}\n\nYou are ${label} on the planning panel${roundNote(this.turns, this.limit)}: ask your seat's open questions, or declare READY.`,
490
652
  systemPrompt: memberPrompt(member, profile.instructions),
491
- tools: member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS,
492
- }, signal, (step) => (state.step = step));
653
+ tools: this.tools(member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS),
654
+ };
655
+ const route = effort ? this.deps.effort?.plan(effort, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }) : undefined;
656
+ let outcome = await this.run(label, "panel", route ? { ...profile, model: route.model, thinking: route.thinking } : profile, request, signal, (step) => (state.step = step), route ? profileLabel(route.from) : undefined);
657
+ // A routed seat that fails asks again on its configured model and thinking.
658
+ if (route && fellShort(outcome) && !signal.aborted) outcome = await this.run(label, "panel", profile, request, signal, (step) => (state.step = step));
493
659
  if (outcome.status === "success") {
494
660
  state.reply = parseMemberReply(outcome.output);
495
661
  state.status = "done";
496
662
  this.memberNotes.set(member, state.reply.notes);
663
+ this.lastStatus.set(member, state.reply.status === "ready" ? "ready" : "open");
497
664
  } else {
498
665
  state.status = "failed";
499
666
  state.error = outcome.error ?? outcome.status;
@@ -510,25 +677,38 @@ export class PlanningSession {
510
677
  this.status = "thinking";
511
678
  this.error = undefined;
512
679
  this.turns += 1;
680
+ this.attempts += 1;
513
681
  this.answered = [];
514
- this.members = PANEL_MEMBERS.filter((member) => this.seats.has(member)).map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
515
- this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
682
+ const limit = this.limit;
683
+ const mode = roundMode(this.turns, limit);
684
+ this.mode = mode;
685
+ // At and past the limit the oracle works alone: the seats have had their rounds.
686
+ const eligible = mode === "normal" ? PANEL_MEMBERS.filter((member) => this.seats.has(member)) : [];
687
+ this.members = [];
688
+ this.satOut = new Map();
689
+ this.step = "reading the conversation";
516
690
  this.deps.onChange?.();
517
691
  try {
518
- const transcript = plannerTranscript(this.messages, this.seed, this.reply?.plan, "");
519
- const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal)));
692
+ const [seated, likely, effort] = await Promise.all([this.chooseSeats(eligible, controller.signal), this.likely(controller.signal), eligible.length > 0 ? this.scoreRound(controller.signal) : undefined]);
693
+ if (controller.signal.aborted) throw new Error("stopped");
694
+ this.members = seated.map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
695
+ this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
696
+ this.deps.onChange?.();
697
+ const transcript = [plannerTranscript(this.messages, this.seed, this.reply?.plan, ""), likely].filter(Boolean).join("\n\n");
698
+ const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal, effort)));
520
699
  if (controller.signal.aborted) throw new Error("stopped");
521
700
  this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
522
701
  this.step = "writing the plan";
523
702
  this.deps.onChange?.();
524
703
  const profile = this.deps.profile();
525
704
  const lead = await this.run(ORACLE_LABEL, "planner", profile, {
526
- 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.`,
705
+ task: `${transcript}\n\n${panelSection(outcomes)}\n\n${oracleClosing(mode, this.turns, limit)}`,
527
706
  systemPrompt: plannerPrompt(profile.instructions),
528
- tools: PLANNER_TOOLS,
707
+ tools: this.tools(PLANNER_TOOLS),
529
708
  }, controller.signal, (step) => (this.step = step));
530
709
  if (controller.signal.aborted) throw new Error("stopped");
531
- this.finishRound(outcomes, lead);
710
+ this.finishRound(outcomes, lead, mode);
711
+ if (mode === "normal" && this.questions.length > 0) await this.answerObvious(controller.signal);
532
712
  } catch (error) {
533
713
  this.error = (error as Error).message;
534
714
  } finally {
@@ -540,26 +720,124 @@ export class PlanningSession {
540
720
  this.deps.onRound?.(this);
541
721
  }
542
722
  // Comments left on draft lines during the round go to the panel now, unless questions wait (they ride with the answers).
543
- if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) await this.send("");
723
+ if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) {
724
+ await this.send("");
725
+ return;
726
+ }
727
+ // The classifier settled every question: the panel folds those answers in without waiting for you, once in a row.
728
+ const settled = this.messages.at(-1)?.decided?.length ?? 0;
729
+ if (!this.error && !this.busy && settled > 0 && this.questions.length === 0 && this.reply?.status !== "ready" && !this.autoContinued) {
730
+ this.autoContinued = true;
731
+ await this.post(CLASSIFIER_CONTINUE);
732
+ }
733
+ }
734
+
735
+ /** The idea as first described, with the source issue. */
736
+ private idea(): string {
737
+ const first = this.messages.find((message) => message.role === "you")?.text ?? "";
738
+ return this.seed ? `Issue #${this.seed.issue.number}: ${this.seed.issue.title}\n\n${this.seed.body}\n\n${first}`.trim() : first;
544
739
  }
545
740
 
546
- /** The oracle's reply sets the round's questions (chosen from the seats' and its own), verdict and draft. */
547
- private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
741
+ /** The user's newest message, with any answers the classifier gave in the round before it. */
742
+ private latest(): string {
743
+ let index = this.messages.length - 1;
744
+ while (index >= 0 && this.messages[index]!.role !== "you") index -= 1;
745
+ if (index < 0) return "";
746
+ const decided = this.messages[index - 1]?.decided;
747
+ return [decided && decided.length > 0 ? decidedBlock(decided) : "", this.messages[index]!.text].filter(Boolean).join("\n\n");
748
+ }
749
+
750
+ /**
751
+ * The seats that sit this round: every eligible seat, unless the classifier
752
+ * judges some unpinned ones not needed. Pinned seats always sit; a failure
753
+ * or a classifier that is off seats everyone.
754
+ */
755
+ private async chooseSeats(eligible: readonly PanelMember[], signal: AbortSignal): Promise<PanelMember[]> {
756
+ const jev = this.deps.classifier;
757
+ const open = eligible.filter((member) => !this.pins.has(member));
758
+ if (!jev || open.length === 0 || !jev.enabled("seats")) return [...eligible];
759
+ this.step = "choosing seats";
760
+ this.deps.onChange?.();
761
+ const decision = await chooseSeats(jev, {
762
+ round: this.turns,
763
+ idea: this.idea(),
764
+ latest: this.latest(),
765
+ ...(this.reply?.plan ? { draft: this.reply.plan } : {}),
766
+ candidates: open.map((member) => ({
767
+ member,
768
+ label: MEMBER_LABELS[member],
769
+ owns: MEMBER_SEATS[member],
770
+ ...(this.lastStatus.has(member) ? { lastStatus: this.lastStatus.get(member)! } : {}),
771
+ notes: this.memberNotes.get(member) ?? [],
772
+ })),
773
+ }, signal);
774
+ if (!decision) return [...eligible];
775
+ const seated = eligible.filter((member) => this.pins.has(member) || decision.seat.has(member));
776
+ for (const member of open) if (!decision.seat.has(member)) this.satOut.set(member, decision.probabilities.get(member) ?? 0);
777
+ const out = [...this.satOut].map(([member, probability]) => `${MEMBER_LABELS[member]} sat out (${probability.toFixed(2)})`);
778
+ this.deps.feed?.log("CLASSIFIER", [`seated ${seated.length > 0 ? seated.map((member) => MEMBER_LABELS[member]).join(", ") : "nobody (the oracle plans alone)"}`, ...out, `${decision.ms} ms`].join(" · "), "info");
779
+ return seated;
780
+ }
781
+
782
+ /**
783
+ * Answer the round's questions the conversation already settles: the
784
+ * classifier's pick must be the recommended option, confident and clearly
785
+ * ahead. They leave the questionnaire and are recorded on the round's
786
+ * message, so every seat and the oracle read them as decided.
787
+ */
788
+ private async answerObvious(signal: AbortSignal): Promise<void> {
789
+ const jev = this.deps.classifier;
790
+ if (!jev?.enabled("answers")) return;
791
+ this.step = "checking for obvious answers";
792
+ this.deps.onChange?.();
793
+ const candidates = this.questions.map((question, index) => {
794
+ const recommended = recommendedOption(question);
795
+ return { index, from: question.from, text: question.text, options: question.options.map((option) => ({ label: optionLabel(option), description: option.description })), recommended: recommended ? optionLabel(recommended) : "" };
796
+ });
797
+ const decided = await autoAnswer(jev, candidates, { request: this.idea(), conversation: plannerTranscript(this.messages.slice(0, -1), this.seed, undefined, ""), ...(this.reply?.plan ? { draft: this.reply.plan } : {}) }, signal);
798
+ if (!decided || decided.length === 0 || signal.aborted) return;
799
+ const settled = new Set(decided.map((entry) => entry.index));
800
+ this.questions = this.questions.filter((_question, index) => !settled.has(index));
801
+ const last = this.messages.at(-1);
802
+ if (last?.role === "planner") {
803
+ const { questions: _asked, ...rest } = last;
804
+ const text = this.questions.length > 0 ? plannerSays(false, this.questions) : "The classifier settled this round's questions with the recommended options; the panel folds them in next.";
805
+ this.messages = [...this.messages.slice(0, -1), { ...rest, text, decided, ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
806
+ }
807
+ this.deps.feed?.log("CLASSIFIER", `answered ${decided.map((entry) => `[${entry.from}] ${entry.answer} (${entry.probability.toFixed(2)})`).join(", ")}; ${this.questions.length} left for you`, "info");
808
+ }
809
+
810
+ /**
811
+ * The oracle's reply sets the round's questions (chosen from the seats' and
812
+ * its own), verdict and draft. At and past the round limit nothing more is
813
+ * asked: any question the oracle still wrote is decided with its
814
+ * recommended option and recorded in the plan's Assumptions, and a plan
815
+ * that exists is READY.
816
+ */
817
+ private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome, mode: RoundMode): void {
548
818
  const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
549
819
  const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
550
- const questions = roundQuestions(reply, seatQuestions);
820
+ let questions = roundQuestions(reply, seatQuestions);
551
821
  const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
552
- const ready = Boolean(reply && reply.status === "ready" && seatsReady);
822
+ let ready = Boolean(reply && reply.status === "ready" && seatsReady);
553
823
  if (reply) {
554
824
  // A round without a plan keeps the previous draft.
555
- const plan = reply.plan ?? this.reply?.plan;
556
- this.reply = { ...reply, status: ready ? "ready" : "grilling", ...(plan ? { plan } : {}) };
825
+ let plan = reply.plan ?? this.reply?.plan;
826
+ if (mode !== "normal") {
827
+ if (plan && questions.length > 0) {
828
+ plan = appendAssumptions(plan, questions, "decided at the round limit");
829
+ this.deps.feed?.log(ORACLE_LABEL, `round limit: decided ${questions.length} open question${questions.length === 1 ? "" : "s"} with the recommended option`, "info");
830
+ }
831
+ questions = [];
832
+ ready = Boolean(plan);
833
+ }
834
+ this.reply = { ...reply, status: ready ? "ready" : "grilling", ...(mode !== "normal" ? { questions: [] } : {}), ...(plan ? { plan } : {}) };
557
835
  } else {
558
836
  this.error = `the oracle's part of the round failed — ${lead.error ?? lead.status}`;
559
837
  }
560
838
  if (!reply && seatQuestions.length === 0) return;
561
839
  this.questions = ready ? [] : questions;
562
- this.messages = [...this.messages, { role: "planner", text: plannerSays(ready, this.questions), at: Date.now(), ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
840
+ this.messages = [...this.messages, { role: "planner", text: plannerSays(ready, this.questions, mode), at: Date.now(), ...(this.questions.length > 0 ? { questions: this.questions } : {}) }];
563
841
  }
564
842
  }
565
843