@a-t-h-i/bot-lobby 0.6.6 → 0.6.8

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 (58) hide show
  1. package/README.md +183 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +21 -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 +23 -7
  9. package/src/ask/state.ts +18 -3
  10. package/src/ask/tool.ts +9 -2
  11. package/src/ask/view.ts +8 -4
  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/execution/agent-runner.ts +23 -1
  16. package/src/execution/fallback.ts +75 -0
  17. package/src/execution/workspace.ts +185 -0
  18. package/src/knowledge/edit.ts +109 -0
  19. package/src/knowledge/notes.ts +143 -0
  20. package/src/knowledge/selector.ts +1 -1
  21. package/src/knowledge/store.ts +16 -2
  22. package/src/lobby/ask.ts +42 -21
  23. package/src/lobby/feed.ts +7 -0
  24. package/src/lobby/issues.ts +1 -1
  25. package/src/lobby/knowledge.ts +179 -0
  26. package/src/lobby/layout.ts +99 -1
  27. package/src/lobby/mini.ts +159 -0
  28. package/src/lobby/planner.ts +209 -10
  29. package/src/lobby/pr-review.ts +360 -0
  30. package/src/lobby/pulls.ts +250 -0
  31. package/src/lobby/quickfix.ts +17 -3
  32. package/src/lobby/runtime.ts +142 -17
  33. package/src/lobby/split.ts +230 -0
  34. package/src/lobby/tabs/git.ts +162 -0
  35. package/src/lobby/tabs/knowledge.ts +135 -0
  36. package/src/lobby/tabs/plan.ts +3 -1
  37. package/src/lobby/tabs/tasks.ts +11 -2
  38. package/src/lobby/view.ts +450 -35
  39. package/src/master/master.ts +40 -14
  40. package/src/master/research.ts +1 -0
  41. package/src/pi/commands.ts +12 -7
  42. package/src/pi/events.ts +2 -0
  43. package/src/pi/master-fallback.ts +61 -0
  44. package/src/pi/model-support.ts +27 -3
  45. package/src/pi/plan-checklist.ts +42 -0
  46. package/src/pi/route.ts +2 -1
  47. package/src/pi/settings-ui.ts +104 -11
  48. package/src/pi/start-flags.ts +14 -0
  49. package/src/pi/start-task.ts +56 -5
  50. package/src/pi/tools.ts +4 -2
  51. package/src/schemas/configuration.ts +74 -11
  52. package/src/schemas/findings.ts +2 -0
  53. package/src/schemas/task.ts +15 -0
  54. package/src/state/backlog.ts +27 -4
  55. package/src/state/metrics.ts +2 -2
  56. package/src/state/persistence.ts +5 -5
  57. package/src/text.ts +38 -0
  58. package/src/workflow/workflow.ts +23 -2
@@ -21,9 +21,12 @@ import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state
21
21
  import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
22
22
  import { describeToolCall } from "../pi/activity.ts";
23
23
  import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
24
- import { shortTitle } from "../text.ts";
24
+ import { taskName } from "../text.ts";
25
+ import { describeWorkspace, type WorkspaceInfo } from "../execution/workspace.ts";
26
+ import { basename } from "node:path";
27
+ import { stripStartFlags } from "../pi/start-flags.ts";
25
28
  import { isSubagentProcess } from "../pi/quiet.ts";
26
- import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
29
+ import { modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile } from "../pi/model-support.ts";
27
30
  import { modelLookup } from "../pi/tools.ts";
28
31
  import { startPlannedTask } from "../pi/start-task.ts";
29
32
  import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
@@ -34,10 +37,14 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
34
37
  import { checkThinking } from "../pi/model-support.ts";
35
38
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
36
39
  import { SessionChats } from "./session-files.ts";
37
- import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
38
- import { QuickFixQueue } from "./quickfix.ts";
40
+ import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
41
+ import { jobTitle, QuickFixQueue } from "./quickfix.ts";
42
+ import { miniLine, type MiniInput } from "./mini.ts";
39
43
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
40
44
  import { execCommand, IssuesState } from "./issues.ts";
45
+ import { PullsState } from "./pulls.ts";
46
+ import { PullReviews } from "./pr-review.ts";
47
+ import { KnowledgeBook } from "./knowledge.ts";
41
48
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
42
49
  import { lobbyTheme } from "./theme.ts";
