@a-t-h-i/bot-lobby 0.6.7 → 0.6.9

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 (60) hide show
  1. package/README.md +182 -16
  2. package/package.json +5 -1
  3. package/prompts/master.md +15 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +19 -5
  9. package/src/ask/state.ts +8 -2
  10. package/src/ask/tool.ts +3 -1
  11. package/src/ask/view.ts +6 -3
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/excalidraw/check.ts +31 -0
  16. package/src/excalidraw/client.ts +266 -0
  17. package/src/excalidraw/room.ts +118 -0
  18. package/src/excalidraw/scene.ts +746 -0
  19. package/src/excalidraw/sessions.ts +302 -0
  20. package/src/excalidraw/tools.ts +223 -0
  21. package/src/execution/agent-runner.ts +2 -0
  22. package/src/execution/pi-runner.ts +9 -3
  23. package/src/execution/workspace.ts +185 -0
  24. package/src/index.ts +3 -0
  25. package/src/knowledge/edit.ts +109 -0
  26. package/src/knowledge/notes.ts +143 -0
  27. package/src/knowledge/selector.ts +1 -1
  28. package/src/knowledge/store.ts +16 -2
  29. package/src/lobby/ask.ts +42 -21
  30. package/src/lobby/issues.ts +1 -1
  31. package/src/lobby/knowledge.ts +179 -0
  32. package/src/lobby/layout.ts +95 -18
  33. package/src/lobby/planner.ts +205 -8
  34. package/src/lobby/pr-review.ts +360 -0
  35. package/src/lobby/pulls.ts +250 -0
  36. package/src/lobby/quickfix.ts +2 -0
  37. package/src/lobby/runtime.ts +121 -13
  38. package/src/lobby/split.ts +230 -0
  39. package/src/lobby/tabs/excalidraw.ts +95 -0
  40. package/src/lobby/tabs/git.ts +162 -0
  41. package/src/lobby/tabs/knowledge.ts +135 -0
  42. package/src/lobby/tabs/plan.ts +3 -1
  43. package/src/lobby/tabs/tasks.ts +11 -2
  44. package/src/lobby/view.ts +611 -32
  45. package/src/master/master.ts +39 -13
  46. package/src/pi/commands.ts +12 -7
  47. package/src/pi/model-support.ts +9 -0
  48. package/src/pi/plan-checklist.ts +42 -0
  49. package/src/pi/route.ts +2 -1
  50. package/src/pi/settings-ui.ts +41 -1
  51. package/src/pi/start-flags.ts +14 -0
  52. package/src/pi/start-task.ts +56 -5
  53. package/src/pi/tools.ts +4 -2
  54. package/src/schemas/configuration.ts +32 -3
  55. package/src/schemas/task.ts +15 -0
  56. package/src/state/backlog.ts +27 -4
  57. package/src/state/metrics.ts +2 -2
  58. package/src/state/persistence.ts +5 -5
  59. package/src/text.ts +38 -0
  60. package/src/workflow/workflow.ts +11 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The Knowledge tab's side of the disk: every agent's knowledge files, one
3
+ * file read as entries with the user's notes on them, and the edits the user
4
+ * can make — replace, add or delete an entry, edit the whole file — plus
5
+ * notes. Every write goes through the compactor's path, which archives the
6
+ * previous version first (`archive/<Agent>/…bak`), so an edit can be undone by
7
+ * hand. An entry is found again by its exact text; when the file changed on
8
+ * disk since the tab drew it, the edit is refused with a line saying so.
9
+ */
10
+ import { join } from "node:path";
11
+ import { compactKnowledgeFile } from "../knowledge/compactor.ts";
12
+ import { findEntry, insertAfter, parseEntries, removeEntry, replaceEntry, type KnowledgeEntry } from "../knowledge/edit.ts";
13
+ import { addNote, moveNotes, readNotes, removeNote, removeNotesOn, type KnowledgeNote } from "../knowledge/notes.ts";
14
+ import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
15
+ import { readFirstExisting } from "../knowledge/store.ts";
16
+ import { dataRoot, readDataRoots } from "../state/project.ts";
17
+
18
+ /** What each knowledge file is, in words. */
19
+ export const FILE_LABELS: Record<string, string> = {
20
+ "knowledge.md": "Knowledge",
21
+ "decisions.md": "Decisions",
22
+ "completed-tasks.md": "Completed tasks",
23
+ "standards.md": "Standards",
24
+ "design-language.md": "Design language",
25
+ "engineering-standards.md": "Engineering standards",
26
+ "testing-standards.md": "Testing standards",
27
+ };
28
+
29
+ /** Who the agents are, as the lobby names them. */
30
+ export const AGENT_LABELS: Record<KnowledgeAgent, string> = { master: "Master (oracle)", designer: "Designer", backend: "Backend", qa: "QA" };
31
+
32
+ export interface KnowledgeFileInfo {
33
+ agent: KnowledgeAgent;
34
+ file: string;
35
+ label: string;
36
+ chars: number;
37
+ /** Past the compaction threshold: the Master should compact it. */
38
+ over: boolean;
39
+ notes: number;
40
+ }
41
+
42
+ export interface KnowledgeView extends KnowledgeFileInfo {
43
+ content: string;
44
+ entries: KnowledgeEntry[];
45
+ /** Notes on entries this file has. */
46
+ attached: KnowledgeNote[];
47
+ /** Notes whose entry has changed or gone: kept, shown apart. */
48
+ detached: KnowledgeNote[];
49
+ }
50
+
51
+ /** An entry picked in the tab: its text, and which of several identical ones it is. */
52
+ export interface EntryRef {
53
+ text: string;
54
+ occurrence: number;
55
+ }
56
+
57
+ export interface KnowledgeDeps {
58
+ root: string;
59
+ configDir: string;
60
+ /** Characters past which a file is over its compaction threshold. */
61
+ threshold: () => number;
62
+ /** Backups of a file kept in the archive. */
63
+ backups: () => number;
64
+ sessionId?: () => string | undefined;
65
+ now?: () => Date;
66
+ }
67
+
68
+ const CHANGED = "that entry changed on disk since it was drawn — r reloads the file";
69
+
70
+ export class KnowledgeBook {
71
+ private readonly deps: KnowledgeDeps;
72
+
73
+ constructor(deps: KnowledgeDeps) {
74
+ this.deps = deps;
75
+ }
76
+
77
+ private get data(): string {
78
+ return dataRoot(this.deps.root, this.deps.configDir);
79
+ }
80
+
81
+ private read(agent: KnowledgeAgent, file: string): string {
82
+ return readFirstExisting(readDataRoots(this.deps.root, this.deps.configDir).map((root) => join(knowledgeDir(root, agent), file)));
83
+ }
84
+
85
+ /** Every agent's files in the order the tab lists them, with their size and notes. */
86
+ files(): KnowledgeFileInfo[] {
87
+ const notes = readNotes(this.data);
88
+ return (Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]).flatMap((agent) =>
89
+ KNOWLEDGE_FILES[agent].map((file) => {
90
+ const chars = this.read(agent, file).length;
91
+ return { agent, file, label: FILE_LABELS[file] ?? file, chars, over: chars > this.deps.threshold(), notes: notes.filter((note) => note.agent === agent && note.file === file).length };
92
+ }),
93
+ );
94
+ }
95
+
96
+ /** One file as entries with their notes. */
97
+ open(agent: KnowledgeAgent, file: string): KnowledgeView {
98
+ const content = this.read(agent, file);
99
+ const entries = parseEntries(content);
100
+ const notes = readNotes(this.data).filter((note) => note.agent === agent && note.file === file);
101
+ const known = new Set(entries.map((entry) => entry.text));
102
+ return {
103
+ agent,
104
+ file,
105
+ label: FILE_LABELS[file] ?? file,
106
+ chars: content.length,
107
+ over: content.length > this.deps.threshold(),
108
+ notes: notes.length,
109
+ content,
110
+ entries,
111
+ attached: notes.filter((note) => known.has(note.entry)),
112
+ detached: notes.filter((note) => !known.has(note.entry)),
113
+ };
114
+ }
115
+
116
+ /** Write a file's new content with the previous one archived; the notice says where it went. */
117
+ private write(agent: KnowledgeAgent, file: string, content: string): string {
118
+ compactKnowledgeFile({ dataRoot: this.data, agent, file, content, backupCount: this.deps.backups(), ...(this.deps.now ? { now: this.deps.now() } : {}) });
119
+ return `saved ${agent}/${file} — the version before is in archive/${AGENT_DIR_NAMES[agent]}`;
120
+ }
121
+
122
+ /** Replace one entry with `text`, exactly as written; its notes move with it. */
123
+ edit(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
124
+ const content = this.read(agent, file);
125
+ const entry = findEntry(content, ref.text, ref.occurrence);
126
+ if (!entry) return CHANGED;
127
+ if (!text.trim()) return "nothing to save — d d deletes an entry, esc cancels";
128
+ const next = replaceEntry(content, entry, text);
129
+ if (next.trim() === content.trim()) return "nothing changed";
130
+ const notice = this.write(agent, file, next);
131
+ const moved = moveNotes(this.data, agent, file, entry.text, text.replace(/\s+$/, ""), this.deps.now?.());
132
+ return `${notice}${moved > 0 ? `; ${moved} note${moved === 1 ? "" : "s"} stayed with it` : ""}`;
133
+ }
134
+
135
+ /** Add a bullet after an entry (at the end when there is none). */
136
+ add(agent: KnowledgeAgent, file: string, after: EntryRef | undefined, text: string): string {
137
+ const content = this.read(agent, file);
138
+ const entry = after ? findEntry(content, after.text, after.occurrence) : undefined;
139
+ if (after && !entry) return CHANGED;
140
+ const next = insertAfter(content, entry, text);
141
+ if (next === content) return "nothing to add";
142
+ return this.write(agent, file, next);
143
+ }
144
+
145
+ /** Delete an entry, and the notes on it. */
146
+ remove(agent: KnowledgeAgent, file: string, ref: EntryRef): string {
147
+ const content = this.read(agent, file);
148
+ const entry = findEntry(content, ref.text, ref.occurrence);
149
+ if (!entry) return CHANGED;
150
+ const notice = this.write(agent, file, removeEntry(content, entry));
151
+ const gone = removeNotesOn(this.data, agent, file, entry.text, this.deps.now?.());
152
+ return `${notice}${gone > 0 ? `; its ${gone} note${gone === 1 ? "" : "s"} went with it` : ""}`;
153
+ }
154
+
155
+ /** Replace a whole file (edited in pi's editor). */
156
+ replaceFile(agent: KnowledgeAgent, file: string, text: string): string {
157
+ const content = this.read(agent, file);
158
+ if (text.trim() === content.trim()) return "nothing changed";
159
+ return this.write(agent, file, text);
160
+ }
161
+
162
+ /** Leave a note on an entry: every agent that reads this knowledge reads it under the entry. */
163
+ comment(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
164
+ const entry = findEntry(this.read(agent, file), ref.text, ref.occurrence);
165
+ if (!entry) return CHANGED;
166
+ try {
167
+ addNote(this.data, { agent, file, entry: entry.text, text, ...(this.deps.sessionId?.() ? { by: this.deps.sessionId!()! } : {}) }, this.deps.now?.());
168
+ } catch (error) {
169
+ return (error as Error).message;
170
+ }
171
+ return "note saved — every agent reads it under this entry";
172
+ }
173
+
174
+ /** Take a note back. */
175
+ unnote(id: string): string {
176
+ removeNote(this.data, id, this.deps.now?.());
177
+ return "note removed";
178
+ }
179
+ }
@@ -91,6 +91,9 @@ export function wrapHanging(lead: string, text: string, width: number): string[]
91
91
  return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