43
50
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
@@ -60,6 +67,9 @@ interface Runtime {
60
67
  quickfix: QuickFixQueue;
61
68
  planner?: PlanningSession;
62
69
  issues: IssuesState;
70
+ pulls: PullsState;
71
+ reviews: PullReviews;
72
+ knowledge: KnowledgeBook;
63
73
  unsubscribeFeed?: () => void;
64
74
  /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
65
75
  asker?: Asker;
@@ -69,6 +79,9 @@ interface Runtime {
69
79
  mouse: boolean;
70
80
  /** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
71
81
  inSettings: boolean;
82
+ /** The repository (or folder) and branch the lobby's title shows, and whether git is being asked now. */
83
+ workspace: WorkspaceInfo;
84
+ readingWorkspace: boolean;
72
85
  }
73
86
 
74
87
  let runtime: Runtime | undefined;
@@ -94,18 +107,39 @@ let frameTimer: ReturnType<typeof setTimeout> | undefined;
94
107
  * through pi.
95
108
  */
96
109
  function rerender(): void {
97
- if (!runtime?.visible || frameTimer) return;
110
+ // Hidden, only the one-line status under the editor changes.
111
+ if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
98
112
  const wait = lastFrame + FRAME_MS - Date.now();
99
113
  const paint = () => {
100
114
  frameTimer = undefined;
101
115
  lastFrame = Date.now();
102
- if (runtime?.visible) runtime.tui?.requestRender();
116
+ if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
103
117
  };
104
118
  if (wait <= 0) return paint();
105
119
  frameTimer = setTimeout(paint, wait);
106
120
  frameTimer.unref?.();
107
121
  }
108
122
 
123
+ /** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
124
+ function miniShown(state: Runtime): boolean {
125
+ return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
126
+ }
127
+
128
+ /** What the status line shows: the task, the planning round, the quick fix in hand. */
129
+ function miniInput(state: Runtime): MiniInput {
130
+ const snapshot = taskSnapshot();
131
+ const planner = state.planner;
132
+ const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
133
+ const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
134
+ return {
135
+ ...(snapshot.task ? { task: snapshot.task } : {}),
136
+ runs: snapshot.runs,
137
+ ...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
138
+ ...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
139
+ key: "Alt+L",
140
+ };
141
+ }
142
+
109
143
  function sessionModel(ctx: ExtensionContext): string | undefined {
110
144
  return ctx.model ? modelRef(ctx.model) : undefined;
111
145
  }
@@ -243,7 +277,8 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
243
277
  const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
244
278
  if (starting) return `${plan.id} is already starting in ${starting.name}`;
245
279
  }
246
- const name = plan?.title ?? shortTitle(request!);
280
+ // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
281
+ const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
247
282
  try {
248
283
  const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
249
284
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
@@ -423,6 +458,15 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
423
458
  return `deleted ${taskId} for good`;
424
459
  }
425
460
 