92
92
  }
93
93
 
94
+ /** The mark before a git branch name, in the lobby's title and a task's details. */
95
+ export const BRANCH_GLYPH = "⎇";
96
+
94
97
  /** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
95
98
  export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
96
99
  if (width <= 0) return "";
@@ -268,21 +271,84 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
268
271
  return { from, to: from + size };
269
272
  }
270
273
 
271
- /** Page buttons in the bottom border of a pane that scrolls: two pages up, one up, one down, two down. */
272
- export const PAGER = "▲▲ ▲ ▼ ▼▼";
273
- /** Columns the pager takes with a space on each side. */
274
- export const PAGER_WIDTH = PAGER.length + 2;
275
- /** Narrowest box that has room for the pager beside its corners. */
276
- const PAGER_MIN_WIDTH = PAGER_WIDTH + 6;
277
-
278
- /** Which page button a column of a scrollable pane's bottom border is: -2, -1, 1 or 2 pages (negative up), or undefined. */
279
- export function pagerButton(width: number, column: number): -2 | -1 | 1 | 2 | undefined {
280
- if (width < PAGER_MIN_WIDTH) return undefined;
281
- const from = column - width;
282
- if (from >= -12 && from <= -10) return -2;
283
- if (from >= -9 && from <= -7) return -1;
284
- if (from >= -6 && from <= -5) return 1;
285
- if (from >= -4 && from <= -2) return 2;
274
+ /** Lines a page moves a pane: its rows less one, so a line of context carries over. */
275
+ export function pageStep(rows: number): number {
276
+ return Math.max(1, rows - 1);
277
+ }
278
+
279
+ /** How many pages a pane showing `rows` lines at a time holds out of `total`. */
280
+ export function pageCount(total: number, rows: number): number {
281
+ return total <= rows ? 1 : 1 + Math.ceil((total - rows) / pageStep(rows));
282
+ }
283
+
284
+ /**
285
+ * The page a pane showing lines from `start` is on, counted so that the top is
286
+ * page 1 and the bottom the last, and a page's worth of scrolling from either
287
+ * end lands exactly on the next number: a log that opens at its newest line
288
+ * and a detail that opens at its first both step through the same numbers.
289
+ */
290
+ export function pageOf(total: number, rows: number, start: number): number {
291
+ const end = Math.max(0, total - rows);
292
+ const behind = Math.ceil(Math.max(0, end - Math.max(0, start)) / pageStep(rows));
293
+ return Math.max(1, pageCount(total, rows) - behind);
294
+ }
295
+
296
+ /** One piece of the pager: a button, where the pane is, or what sits between them. */
297
+ export interface PagerPart {
298
+ text: string;
299
+ role: "up" | "down" | "where" | "between";
300
+ }
301
+
302
+ /** Fewest columns of border kept either side of the pager, beside its two spaces. */
303
+ const PAGER_MARGIN = 3;
304
+
305
+ /**
306
+ * The pager in a scrolling pane's bottom border, as words a newcomer can read:
307
+ * `▲ prev · page 2/5 · next ▼`, shortening to `▲ prev · 2/5 · next ▼` and
308
+ * `▲ 2/5 ▼` as the pane narrows, and to nothing when even that will not fit
309
+ * in `room` columns. The page number is padded to the width of the page count,
310
+ * so the buttons stay where they are as it changes and a click can be traced
311
+ * from the pane's size alone.
312
+ */
313
+ export function pager(total: number, rows: number, start: number, room: number): PagerPart[] | undefined {
314
+ const pages = pageCount(total, rows);
315
+ const count = String(pages);
316
+ const of = `${String(pageOf(total, rows, start)).padStart(count.length)}/${pages}`;
317
+ const between = (text: string): PagerPart => ({ text, role: "between" });
318
+ const up = (text: string): PagerPart => ({ text, role: "up" });
319
+ const down = (text: string): PagerPart => ({ text, role: "down" });
320
+ const where = (text: string): PagerPart => ({ text, role: "where" });
321
+ const variants: PagerPart[][] = [
322
+ [up("▲ prev"), between(" · "), where(`page ${of}`), between(" · "), down("next ▼")],
323
+ [up("▲ prev"), between(" · "), where(of), between(" · "), down("next ▼")],
324
+ [up("▲"), between(" "), where(of), between(" "), down("▼")],
325
+ ];
326
+ return variants.find((parts) => parts.reduce((sum, part) => sum + textWidth(part.text), 0) <= room);
327
+ }
328
+
329
+ /** Columns a box of `width` leaves for the pager's text: two spaces around it and a margin of border on each side. */
330
+ function pagerRoom(width: number): number {
331
+ return width - 2 - 2 - PAGER_MARGIN;
332
+ }
333
+
334
+ /**
335
+ * Which button a column of a scrollable pane's bottom border is: -1 (a page
336
+ * up) or 1 (a page down), or undefined. The buttons take their words and the
337
+ * space beside them, so a click a little off still lands.
338
+ */
339
+ export function pagerButton(width: number, column: number, total: number, rows: number): -1 | 1 | undefined {
340
+ const parts = pager(total, rows, 0, pagerRoom(width));
341
+ if (!parts) return undefined;
342
+ const size = parts.reduce((sum, part) => sum + textWidth(part.text), 0);
343
+ // The text ends one space before the corner: `… next ▼ ╯`.
344
+ const origin = width - 2 - size;
345
+ let at = origin;
346
+ for (const part of parts) {
347
+ const from = part.role === "up" ? origin - 1 : at;
348
+ const to = part.role === "down" ? width - 1 : at + textWidth(part.text);
349
+ if (column >= from && column < to) return part.role === "up" ? -1 : part.role === "down" ? 1 : undefined;
350
+ at += textWidth(part.text);
351
+ }
286
352
  return undefined;
287
353
  }
288
354
 
@@ -309,9 +375,20 @@ export function box(width: number, height: number, content: readonly string[], o
309
375
  const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
310
376
  const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
311
377
  const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
312
- // A pane with more lines than rows carries page buttons in its bottom border; `pagerButton` finds them again for clicks.
313
- const pager = thumb && width >= PAGER_MIN_WIDTH ? ` ${PAGER} ` : "";
314
- const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - textWidth(pager)))}${pager ? paint(theme, focused ? "accent" : "muted", pager) : ""}${edge("╯")}`;
378
+ // A pane with more lines than rows carries a pager in its bottom border; `pagerButton` finds its buttons again for clicks.
379
+ const parts = thumb && options.scroll ? pager(options.scroll.total, height - 2, options.scroll.start, pagerRoom(width)) : undefined;
380
+ const atTop = (options.scroll?.start ?? 0) <= 0;
381
+ const atEnd = options.scroll ? options.scroll.start >= options.scroll.total - (height - 2) : true;
382
+ const paintPart = (part: PagerPart): string => {
383
+ if (part.role === "between") return paint(theme, "dim", part.text);
384
+ if (part.role === "where") return paint(theme, focused ? "text" : "muted", part.text);
385
+ // A button that cannot go further is dimmed: the pane is at that end.
386
+ if (part.role === "up" ? atTop : atEnd) return paint(theme, "dim", part.text);
387
+ return bold(theme, paint(theme, focused ? "accent" : "text", part.text));
388
+ };
389
+ const pagerText = parts ? ` ${parts.map(paintPart).join("")} ` : "";
390
+ const pagerWidth = parts ? parts.reduce((sum, part) => sum + textWidth(part.text), 0) + 2 : 0;
391
+ const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - pagerWidth))}${pagerText}${edge("╯")}`;
315
392
  return [top, ...rows, bottom];
316
393
  }
317
394
 
@@ -22,14 +22,18 @@ import { loadPrompt } from "../prompts/loader.ts";
22
22
  import { compilePrompt } from "../prompts/compiler.ts";
23
23
  import { withFallback } from "../execution/fallback.ts";
24
24
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
25
+ import { grantOption, type Grant } from "../excalidraw/sessions.ts";
25
26
  import { describeToolCall } from "../pi/activity.ts";
26
27
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
27
28
  import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
29
+ import { planSteps } from "../pi/plan-checklist.ts";
30
+ import { KEEP_WHOLE, MAX_SPLIT_REVISIONS, partBrief, partInfo, parseSplit, splitFailedQuestion, splitLabel, splitProblems, splitPrompt, splitQuestion, splitRequest, type SplitProposal } from "./split.ts";
31
+ import type { AskQuestion, AskResult } from "../ask/types.ts";
28
32
  import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
29
33
  import { truncate } from "../text.ts";
30
34
  import type { LobbyFeed } from "./feed.ts";
31
35
  import type { QuickFixProfile } from "./quickfix.ts";
32
- import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
36
+ import { MAX_QUESTIONS } from "./ask.ts";
33
37
  import type { Classifier } from "../classifier/classifier.ts";
34
38
  import { chooseSeats } from "../classifier/seats.ts";