461
+ /** What a pull request review runs on: QA's model, thinking and time limit. */
462
+ function reviewProfile(state: Runtime) {
463
+ return resolveReviewProfile(loadConfig(), {
464
+ lookup: modelLookup(state.ctx),
465
+ sessionModel: sessionModel(state.ctx),
466
+ warn: (message) => state.ctx.ui.notify(message, "warning"),
467
+ });
468
+ }
469
+
426
470
  function seatProfile(state: Runtime, member: PanelMember) {
427
471
  return resolvePanelProfile(loadConfig(), member, {
428
472
  lookup: modelLookup(state.ctx),
@@ -463,6 +507,26 @@ function panelAsker(state: Runtime): Asker {
463
507
  return state.asker ?? askUser;
464
508
  }
465
509
 
510
+ /**
511
+ * Save the plan as a pending task. A plan with many steps is first split by
512
+ * the oracle into up to five tasks, which the user takes, changes or declines
513
+ * in a questionnaire; the split's questions open over the lobby like the
514
+ * panel's. Returns a notice for the lobby.
515
+ */
516
+ export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
517
+ const session = state?.planner;
518
+ if (!state || !session) return "no planning session";
519
+ if (state.asking) return "a questionnaire is already open";
520
+ state.asking = true;
521
+ try {
522
+ const asker = panelAsker(state);
523
+ return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
524
+ } finally {
525
+ state.asking = false;
526
+ rerender();
527
+ }
528
+ }
529
+
466
530
  /**
467
531
  * The oracle puts the round's questions to the user, one at a time; answered
468
532
  * questionnaires are kept if the user stops, so the next call resumes there.
@@ -492,7 +556,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
492
556
  session.answered = [];
493
557
  return "nothing was answered — the questions stay open";
494
558
  }
495
- void session.send(message);
559
+ void session.send(message, settledQuestions(session.questions, session.answered));
496
560
  return "answers sent — the panel is on the next round";
497
561
  } catch (error) {
498
562
  return `could not put the questions: ${(error as Error).message}`;
@@ -502,6 +566,22 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
502
566
  }
503
567
  }
504
568
 
569
+ /** Read the repository name and branch again, off the render path; the title repaints when they changed. */
570
+ function refreshWorkspace(state: Runtime): void {
571
+ if (state.readingWorkspace) return;
572
+ state.readingWorkspace = true;
573
+ describeWorkspace(state.ctx.cwd)
574
+ .then((info) => {
575
+ if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
576
+ state.workspace = info;
577
+ rerender();
578
+ })
579
+ .catch(() => {})
580
+ .finally(() => {
581
+ state.readingWorkspace = false;
582
+ });
583
+ }
584
+
505
585
  function host(state: Runtime, tui: TUI): LobbyHost {
506
586
  return {
507
587
  rows: () => tui.terminal.rows,
@@ -528,6 +608,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
528
608
  planner: () => state.planner,
529
609
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
530
610
  answerPanel: () => answerPanel(state),
611
+ savePlan: () => savePlan(state),
531
612
  defaultPanel: () => loadConfig().lobby.planningPanel,
532
613
  planningRounds: () => loadConfig().lobby.maxPlanningRounds,
533
614
  issuesEnabled: () => loadConfig().lobby.issues,
@@ -540,10 +621,16 @@ function host(state: Runtime, tui: TUI): LobbyHost {
540
621
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
541
622
  },
542
623
  issues: state.issues,
624
+ pulls: state.pulls,
625
+ reviews: state.reviews,
626
+ knowledge: state.knowledge,
627
+ editText: (title, text) => editText(state, title, text),
543
628
  profileLabel: (kind) => {
544
629
  const profile = lobbyProfile(state, kind);
545
630
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
546
631
  },
632
+ workspace: () => state.workspace,
633
+ refreshWorkspace: () => refreshWorkspace(state),
547
634
  sessionName: () => state.pi.getSessionName(),
548
635
  sessions: () => backgroundSessions(),
549
636
  startSession: (start) => startSession(state, start),
@@ -566,20 +653,20 @@ function host(state: Runtime, tui: TUI): LobbyHost {
566
653
  }
567
654
 
568
655
  /**
569
- * bot-lobby's settings (or one agent's entry) from inside the lobby. The
570
- * lobby stays aside for the whole visit, not only for each menu, so it does
571
- * not flash between them, and rereads the config when it comes back.
656
+ * Run something pi draws itself (its settings menus, its multi-line editor)
657
+ * with the lobby stepped aside for the whole visit, not only for each dialog,
658
+ * so it does not flash between them; the lobby rereads the config after.
572
659
  */
573
- async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
574
- if (state.inSettings) return;
660
+ async function whileAside<T>(state: Runtime, run: () => Promise<T>, failure: string): Promise<T | undefined> {
661
+ if (state.inSettings) return undefined;
575
662
  state.inSettings = true;
576
663
  setMouse(state, false);
577
664
  state.handle?.setHidden(true);
578
665
  try {
579
- if (entry) await openEntrySettings(state.pi, state.ctx, entry);
580
- else await openSettings(state.pi, state.ctx);
666
+ return await run();
581
667
  } catch (error) {
582
- state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
668
+ state.ctx.ui.notify(`bot-lobby: ${failure} — ${(error as Error).message}`, "warning");
669
+ return undefined;
583
670
  } finally {
584
671
  state.inSettings = false;
585
672
  state.asideForPrompt = false;
@@ -593,6 +680,16 @@ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Pr
593
680
  }
594
681
  }
595
682
 
683
+ /** bot-lobby's settings (or one agent's entry) from inside the lobby. */
684
+ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
685
+ await whileAside(state, () => (entry ? openEntrySettings(state.pi, state.ctx, entry) : openSettings(state.pi, state.ctx)), "settings failed");
686
+ }
687
+
688
+ /** A text edited in pi's multi-line editor (Enter saves, Shift+Enter is a new line); undefined when cancelled. */
689
+ async function editText(state: Runtime, title: string, text: string): Promise<string | undefined> {
690
+ return whileAside(state, () => state.ctx.ui.editor(title, text), "the editor failed");
691
+ }
692
+
596
693
  /** Remember which panes show, keeping every other setting as the file has it now. */
597
694
  function savePanels(panels: Record<LobbyPanel, boolean>): void {
598
695
  try {
@@ -696,6 +793,7 @@ function shutdown(): void {
696
793
  setMouse(state, false);
697
794
  state.unsubscribeFeed?.();
698
795
  state.quickfix.cancelAll();
796
+ state.reviews.cancelAll();
699
797
  state.planner?.cancel();
700
798
  state.view?.dispose();
701
799
  state.handle?.hide();
@@ -737,9 +835,35 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
737
835
  asking: false,
738
836
  mouse: false,
739
837
  inSettings: false,
838
+ workspace: { name: basename(ctx.cwd) || ctx.cwd },
839
+ readingWorkspace: false,
740
840
  quickfix: undefined as unknown as QuickFixQueue,
741
841
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
842
+ pulls: new PullsState(execCommand, ctx.cwd, rerender),
843
+ reviews: undefined as unknown as PullReviews,
844
+ knowledge: new KnowledgeBook({
845
+ root,
846
+ configDir,
847
+ threshold: () => loadConfig().knowledge.compactionThreshold,
848
+ backups: () => loadConfig().knowledge.backupCount,
849
+ sessionId: () => ctx.sessionManager.getSessionId(),
850
+ }),
742
851
  };
852
+ state.reviews = new PullReviews({
853
+ cwd: ctx.cwd,
854
+ root,
855
+ configDir,
856
+ exec: execCommand,
857
+ profile: () => reviewProfile(state),
858
+ stallTimeoutMs: workflow.stallTimeoutMs,
859
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
860
+ feed: lobbyFeed,
861
+ onChange: rerender,
862
+ notify: (message, level) => {
863
+ if (!state.visible) ctx.ui.notify(message, level);
864
+ },
865
+ classifier: classifier(),
866
+ });
743
867
  state.quickfix = new QuickFixQueue({
744
868
  cwd: ctx.cwd,
745
869
  root,
@@ -769,7 +893,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
769
893
  // Capture pi's TUI through a zero-line widget below the editor.
770
894
  ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
771
895
  state.tui = tui;
772
- return { render: () => [], invalidate: () => {} };
896
+ // Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
897
+ return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
773
898
  }, { placement: "belowEditor" });
774
899
  // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
775
900
  onOwnerEvent((event) => {
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Splitting a long plan into tasks. When the user saves a plan with many
3
+ * steps, the oracle proposes how to cut it into two to five tasks, the user
4
+ * decides (keep it whole, take the split, or say what to change), and each part
5
+ * is saved as a pending task that knows its siblings.
6
+ *
7
+ * The oracle decides where the lines go; the engine enforces the rest: at
8
+ * most `MAX_SPLIT_TASKS` tasks, every step of the plan in exactly one of them,
9
+ * and a task only ever building on earlier ones. A proposal that breaks a rule
10
+ * is sent back once with the problems, and never reaches the user. Each part's
11
+ * brief is composed here from the plan as written (its objective, decisions,
12
+ * assumptions and risks stay whole, its steps section holds only that part's
13
+ * steps), so nothing the user agreed can be lost in a model's retelling.
14
+ */
15
+ import { loadPrompt } from "../prompts/loader.ts";
16
+ import { planStepBlocks } from "../pi/plan-checklist.ts";
17
+ import type { SplitInfo } from "../state/backlog.ts";
18
+ import type { AskQuestion } from "../ask/types.ts";
19
+
20
+ /** The most tasks a plan is split into. */
21
+ export const MAX_SPLIT_TASKS = 5;
22
+ /** Times the user may ask the oracle to change a split before it is final. */
23
+ export const MAX_SPLIT_REVISIONS = 3;
24
+
25
+ export interface SplitTask {
26
+ title: string;
27
+ goal: string;
28
+ /** The plan's steps (numbered from 1) this task does. */
29
+ covers: number[];
30
+ /** Earlier tasks (numbered from 1) it builds on. */
31
+ after: number[];
32
+ done: string[];
33
+ }
34
+
35
+ export interface SplitProposal {
36
+ tasks: SplitTask[];
37
+ /** What the oracle tells the user about the split. */
38
+ note?: string;
39
+ }
40
+
41
+ export function splitPrompt(instructions?: string): string {
42
+ const custom = instructions?.trim();
43
+ return custom ? `${loadPrompt("splitter.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("splitter.md");
44
+ }
45
+
46
+ /** `3, 5-6` as [3, 5, 6]; `none` (or nothing) as []. Ranges are bounded, so a stray `1-999999` cannot fill memory. */
47
+ export function parseNumbers(text: string): number[] {
48
+ const found = new Set<number>();
49
+ for (const match of text.matchAll(/(\d+)(?:\s*[-–—]\s*(\d+))?/g)) {
50
+ const first = Number(match[1]);
51
+ const last = match[2] ? Number(match[2]) : first;
52
+ for (let number = first; number <= Math.min(last, first + 200); number += 1) found.add(number);
53
+ }
54
+ return [...found].sort((a, b) => a - b);
55
+ }
56
+
57
+ /** Read the oracle's reply into a proposal; throws a one-line reason when it has no tasks at all. */
58
+ export function parseSplit(text: string): SplitProposal {
59
+ const [head = "", ...after] = text.split(/^##\s+Note\s*$/im);
60
+ const note = after.join("\n").trim().replace(/\s+/g, " ") || undefined;
61
+ const parts = head.split(/^###\s+\d+[.):]?\s*/m).slice(1);
62
+ if (parts.length === 0) throw new Error("no tasks found: write each as `### 1. Title` with Goal, Covers, After and Done when");
63
+ const tasks = parts.map((part): SplitTask => {
64
+ const lines = part.split("\n");
65
+ const title = (lines[0] ?? "").replace(/[*_`#]/g, "").trim();
66
+ const value = (key: string) => new RegExp(`^\\s*[-*]?\\s*\\**${key}\\**\\s*:\\**\\s*(.*)$`, "im").exec(part)?.[1]?.trim() ?? "";
67
+ const doneAt = lines.findIndex((line) => /^\s*\**done when\**\s*:/i.test(line));
68
+ const inline = doneAt < 0 ? undefined : /:\**\s*(\S.*)$/.exec(lines[doneAt]!)?.[1];
69
+ const bullets = doneAt < 0 ? [] : lines.slice(doneAt + 1).filter((line) => /^\s*[-*]\s+\S/.test(line)).map((line) => line.replace(/^\s*[-*]\s+/, "").trim());
70
+ const done = [...(inline ? [inline] : []), ...bullets];
71
+ return { title, goal: value("goal"), covers: parseNumbers(value("covers")), after: parseNumbers(value("after")), done };
72
+ });
73
+ return { tasks, ...(note ? { note } : {}) };
74
+ }
75
+
76
+ /** Every rule a proposal breaks, in words the oracle can act on; empty when it is sound. */
77
+ export function splitProblems(proposal: SplitProposal, stepCount: number, max = MAX_SPLIT_TASKS): string[] {
78
+ const problems: string[] = [];
79
+ const { tasks } = proposal;
80
+ if (tasks.length < 2) problems.push("a split needs at least 2 tasks");
81
+ if (tasks.length > max) problems.push(`a plan is split into at most ${max} tasks, not ${tasks.length}`);
82
+ const owner = new Map<number, number>();
83
+ tasks.forEach((task, index) => {
84
+ const number = index + 1;
85
+ if (!task.title) problems.push(`task ${number} has no title`);
86
+ if (task.covers.length === 0) problems.push(`task ${number} covers no steps`);
87
+ for (const step of task.covers) {
88
+ if (step < 1 || step > stepCount) problems.push(`task ${number} covers step ${step}, but the plan has steps 1 to ${stepCount}`);
89
+ else if (owner.has(step)) problems.push(`step ${step} is in both task ${owner.get(step)} and task ${number}`);
90
+ else owner.set(step, number);
91
+ }
92
+ for (const earlier of task.after) if (earlier >= number || earlier < 1) problems.push(`task ${number} cannot come after task ${earlier}: list the tasks in the order they can be done, each building only on earlier ones`);
93
+ });
94
+ const missing = Array.from({ length: stepCount }, (_, index) => index + 1).filter((step) => !owner.has(step));
95
+ if (missing.length > 0) problems.push(`step${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} in no task: every step belongs to exactly one`);
96
+ return [...new Set(problems)];
97
+ }
98
+
99
+ /** `1-3, 5` for step numbers. */
100
+ export function rangeWords(numbers: readonly number[]): string {
101
+ const parts: string[] = [];
102
+ for (let index = 0; index < numbers.length; ) {
103
+ let end = index;
104
+ while (end + 1 < numbers.length && numbers[end + 1] === numbers[end]! + 1) end += 1;
105
+ parts.push(end > index ? `${numbers[index]}-${numbers[end]}` : String(numbers[index]));
106
+ index = end + 1;
107
+ }
108
+ return parts.join(", ");
109
+ }
110
+
111
+ /** What the oracle is asked: the plan, its steps as numbered here, and, on a revision, the split so far with what the user wants changed. */
112
+ export function splitRequest(input: { title: string; plan: string; steps: readonly string[]; max?: number; revision?: { previous: SplitProposal; words: string }; rejected?: readonly string[] }): string {
113
+ const max = input.max ?? MAX_SPLIT_TASKS;
114
+ const lines = [
115
+ `Split this plan into 2 to ${max} tasks: ${input.title}`,
116
+ "",
117
+ `The plan has ${input.steps.length} steps. They are numbered here in the order of its Steps section; use these numbers in Covers.`,
118
+ ...input.steps.map((step, index) => `${index + 1}. ${step.replace(/\s+/g, " ").trim()}`),
119
+ "",
120
+ "## The plan",
121
+ "",
122
+ input.plan.trim(),
123
+ ];
124
+ if (input.revision) {
125
+ lines.push("", "## The split so far", "", proposalText(input.revision.previous), "", `## What the user wants changed`, "", input.revision.words.trim(), "", "Apply exactly that and keep the rest of the split as it is.");
126
+ }
127
+ if (input.rejected && input.rejected.length > 0) {
128
+ lines.push("", "## Your last split was rejected", "", ...input.rejected.map((problem) => `- ${problem}`), "", "Answer again in the required format with a split that breaks none of these.");
129
+ }
130
+ return lines.join("\n");
131
+ }
132
+
133
+ /** A proposal as the oracle's own format, so a revision starts from what it (and the user) saw. */
134
+ export function proposalText(proposal: SplitProposal): string {
135
+ return [
136
+ "## Tasks",
137
+ ...proposal.tasks.flatMap((task, index) => [
138
+ `### ${index + 1}. ${task.title}`,
139
+ `Goal: ${task.goal}`,
140
+ `Covers: ${rangeWords(task.covers)}`,
141
+ `After: ${task.after.length > 0 ? rangeWords(task.after) : "none"}`,
142
+ ...(task.done.length > 0 ? ["Done when:", ...task.done.map((point) => `- ${point}`)] : []),
143
+ "",
144
+ ]),
145
+ ...(proposal.note ? ["## Note", proposal.note] : []),
146
+ ].join("\n").trimEnd();
147
+ }
148
+
149
+ /** Markdown for the questionnaire's preview: each task with the steps it takes. */
150
+ export function splitPreview(proposal: SplitProposal, steps: readonly string[]): string {
151
+ const clip = (text: string) => {
152
+ const flat = text.replace(/\s+/g, " ").trim();
153
+ return flat.length > 110 ? `${flat.slice(0, 109)}…` : flat;
154
+ };
155
+ return proposal.tasks.map((task, index) => [
156
+ `**${index + 1}. ${task.title}** · step${task.covers.length === 1 ? "" : "s"} ${rangeWords(task.covers)}${task.after.length > 0 ? ` · after ${rangeWords(task.after)}` : ""}`,
157
+ ...(task.goal ? [`_${clip(task.goal)}_`] : []),
158
+ ...task.covers.map((step) => `- ${step}. ${clip(steps[step - 1] ?? "")}`),
159
+ ].join("\n")).join("\n\n");
160
+ }
161
+
162
+ /** The question the user answers: take the split, keep the plan whole, or write what to change. */
163
+ export function splitQuestion(input: { stepCount: number; proposal: SplitProposal; steps: readonly string[]; revisions: number }): AskQuestion {
164
+ const count = input.proposal.tasks.length;
165
+ const last = input.revisions >= MAX_SPLIT_REVISIONS;
166
+ return {
167
+ header: "Split plan",
168
+ question: [
169
+ `This plan has **${input.stepCount} steps**. The oracle suggests splitting it into **${count} tasks**, each finished and reviewed on its own.`,
170
+ ...(input.proposal.note ? [input.proposal.note] : []),
171
+ last ? "This is the last revision: take it, or keep the plan whole." : "Type what to change (for example *merge 2 and 3*) to revise it.",
172
+ ].join("\n\n"),
173
+ options: [
174
+ { label: `${splitLabel(count)} (Recommended)`, description: "Each part is saved as its own pending task, in order, knowing the others.", preview: splitPreview(input.proposal, input.steps) },
175
+ { label: "Keep it as one task", description: `One task with all ${input.stepCount} steps.` },
176
+ ],
177
+ };
178
+ }
179
+
180
+ export const KEEP_WHOLE = "Keep it as one task";
181
+
182
+ export function splitLabel(count: number): string {
183
+ return `Split into ${count} tasks`;
184
+ }
185
+
186
+ /** Ask whether to save a plan as one task after the oracle could not split it. */
187
+ export function splitFailedQuestion(reason: string): AskQuestion {
188
+ return {
189
+ header: "Split plan",
190
+ question: `The oracle could not split this plan (${reason.replace(/\s+/g, " ").trim()}). Save it as one task?`,
191
+ options: [
192
+ { label: "Save it as one task", description: "All the steps in one pending task." },
193
+ { label: "Not now", description: "Save nothing; ctrl+s asks again." },
194
+ ],
195
+ };
196
+ }
197
+
198
+ /** The original plan with its Steps section holding only `covers` (renumbered); undefined when the plan's steps are not a list. */
199
+ function planWithSteps(plan: string, covers: readonly number[], stepCount: number): string | undefined {
200
+ const found = planStepBlocks(plan);
201
+ if (!found || found.blocks.length !== stepCount) return undefined;
202
+ const lines = plan.split("\n");
203
+ const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
204
+ const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
205
+ return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
206
+ }
207
+
208
+ /** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
209
+ export function partBrief(input: { plan: string; steps: readonly string[]; proposal: SplitProposal; index: number }): string {
210
+ const { proposal, index } = input;
211
+ const task = proposal.tasks[index]!;
212
+ const of = proposal.tasks.length;
213
+ const head = [
214
+ `### Part ${index + 1} of ${of} — ${task.title}`,
215
+ ...(task.goal ? [`**Goal of this part:** ${task.goal}`] : []),
216
+ ...(task.after.length > 0 ? [`**Builds on:** ${task.after.map((part) => `part ${part} — ${proposal.tasks[part - 1]?.title ?? ""}`).join("; ")} (finish that first).`] : []),
217
+ ...(task.done.length > 0 ? ["**Done when:**", ...task.done.map((point) => `- ${point}`)] : []),
218
+ ].join("\n");
219
+ const own = planWithSteps(input.plan, task.covers, input.steps.length);
220
+ if (own) return `${head}\n\n${own.trim()}`;
221
+ // Steps that are not a list: the whole plan, with this part's steps named on top.
222
+ const named = task.covers.map((step, position) => `${position + 1}. ${(input.steps[step - 1] ?? "").replace(/\s+/g, " ").trim()}`);
223
+ return `${head}\n\n**Steps of this part (do only these; the plan below is the whole plan, for context):**\n${named.join("\n")}\n\n${input.plan.trim()}`;
224
+ }
225
+
226
+ /** What each saved part knows of the others. */
227
+ export function partInfo(proposal: SplitProposal, index: number, group: string): SplitInfo {
228
+ const task = proposal.tasks[index]!;
229
+ return { group, part: index + 1, of: proposal.tasks.length, titles: proposal.tasks.map((entry) => entry.title), after: [...task.after] };
230
+ }