35
39
  import { autoAnswer, type AutoAnswer } from "../classifier/answers.ts";
@@ -71,6 +75,13 @@ export interface PanelQuestion extends AskedQuestion {
71
75
  from: string;
72
76
  }
73
77
 
78
+ /** One of the panel's questions as the user's reply settled it: answered, or left open (`answer` absent). */
79
+ export interface SettledQuestion {
80
+ from: string;
81
+ question: string;
82
+ answer?: string;
83
+ }
84
+
74
85
  export interface PlannerMessage {
75
86
  role: "you" | "planner";
76
87
  text: string;
@@ -79,6 +90,8 @@ export interface PlannerMessage {
79
90
  questions?: PanelQuestion[];
80
91
  /** Questions the classifier answered with their recommended option instead of asking. */
81
92
  decided?: AutoAnswer[];
93
+ /** On the user's reply: which of the questions before it were answered, so they are never asked again. */
94
+ settled?: SettledQuestion[];
82
95
  }
83
96
 
84
97
  /** The oracle's reply: its verdict, title, own questions and the draft plan. */
@@ -337,6 +350,8 @@ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: Pl
337
350
  lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
338
351
  if (message.decided && message.decided.length > 0) lines.push(decidedBlock(message.decided), "");
339
352
  }
353
+ const settled = messages.flatMap((message) => message.settled ?? []);
354
+ if (settled.length > 0) lines.push(settledBlock(settled), "");
340
355
  if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
341
356
  lines.push(closing);
342
357
  return lines.join("\n");
@@ -356,6 +371,55 @@ export function decidedBlock(decided: readonly AutoAnswer[]): string {
356
371
  ].join("\n");
357
372
  }
358
373
 
374
+ /** One line of at most `max` characters, for the activity log. */
375
+ function brief(text: string, max: number): string {
376
+ const flat = text.replace(/\s+/g, " ").trim();
377
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
378
+ }
379
+
380
+ /** Two questions this alike (by shared words) ask the same thing. */
381
+ const SAME_QUESTION = 0.8;
382
+
383
+ /** Words that carry no topic: a rewording changes them without changing the question. */
384
+ const FILLER = new Set(["a", "an", "the", "of", "to", "in", "on", "at", "for", "with", "and", "or", "is", "are", "be", "been", "it", "its", "this", "that", "do", "does", "did", "we", "you", "i", "should", "would", "could", "can", "will", "shall", "must", "which", "what", "how", "so", "as", "by", "from", "than", "then", "us", "our", "your"]);
385
+
386
+ function questionWords(text: string): Set<string> {
387
+ const words = text.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").split(/\s+/).filter(Boolean);
388
+ const content = words.filter((word) => !FILLER.has(word));
389
+ return new Set(content.length > 0 ? content : words);
390
+ }
391
+
392
+ /** Whether two questions ask the same thing: the same topic words in any order, or nearly all of them. */
393
+ export function sameQuestion(a: string, b: string): boolean {
394
+ const left = questionWords(a);
395
+ const right = questionWords(b);
396
+ if (left.size === 0 || right.size === 0) return false;
397
+ let shared = 0;
398
+ for (const word of left) if (right.has(word)) shared += 1;
399
+ if (shared === left.size && shared === right.size) return true;
400
+ return Math.min(left.size, right.size) >= 3 && shared / (left.size + right.size - shared) >= SAME_QUESTION;
401
+ }
402
+
403
+ /**
404
+ * Split a round's questions into those still to ask and the repeats of ones
405
+ * the user already settled (answered, or left for the oracle to decide): the
406
+ * user never gets the same question twice, whatever the models write.
407
+ */
408
+ export function withoutSettled(questions: readonly PanelQuestion[], settled: readonly SettledQuestion[]): { asked: PanelQuestion[]; repeats: PanelQuestion[] } {
409
+ const asked: PanelQuestion[] = [];
410
+ const repeats: PanelQuestion[] = [];
411
+ for (const question of questions) (settled.some((entry) => sameQuestion(entry.question, question.text)) ? repeats : asked).push(question);
412
+ return { asked, repeats };
413
+ }
414
+
415
+ /** What the user settled, as every seat and the oracle read it: closed questions, never to be asked again. */
416
+ export function settledBlock(settled: readonly SettledQuestion[]): string {
417
+ return [
418
+ "## Already settled with the user (closed: never ask these again, in any wording)",
419
+ ...settled.map((entry) => `- [${entry.from}] ${entry.question} → ${entry.answer ? entry.answer.replace(/\s*\n\s*/g, " / ") : "(left unanswered: decide it yourself with its recommended option and list it under Assumptions)"}`),
420
+ ].join("\n");
421
+ }
422
+
359
423
  /** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
360
424
  export function panelSection(outcomes: readonly MemberOutcome[]): string {
361
425
  if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
@@ -439,6 +503,8 @@ export class PlanningSession {
439
503
  saved?: PlannedTask;
440
504
  /** Questionnaires answered so far for this round's questions, so stopping one resumes where it left off. */
441
505
  answered: AskResult[] = [];
506
+ /** The tasks the plan was saved as when it was split (`saved` is the first). */
507
+ savedParts: PlannedTask[] = [];
442
508
  /** Comments on draft lines, sent with the user's next turn. */
443
509
  lineComments: LineComment[] = [];
444
510
  /** How the latest round ran under the round limit. */
@@ -498,21 +564,30 @@ export class PlanningSession {
498
564
  return this.seats.has(member);
499
565
  }
500
566
 
501
- /** Add the user's message (the idea, or answers), with any line comments, and run a round. */
502
- async send(text: string): Promise<void> {
567
+ /**
568
+ * Add the user's message (the idea, or answers), with any line comments, and
569
+ * run a round. `settled` says which of the open questions the message
570
+ * answered (or left), so the panel is never allowed to ask them again.
571
+ */
572
+ async send(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
503
573
  this.autoContinued = false;
504
- await this.post(text);
574
+ await this.post(text, settled);
505
575
  }
506
576
 
507
- private async post(text: string): Promise<void> {
577
+ private async post(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
508
578
  const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
509
579
  if (!body) return;
510
580
  if (this.busy) throw new Error("the panel is still thinking");
511
581
  this.lineComments = [];
512
- this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
582
+ this.messages = [...this.messages, { role: "you", text: body, at: Date.now(), ...(settled && settled.length > 0 ? { settled: [...settled] } : {}) }];
513
583
  await this.turn();
514
584
  }
515
585
 
586
+ /** Every question the user has settled so far, oldest first. */
587
+ get settled(): SettledQuestion[] {
588
+ return this.messages.flatMap((message) => message.settled ?? []);
589
+ }
590
+
516
591
  /** The round's questions still wait for answers. */
517
592
  get awaitingAnswers(): boolean {
518
593
  return !this.busy && this.questions.length > 0;
@@ -574,6 +649,121 @@ export class PlanningSession {
574
649
  return this.saved;
575
650
  }
576
651
 
652
+ /** Save each part of a split plan as its own pending task, in order, each knowing the others. */
653
+ saveParts(proposal: SplitProposal, now = new Date()): PlannedTask[] {
654
+ const plan = this.reply?.plan;
655
+ if (!plan) throw new Error("there is no draft plan to save yet");
656
+ const steps = planSteps(plan);
657
+ const group = `SPLIT-${now.getTime().toString(36)}`;
658
+ // Part 1 is saved last of all timestamps' newest, so the newest-first pending list reads in part order.
659
+ const parts = proposal.tasks.map((task, index) => savePlannedTask(this.deps.root, this.deps.configDir, {
660
+ title: task.title,
661
+ brief: partBrief({ plan, steps, proposal, index }),
662
+ ...(this.seed ? { issue: this.seed.issue } : {}),
663
+ split: partInfo(proposal, index, group),
664
+ }, new Date(now.getTime() + (proposal.tasks.length - index))));
665
+ this.savedParts = parts;
666
+ this.saved = parts[0];
667
+ this.deps.feed?.log(ORACLE_LABEL, `split the plan into ${parts.length} tasks: ${parts.map((part) => part.id).join(", ")}`, "success");
668
+ this.deps.onChange?.();
669
+ return parts;
670
+ }
671
+
672
+ /**
673
+ * The oracle's proposal for splitting the plan: run, read, and checked
674
+ * against the rules (once more with the problems when it breaks one).
675
+ * `feedback` is the user's change request on an earlier proposal. A string
676
+ * is the reason there is no proposal.
677
+ */
678
+ private async proposeSplit(plan: string, steps: readonly string[], feedback: { previous: SplitProposal; words: string } | undefined, signal: AbortSignal): Promise<SplitProposal | string> {
679
+ const profile = this.deps.profile();
680
+ const title = this.title ?? "the plan";
681
+ let rejected: string[] = [];
682
+ let reason = "";
683
+ for (let attempt = 0; attempt < 2; attempt += 1) {
684
+ this.step = attempt === 0 ? "deciding how to split the plan" : "fixing the split";
685
+ this.deps.onChange?.();
686
+ const lead = await this.run(ORACLE_LABEL, "planner", profile, {
687
+ task: splitRequest({ title, plan, steps, ...(feedback ? { revision: feedback } : {}), ...(rejected.length > 0 ? { rejected } : {}) }),
688
+ systemPrompt: splitPrompt(profile.instructions),
689
+ tools: this.tools(PLANNER_TOOLS),
690
+ ...grantOption("planner"),
691
+ }, signal, (step) => (this.step = step));
692
+ if (signal.aborted) throw new Error("stopped");
693
+ if (lead.status !== "success") return lead.error ?? lead.status;
694
+ try {
695
+ const proposal = parseSplit(lead.output);
696
+ rejected = splitProblems(proposal, steps.length);
697
+ if (rejected.length === 0) return proposal;
698
+ reason = rejected[0]!;
699
+ } catch (error) {
700
+ rejected = [(error as Error).message];
701
+ reason = rejected[0]!;
702
+ }
703
+ }
704
+ return reason;
705
+ }
706
+
707
+ /**
708
+ * Save the plan as a pending task; when it has more steps than
709
+ * `splitAbove`, first let the oracle propose splitting it into up to five
710
+ * tasks and let the user decide: take it, keep the plan whole, or say what to
711
+ * change (the oracle revises, a few times). Nothing the user leaves open is
712
+ * decided for them: a questionnaire they put away saves nothing. Returns the
713
+ * notice for the lobby.
714
+ */
715
+ async saveWithSplit(ask: (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>, options: { splitAbove: number }): Promise<string> {
716
+ const plan = this.reply?.plan;
717
+ if (!plan) throw new Error("there is no draft plan to save yet");
718
+ const steps = planSteps(plan);
719
+ const unagreed = this.reply?.status === "ready" ? "" : " (the panel had not agreed yet)";
720
+ const whole = () => `saved ${this.save().id} to the pending tasks — start it from the Tasks tab${unagreed}`;
721
+ if (options.splitAbove <= 0 || steps.length <= options.splitAbove) return whole();
722
+ if (this.busy) return "the panel is still working — save again once it is done, and the oracle will look at splitting this plan";
723
+ const controller = new AbortController();
724
+ this.controller = controller;
725
+ this.status = "thinking";
726
+ this.error = undefined;
727
+ this.deps.onChange?.();
728
+ try {
729
+ let proposal = await this.proposeSplit(plan, steps, undefined, controller.signal);
730
+ if (typeof proposal === "string") {
731
+ const reason = proposal;
732
+ this.deps.feed?.log(ORACLE_LABEL, `could not split the plan — ${reason.split("\n")[0]}`, "warning");
733
+ this.step = "waiting for you";
734
+ const answer = (await ask([splitFailedQuestion(reason)], controller.signal)).answers[0];
735
+ if (controller.signal.aborted) throw new Error("stopped");
736
+ return answer?.answer === "Save it as one task" ? whole() : "the plan was not saved — ctrl+s tries again";
737
+ }
738
+ for (let revisions = 0; ; revisions += 1) {
739
+ this.step = "waiting for you";
740
+ this.deps.onChange?.();
741
+ const result = await ask([splitQuestion({ stepCount: steps.length, proposal, steps, revisions })], controller.signal);
742
+ // A stopped questionnaire comes back empty too; it was stopped, not left open.
743
+ if (controller.signal.aborted) throw new Error("stopped");
744
+ const answer = result.answers[0];
745
+ if (!answer) return "the split question was left open, so nothing was saved — ctrl+s asks again";
746
+ if (answer.kind === "option") {
747
+ if (answer.answer === KEEP_WHOLE) return whole();
748
+ const parts = this.saveParts(proposal);
749
+ return `split into ${parts.length} tasks — ${parts.map((part) => part.id).join(", ")}; start them from the Tasks tab, in order${unagreed}`;
750
+ }
751
+ if (revisions >= MAX_SPLIT_REVISIONS) return `that was the last revision, and nothing was saved — ctrl+s asks again and the oracle starts over`;
752
+ const revised = await this.proposeSplit(plan, steps, { previous: proposal, words: answer.answer ?? "" }, controller.signal);
753
+ if (typeof revised === "string") return `the oracle could not apply that (${revised.split("\n")[0]}) — nothing was saved; ctrl+s asks again`;
754
+ proposal = revised;
755
+ }
756
+ } catch (error) {
757
+ if (controller.signal.aborted) return "stopped — nothing was saved";
758
+ return `could not save the plan: ${(error as Error).message}`;
759
+ } finally {
760
+ this.status = "idle";
761
+ this.step = undefined;
762
+ this.controller = undefined;
763
+ this.deps.onChange?.();
764
+ }
765
+ }
766
+
577
767
  private stepKey(who: string): string {
578
768
  return `plan-${who}-${this.attempts}`;
579
769
  }
@@ -590,7 +780,7 @@ export class PlanningSession {
590
780
  this.deps.onChange?.();
591
781
  }
592
782
 
593
- 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> {
783
+ private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[]; excalidraw?: Grant }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
594
784
  const startedAt = Date.now();
595
785
  let outcome: RunOutcome;
596
786
  try {
@@ -658,6 +848,8 @@ export class PlanningSession {
658
848
  task: `${transcript}\n\nYou are ${label} on the planning panel${roundNote(this.turns, this.limit)}: ask your seat's open questions, or declare READY.`,
659
849
  systemPrompt: memberPrompt(member, profile.instructions),
660
850
  tools: this.tools(member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS),
851
+ // A panel seat is the agent of its own kind: DEV is Backend, DESIGN the Designer.
852
+ ...grantOption(member),
661
853
  };
662
854
  const route = effort ? this.deps.effort?.plan(effort, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }) : undefined;
663
855
  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);
@@ -686,6 +878,8 @@ export class PlanningSession {
686
878
  this.turns += 1;
687
879
  this.attempts += 1;
688
880
  this.answered = [];
881
+ // The user's reply answers whatever was open: a round that then fails or is stopped must not put those questions to them again.
882
+ this.questions = [];
689
883
  const limit = this.limit;
690
884
  const mode = roundMode(this.turns, limit);
691
885
  this.mode = mode;
@@ -712,6 +906,7 @@ export class PlanningSession {
712
906
  task: `${transcript}\n\n${panelSection(outcomes)}\n\n${oracleClosing(mode, this.turns, limit)}`,
713
907
  systemPrompt: plannerPrompt(profile.instructions),
714
908
  tools: this.tools(PLANNER_TOOLS),
909
+ ...grantOption("planner"),
715
910
  }, controller.signal, (step) => (this.step = step));
716
911
  if (controller.signal.aborted) throw new Error("stopped");
717
912
  this.finishRound(outcomes, lead, mode);
@@ -824,7 +1019,9 @@ export class PlanningSession {
824
1019
  private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome, mode: RoundMode): void {
825
1020
  const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
826
1021
  const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
827
- let questions = roundQuestions(reply, seatQuestions);
1022
+ const { asked, repeats } = withoutSettled(roundQuestions(reply, seatQuestions), this.settled);
1023
+ let questions = asked;
1024
+ if (repeats.length > 0) this.deps.feed?.log(ORACLE_LABEL, `held back ${repeats.length} question${repeats.length === 1 ? "" : "s"} you already settled: ${repeats.map((question) => brief(question.text, 60)).join("; ")}`, "info");
828
1025
  const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
829
1026
  let ready = Boolean(reply && reply.status === "ready" && seatsReady);
830
1027
  if (reply) {