@a-t-h-i/bot-lobby 0.6.7 → 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 (49) hide show
  1. package/README.md +132 -15
  2. package/package.json +1 -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/execution/workspace.ts +185 -0
  16. package/src/knowledge/edit.ts +109 -0
  17. package/src/knowledge/notes.ts +143 -0
  18. package/src/knowledge/selector.ts +1 -1
  19. package/src/knowledge/store.ts +16 -2
  20. package/src/lobby/ask.ts +42 -21
  21. package/src/lobby/issues.ts +1 -1
  22. package/src/lobby/knowledge.ts +179 -0
  23. package/src/lobby/layout.ts +95 -18
  24. package/src/lobby/planner.ts +199 -7
  25. package/src/lobby/pr-review.ts +360 -0
  26. package/src/lobby/pulls.ts +250 -0
  27. package/src/lobby/runtime.ts +115 -13
  28. package/src/lobby/split.ts +230 -0
  29. package/src/lobby/tabs/git.ts +162 -0
  30. package/src/lobby/tabs/knowledge.ts +135 -0
  31. package/src/lobby/tabs/plan.ts +3 -1
  32. package/src/lobby/tabs/tasks.ts +11 -2
  33. package/src/lobby/view.ts +432 -32
  34. package/src/master/master.ts +39 -13
  35. package/src/pi/commands.ts +12 -7
  36. package/src/pi/model-support.ts +9 -0
  37. package/src/pi/plan-checklist.ts +42 -0
  38. package/src/pi/route.ts +2 -1
  39. package/src/pi/settings-ui.ts +41 -1
  40. package/src/pi/start-flags.ts +14 -0
  41. package/src/pi/start-task.ts +56 -5
  42. package/src/pi/tools.ts +4 -2
  43. package/src/schemas/configuration.ts +32 -3
  44. package/src/schemas/task.ts +15 -0
  45. package/src/state/backlog.ts +27 -4
  46. package/src/state/metrics.ts +2 -2
  47. package/src/state/persistence.ts +5 -5
  48. package/src/text.ts +38 -0
  49. package/src/workflow/workflow.ts +11 -0
@@ -6,7 +6,7 @@ import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schem
6
6
  import { domainSpec } from "../agents/registry.ts";
7
7
  import { runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
- import { readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
9
+ import { knowledgeFilePath, readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
11
11
  import { summarizeOutcomes } from "./synthesis.ts";
12
12
  import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
@@ -14,6 +14,7 @@ import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
14
14
  import { truncate } from "../text.ts";
15
15
  import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
16
16
  import type { FileHinter } from "../classifier/files.ts";
17
+ import type { KnowledgePaths, KnowledgePicker } from "../classifier/knowledge.ts";
17
18
  import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
18
19
 
19
20
  export interface ScoutOutcome {
@@ -42,6 +43,8 @@ export interface ScoutRequest {
42
43
  onUpdate?: (run: AgentRun) => void;
43
44
  /** Likely files for each scout's context, and the lookup tool, while the classifier's file hints are on. */
44
45
  hints?: FileHinter;
46
+ /** Picks the relevant sections of long knowledge files, while the classifier's knowledge picks are on. */
47
+ knowledge?: KnowledgePicker;
45
48
  /** Moves a trivial scout to the cheaper model, while the classifier's effort routing is on. */
46
49
  effort?: EffortRouter;
47
50
  }
@@ -61,11 +64,31 @@ function scoutInstruction(request: ScoutRequest, domain: Domain): string {
61
64
  ].join("\n");
62
65
  }
63
66
 
64
- function scoutContext(request: ScoutRequest, domain: Domain, likely = ""): AgentRequest["context"] {
65
- const slices = readAgentKnowledge(request.dataRoots, domain);
67
+ /** Where an agent's knowledge files are, for the note that says what a prompt left out. */
68
+ function knowledgePaths(dataRoots: readonly string[], agent: Domain): KnowledgePaths {
69
+ const root = dataRoots[0];
70
+ return root ? { knowledge: knowledgeFilePath(root, agent, "knowledge"), standards: knowledgeFilePath(root, agent, "standard"), decisions: knowledgeFilePath(root, agent, "decision") } : {};
71
+ }
72
+
73
+ /**
74
+ * What an agent's prompt carries of its knowledge for a step: Jev's picks from
75
+ * the files that are too long when a picker is given, the keyword selection
76
+ * otherwise and whenever the picker fails.
77
+ */
78
+ async function pickKnowledge(request: { knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
79
+ const slices = readAgentKnowledge(request.dataRoots, agent);
80
+ if (!request.knowledge) return selectKnowledge(query, slices);
81
+ try {
82
+ return await request.knowledge.select(query, slices, { paths: knowledgePaths(request.dataRoots, agent), ...(request.signal ? { signal: request.signal } : {}) });
83
+ } catch {
84
+ return selectKnowledge(query, slices);
85
+ }
86
+ }
87
+
88
+ function scoutContext(request: ScoutRequest, domain: Domain, selected: KnowledgeSelection, likely = ""): AgentRequest["context"] {
66
89
  return {
67
90
  task: request.taskText,
68
- ...selectKnowledge(`${request.taskText} ${domainSpec(domain).scoutFocus}`, slices),
91
+ ...selected,
69
92
  instructions: request.config.agents[domain].instructions,
70
93
  workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
71
94
  };
@@ -112,14 +135,17 @@ function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
112
135
  /** Run the selected domain scouts concurrently and persist their findings. */
113
136
  export async function runScouts(request: ScoutRequest, run: ProcessRunner = spawnPiProcess): Promise<ScoutOutcome[]> {
114
137
  // Each scout gets the files most likely to answer its own instruction and focus.
115
- const likely = await Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText)));
138
+ const [likely, selected] = await Promise.all([
139
+ Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText))),
140
+ Promise.all(request.domains.map((domain) => pickKnowledge(request, domain, `${request.instruction}\n${domainSpec(domain).scoutFocus}\n\n${request.taskText}`))),
141
+ ]);
116
142
  const extraTools = request.hints?.tools() ?? [];
117
143
  const requests: AgentRequest[] = request.domains.map((domain, index) => ({
118
144
  taskId: request.taskId,
119
145
  domain,
120
146
  role: "scout",
121
147
  instruction: scoutInstruction(request, domain),
122
- context: scoutContext(request, domain, likely[index]),
148
+ context: scoutContext(request, domain, selected[index]!, likely[index]),
123
149
  ...(extraTools.length > 0 ? { extraTools } : {}),
124
150
  ...profileFields(request.config, request.profile, domain, "scout"),
125
151
  ...(request.time ? { time: { ...request.time } } : {}),
@@ -204,6 +230,8 @@ export interface WorkerRequest {
204
230
  onUpdate?: (run: AgentRun) => void;
205
231
  /** Likely files for the worker's step, and the lookup tool, while the classifier's file hints are on. */
206
232
  hints?: FileHinter;
233
+ /** Picks the relevant sections of long knowledge files, while the classifier's knowledge picks are on. */
234
+ knowledge?: KnowledgePicker;
207
235
  /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
208
236
  effort?: EffortRouter;
209
237
  /** Under a task time budget: the step's time, and who decides on more when it runs out. */
@@ -246,10 +274,9 @@ export async function runWorker(
246
274
  request: WorkerRequest,
247
275
  run: ProcessRunner = spawnPiProcess,
248
276
  ): Promise<WorkerOutcome> {
249
- const slices = readAgentKnowledge(request.dataRoots, request.domain);
250
- const selected = selectKnowledge(`${request.taskText} ${request.instruction}`, slices);
251
277
  const configured = profileFields(request.config, request.profile, request.domain, "worker");
252
- const [likely, route] = await Promise.all([
278
+ const [selected, likely, route] = await Promise.all([
279
+ pickKnowledge(request, request.domain, `${request.instruction}\n\n${request.taskText}`),
253
280
  likelyFor(request.hints, request.instruction, request.signal, request.taskText),
254
281
  routeFor(request.effort, request.instruction, { ...(configured.model ? { model: configured.model } : {}), thinking: configured.thinking }, { context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) }),
255
282
  ]);
@@ -325,6 +352,8 @@ export interface ReviewerRequest {
325
352
  profile?: ProfileResolver;
326
353
  signal?: AbortSignal;
327
354
  onUpdate?: (run: AgentRun) => void;
355
+ /** Picks the relevant sections of long knowledge files, while the classifier's knowledge picks are on. */
356
+ knowledge?: KnowledgePicker;
328
357
  }
329
358
 
330
359
  function reviewerContext(request: ReviewerRequest): string {
@@ -390,10 +419,7 @@ export async function runReviewer(
390
419
  request: ReviewerRequest,
391
420
  run: ProcessRunner = spawnPiProcess,
392
421
  ): Promise<ReviewerOutcome> {
393
- const selected = selectKnowledge(
394
- `${request.taskText} ${request.workerSummary}`,
395
- readAgentKnowledge(request.dataRoots, request.domain),
396
- );
422
+ const selected = await pickKnowledge(request, request.domain, `${request.taskText} ${request.workerSummary}`);
397
423
  const agentRequest = reviewerAgentRequest(request, selected);
398
424
  const attempts = Math.max(1, request.config.workflow.maxAgentRetries + 1);
399
425
  let agentRun = await runAgent(agentRequest, run);
@@ -30,6 +30,8 @@ import { describeRun, runFromLog } from "./run-summary.ts";
30
30
  import { modelLookup } from "./tools.ts";
31
31
  import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startClock } from "../state/budget.ts";
32
32
  import { qaStillDue } from "../workflow/track.ts";
33
+ import type { GitIsolation } from "../schemas/configuration.ts";
34
+ import { START_FLAG } from "./start-flags.ts";
33
35
 
34
36
  const HELP = [
35
37
  "/bot-lobby Open the lobby: tasks, plan, quick fix, metrics (alt+l)",
@@ -37,6 +39,7 @@ const HELP = [
37
39
  "/bot-lobby --task [--auto] <request> Always a task (also when the request begins with a subcommand word)",
38
40
  "/bot-lobby --budget 90m <request> Start a task with a time budget the oracle divides between its agents",
39
41
  "/bot-lobby --fast|--full <request> Start a task on the fast track (straight to the agents it needs) or the full workflow, whatever it reads as",
42
+ "/bot-lobby --branch|--worktree|--no-branch <request> Give the task its own git branch, or its own worktree, named after it (or neither), whatever workflow.gitIsolation says",
40
43
  "/bot-lobby budget [90m|off] Show or set this session's task time budget",
41
44
  "/bot-lobby status [taskId] Show the active task",
42
45
  "/bot-lobby tasks List tasks",
@@ -59,13 +62,11 @@ const HELP = [
59
62
  /** Subcommands only win when no free-form text follows (so tasks still start). */
60
63
  const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
61
64
 
65
+ /** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
62
66
  function isTaskId(value: string | undefined): boolean {
63
- return Boolean(value && /^TASK-/.test(value));
67
+ return Boolean(value && /^task-/i.test(value));
64
68
  }
65
69
 
66
- /** A task start's leading flags: `--task` (always a task), `--auto`, `--fast`, `--full`, `--budget <time>` (or `--budget=<time>`). */
67
- const START_FLAG = /^--(task|auto|fast|full)(?=\s|$)\s*|^--budget(?:=|\s+)(\S+)\s*/;
68
-
69
70
  export interface ParsedCommand {
70
71
  sub: string | undefined;
71
72
  rest: string[];
@@ -79,6 +80,8 @@ export interface ParsedCommand {
79
80
  track?: "fast" | "full";
80
81
  /** `--task`: a task, never routed to the quick-fix agent. */
81
82
  task?: boolean;
83
+ /** `--branch`, `--worktree` or `--no-branch`: the task's git isolation, whatever the config says. */
84
+ isolation?: GitIsolation;
82
85
  }
83
86
 
84
87
  export function parseCommand(args: string): ParsedCommand {
@@ -86,12 +89,14 @@ export function parseCommand(args: string): ParsedCommand {
86
89
  // Leading flags start a task: what follows is its request, even when it begins with a subcommand word
87
90
  // (a background session started from the lobby sends `--task [--auto] <request>`).
88
91
  let flagged = false;
89
- const extras: Pick<ParsedCommand, "auto" | "budget" | "budgetError" | "track" | "task"> = {};
92
+ const extras: Pick<ParsedCommand, "auto" | "budget" | "budgetError" | "track" | "task" | "isolation"> = {};
90
93
  for (let match = START_FLAG.exec(trimmed); match; match = START_FLAG.exec(trimmed)) {
91
94
  flagged = true;
92
95
  if (match[1] === "auto") extras.auto = true;
93
96
  else if (match[1] === "task") extras.task = true;
94
97
  else if (match[1] === "fast" || match[1] === "full") extras.track = match[1];
98
+ else if (match[1] === "branch" || match[1] === "worktree") extras.isolation = match[1];
99
+ else if (match[1] === "no-branch") extras.isolation = "off";
95
100
  else if (match[2] !== undefined) {
96
101
  const minutes = parseMinutes(match[2]);
97
102
  if (minutes) extras.budget = minutes;
@@ -324,7 +329,7 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
324
329
  return filtered.length > 0 ? filtered : null;
325
330
  },
326
331
  handler: async (args, ctx) => {
327
- const { sub, rest, restText, auto, budget, budgetError, track, task } = parseCommand(args ?? "");
332
+ const { sub, rest, restText, auto, budget, budgetError, track, task, isolation } = parseCommand(args ?? "");
328
333
  if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
329
334
  if (!sub) {
330
335
  if (!restText) {
@@ -332,7 +337,7 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
332
337
  return;
333
338
  }
334
339
  // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
335
- const started = await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}) });
340
+ const started = await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
336
341
  if (started === "task") autoOpenLobby();
337
342
  return;
338
343
  }
@@ -131,6 +131,15 @@ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember,
131
131
  return resolveRunProfile(panelMemberProfile(config, member), kindLabel(member), options);
132
132
  }
133
133
 
134
+ /**
135
+ * The model, thinking and time limit a pull request review runs on: QA's, the
136
+ * agent whose job is judging work, with its custom instructions.
137
+ */
138
+ export function resolveReviewProfile(config: BotLobbyConfig, options: ResolverOptions): RunProfile {
139
+ const profile = agentProfile(config, "qa", "reviewer");
140
+ return resolveRunProfile({ model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.fallback ? { fallback: profile.fallback } : {}) }, kindLabel("qa"), options);
141
+ }
142
+
134
143
  /** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
135
144
  export function thinkingMismatches(config: BotLobbyConfig, lookup: ModelLookup, sessionModel?: string): string[] {
136
145
  const entries: Array<[SubagentKind | "master", string, string]> = [
@@ -119,6 +119,48 @@ export function planSteps(plan: string): string[] {
119
119
  return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
120
120
  }
121
121
 
122
+ /** Where a plan's steps section sits, and each of its top-level steps whole (its nested lines included). */
123
+ export interface StepBlocks {
124
+ /** The section's body: from this line up to (not including) `to`. */
125
+ from: number;
126
+ to: number;
127
+ /** One entry per step, the step's lines as written. */
128
+ blocks: string[];
129
+ }
130
+
131
+ /**
132
+ * The steps of a plan written as a list under a `Steps` heading, each with the
133
+ * lines that hang under it, so a step can be moved to another plan whole.
134
+ * Undefined when the plan has no such section (its steps are headings, or
135
+ * scattered); the list found here counts steps the way `planSteps` does.
136
+ */
137
+ export function planStepBlocks(plan: string): StepBlocks | undefined {
138
+ const lines = plan.split("\n");
139
+ const heading = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
140
+ if (heading < 0) return undefined;
141
+ let to = lines.length;
142
+ for (let index = heading + 1; index < lines.length; index += 1) {
143
+ if (HEADER_LINE.test(lines[index]!)) {
144
+ to = index;
145
+ break;
146
+ }
147
+ }
148
+ const blocks: string[][] = [];
149
+ let parent: number | undefined;
150
+ for (let index = heading + 1; index < to; index += 1) {
151
+ const line = lines[index]!;
152
+ const item = sectionItem(line);
153
+ if (item && !(parent !== undefined && item.indent >= parent)) {
154
+ blocks.push([line]);
155
+ parent = item.content;
156
+ continue;
157
+ }
158
+ if (!item && parent !== undefined && line.trim() && indentOf(line) < parent) parent = undefined;
159
+ if (parent !== undefined && blocks.length > 0) blocks[blocks.length - 1]!.push(line);
160
+ }
161
+ return { from: heading + 1, to, blocks: blocks.map((block) => block.join("\n").replace(/\s+$/, "")) };
162
+ }
163
+
122
164
  /* -------------------------------------------------------------------------
123
165
  * Step matching. A worker instruction names its step explicitly ("Step 3: ...")
124
166
  * or is scored against every step by shared paths, a shared opening phrase and
package/src/pi/route.ts CHANGED
@@ -90,7 +90,8 @@ export async function startRequest(pi: ExtensionAPI, ctx: ExtensionContext, conf
90
90
  const { task: asTask, ...start } = options;
91
91
  const config = loadConfig();
92
92
  const root = detectProjectRoot(ctx.cwd, configDir);
93
- const placed = asTask || start.track || start.approvedPlan || !config.workflow.routeQuickFixes || !handoff;
93
+ // `--branch` and `--worktree` are for tasks, so they place the request; `--no-branch` only says what a task would not get.
94
+ const placed = asTask || start.track || start.approvedPlan || (start.isolation && start.isolation !== "off") || !config.workflow.routeQuickFixes || !handoff;
94
95
  // A session that already drives a task gets startTask's own warning.
95
96
  if (placed || ownedTask(root, configDir, ctx.sessionManager.getSessionId())) {
96
97
  return (await startTask(pi, ctx, configDir, request, start)) ? "task" : undefined;
@@ -7,16 +7,19 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Container, type Component, type Focusable, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
9
9
  import {
10
+ GIT_ISOLATIONS,
10
11
  JEV_HOSTS,
11
12
  INHERIT_MODEL,
12
13
  isThinkingLevel,
13
14
  LOBBY_PANELS,
14
15
  PLANNING_ROUND_CHOICES,
15
16
  SCOUT_THINKING,
17
+ SPLIT_PLAN_CHOICES,
16
18
  SUBAGENT_KINDS,
17
19
  type AgentModelConfig,
18
20
  type BotLobbyConfig,
19
21
  type ClassifierFeature,
22
+ type GitIsolation,
20
23
  type JevHostName,
21
24
  type LobbyPanel,
22
25
  type SubagentKind,
@@ -451,16 +454,30 @@ export function nextRoundLimit(current: number): number {
451
454
  return PLANNING_ROUND_CHOICES.find((choice) => choice > current) ?? 0;
452
455
  }
453
456
 
457
+ /** `over 8 steps`, or `never` for 0. */
458
+ export function splitLimitLabel(limit: number): string {
459
+ return limit > 0 ? `over ${limit} steps` : "never";
460
+ }
461
+
462
+ /** The next step count in the menu's cycle (6, 8, 10, 12, never); a hand-edited value rejoins it. */
463
+ export function nextSplitLimit(current: number): number {
464
+ const index = SPLIT_PLAN_CHOICES.indexOf(current as (typeof SPLIT_PLAN_CHOICES)[number]);
465
+ if (index >= 0) return SPLIT_PLAN_CHOICES[(index + 1) % SPLIT_PLAN_CHOICES.length]!;
466
+ return SPLIT_PLAN_CHOICES.find((choice) => choice > current) ?? 0;
467
+ }
468
+
454
469
  /** On/off settings for the lobby, and the planning round limit; enter flips or cycles one and saves it. Key rebinding stays in the file (lobby.keys). */
455
470
  async function editLobby(ctx: ExtensionContext): Promise<void> {
456
471
  for (;;) {
457
472
  const config = loadConfig();
458
473
  const items: SelectItem[] = LOBBY_SWITCHES.map((entry) => ({ value: entry.id, label: entry.label, description: `${lobbySwitch(config, entry.id) ? "on" : "off"} · ${entry.help}` }));
459
474
  items.push({ value: "rounds", label: "Planning rounds", description: `${roundLimitLabel(config.lobby.maxPlanningRounds)} · enter cycles 2, 3, 5, 8, unlimited; the last round the oracle settles alone` });
475
+ items.push({ value: "split", label: "Split long plans", description: `${splitLimitLabel(config.lobby.splitPlanAbove)} · saving a plan with more steps offers to split it into up to 5 tasks · enter cycles 6, 8, 10, 12, never` });
460
476
  items.push({ value: "back", label: "Back", description: `keys: lobby.keys in ${globalConfigPath()}` });
461
477
  const choice = await pick(ctx, "bot-lobby settings · Lobby", items);
462
478
  if (!choice || choice === "back") return;
463
479
  if (choice === "rounds") saveConfig({ ...config, lobby: { ...config.lobby, maxPlanningRounds: nextRoundLimit(config.lobby.maxPlanningRounds) } });
480
+ else if (choice === "split") saveConfig({ ...config, lobby: { ...config.lobby, splitPlanAbove: nextSplitLimit(config.lobby.splitPlanAbove) } });
464
481
  else saveConfig(toggleLobbySwitch(config, choice as LobbySwitch));
465
482
  }
466
483
  }
@@ -473,6 +490,7 @@ function lobbySummary(config: BotLobbyConfig): string {
473
490
  config.lobby.mouse ? "mouse" : "no mouse",
474
491
  ...(config.lobby.miniLine ? [] : ["no status line"]),
475
492
  `planning: ${roundLimitLabel(config.lobby.maxPlanningRounds)}`,
493
+ `split plans ${splitLimitLabel(config.lobby.splitPlanAbove)}`,
476
494
  ...(config.lobby.issues ? ["issues tab"] : []),
477
495
  ...(hidden.length > 0 ? [`hidden: ${hidden.join(", ")}`] : []),
478
496
  ].join(" · ");
@@ -487,9 +505,29 @@ export const CLASSIFIER_FEATURE_ITEMS: ReadonlyArray<{ id: ClassifierFeature; la
487
505
  { id: "answers", label: "Obvious answers", help: "a panel question whose recommended option the conversation already makes clearly right is answered for you (listed under Assumptions)" },
488
506
  { id: "triage", label: "Task triage", help: "a new task's size, domains and research need reach the Master as hints; a quick fix that is really a task is held for you" },
489
507
  { id: "effort", label: "Effort routing", help: "a simple step runs one thinking level lower, a trivial one on the cheaper model below; a routed run that falls short runs again on your settings" },
508
+ { id: "review", label: "Pull request read", help: "the Git tab's quick read of a pull request: its size, and how likely it is risky, security-relevant, breaking or untested, before an agent reviews it" },
509
+ { id: "knowledge", label: "Relevant knowledge", help: "when an agent's knowledge, standards or decisions file is too long for its prompt, Jev picks the sections that bear on the step, and the agent is told where the rest is" },
490
510
  { id: "files", label: "File hints", help: "scouts, workers, quick fixes and the planning panel start with the files most likely needed, and can look more up with find_relevant_files" },
491
511
  ];
492
512
 
513
+ /** What each git isolation means, for the settings menu. */
514
+ const GIT_ISOLATION_HELP: Record<GitIsolation, string> = {
515
+ off: "tasks work in the folder you started them in",
516
+ branch: "each new task gets a git branch named after it, checked out in the working folder",
517
+ worktree: "each new task gets its own worktree and branch, named after it: every agent runs there, apart from your checkout",
518
+ };
519
+
520
+ /** The next git isolation in the menu's cycle (off, branch, worktree). */
521
+ export function nextGitIsolation(current: GitIsolation): GitIsolation {
522
+ return GIT_ISOLATIONS[(GIT_ISOLATIONS.indexOf(current) + 1) % GIT_ISOLATIONS.length]!;
523
+ }
524
+
525
+ /** `branch · each new task gets…`, for the settings menu; `--branch`, `--worktree` and `--no-branch` decide for one task. */
526
+ export function gitSummary(config: BotLobbyConfig): string {
527
+ const isolation = config.workflow.gitIsolation;
528
+ return `${isolation} · ${GIT_ISOLATION_HELP[isolation]} · enter cycles ${GIT_ISOLATIONS.join(", ")}`;
529
+ }
530
+
493
531
  /** The next Jev host in the menu's cycle. */
494
532
  export function nextJevHost(current: JevHostName): JevHostName {
495
533
  return JEV_HOSTS[(JEV_HOSTS.indexOf(current) + 1) % JEV_HOSTS.length]!;
@@ -605,12 +643,14 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
605
643
  for (;;) {
606
644
  const config = loadConfig();
607
645
  const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
646
+ items.push({ value: "git", label: "Git isolation", description: gitSummary(config) });
608
647
  items.push({ value: "lobby", label: "Lobby", description: lobbySummary(config) });
609
648
  items.push({ value: "classifier", label: "Classifier (Jev)", description: classifierSummary(config, authStatus(ctx)) });
610
649
  items.push({ value: "close", label: "Close" });
611
650
  const choice = await pick(ctx, "bot-lobby settings", items);
612
651
  if (!choice || choice === "close") return;
613
- if (choice === "lobby") await editLobby(ctx);
652
+ if (choice === "git") saveConfig({ ...config, workflow: { ...config.workflow, gitIsolation: nextGitIsolation(config.workflow.gitIsolation) } });
653
+ else if (choice === "lobby") await editLobby(ctx);
614
654
  else if (choice === "classifier") await editClassifier(ctx);
615
655
  else await editEntry(pi, ctx, choice as SettingsKind);
616
656
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The flags that lead a task's request: `--task` (always a task), `--auto`,
3
+ * `--fast`, `--full`, `--branch`, `--worktree`, `--no-branch` and
4
+ * `--budget <time>` (or `--budget=<time>`). The command parses them; a session
5
+ * started from the lobby is named from what follows them.
6
+ */
7
+ export const START_FLAG = /^--(task|auto|fast|full|branch|worktree|no-branch)(?=\s|$)\s*|^--budget(?:=|\s+)(\S+)\s*/;
8
+
9
+ /** The request without its leading flags. */
10
+ export function stripStartFlags(text: string): string {
11
+ let rest = text.trim();
12
+ for (let match = START_FLAG.exec(rest); match; match = START_FLAG.exec(rest)) rest = rest.slice(match[0].length);
13
+ return rest.trim();
14
+ }
@@ -9,17 +9,19 @@ import { createTask, taskRequest, type Task, type TaskTrack, type TaskTriage, ty
9
9
  import type { Domain } from "../schemas/agent.ts";
10
10
  import { chooseTrack, trackLine, trackSummary } from "../workflow/track.ts";
11
11
  import { lobbyFeed } from "../lobby/feed.ts";
12
- import { createTaskDir, ensureProjectStructure, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
12
+ import { createTaskDir, ensureProjectStructure, loadTask, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
13
13
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
14
14
  import { transition } from "../state/task-state.ts";
15
15
  import { shortTitle } from "../text.ts";
16
16
  import { applyStatus } from "./ui.ts";
17
17
  import { applyMasterModel } from "./settings-ui.ts";
18
18
  import { setAutoMode } from "../state/auto.ts";
19
- import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
19
+ import { listPlannedTasks, loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
20
20
  import { triageFor } from "../classifier/instance.ts";
21
21
  import { freshContextOn, markContext } from "./fresh-context.ts";
22
22
  import { setBudget } from "../state/budget.ts";
23
+ import { createWorkspace, gitLine } from "../execution/workspace.ts";
24
+ import type { GitIsolation } from "../schemas/configuration.ts";
23
25
 
24
26
  function uniqueTaskId(root: string, configDir: string, request: string): string {
25
27
  const base = nextTaskId(request);
@@ -62,6 +64,7 @@ export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: bo
62
64
  ...(options.routed ? [ROUTED_LINE] : []),
63
65
  ...(track ? [trackSummary(track)] : []),
64
66
  ...(budgetMinutes > 0 ? [`Time budget: ${budgetMinutes} minutes of work, for you and every agent. Size the plan to fit it and divide it by scope (see Time budget in your prompt).`] : []),
67
+ ...(task.git ? [gitLine(task.git)] : []),
65
68
  "",
66
69
  ];
67
70
  if (track?.path === "fast") return [...head, ...fastSteps(track)].join("\n");
@@ -77,6 +80,23 @@ export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: bo
77
80
  ].join("\n");
78
81
  }
79
82
 
83
+ /**
84
+ * Give the task its git branch or worktree, named after it. Any failure (no
85
+ * repository, no commits, a refused checkout) is said once and the task runs
86
+ * without: git must never stop a task from starting.
87
+ */
88
+ async function giveWorkspace(ctx: ExtensionContext, root: string, configDir: string, task: Task, isolation: GitIsolation): Promise<void> {
89
+ if (isolation === "off") return;
90
+ const made = await createWorkspace({ cwd: ctx.cwd, root, configDir, name: task.id, mode: isolation });
91
+ if (typeof made === "string") {
92
+ lobbyFeed.log("LOBBY", `${task.id} runs without its own ${isolation} — ${made}`, "warning");
93
+ ctx.ui.notify(`bot-lobby: ${task.id} runs without its own ${isolation} — ${made}`, "warning");
94
+ return;
95
+ }
96
+ task.git = made;
97
+ lobbyFeed.log("LOBBY", made.mode === "worktree" ? `${task.id}: worktree ${made.path} on branch ${made.branch}` : `${task.id}: on branch ${made.branch}${made.from ? ` (from ${made.from})` : ""}`, "success");
98
+ }
99
+
80
100
  /**
81
101
  * Create the task, hand it to this session's Master and send the kickoff.
82
102
  * Returns the task, or undefined (with a warning) when this session already
@@ -98,6 +118,8 @@ export interface StartOptions {
98
118
  triage?: TaskTriage;
99
119
  /** The oracle sent this request to the team after the classifier read it as a quick fix. */
100
120
  routed?: boolean;
121
+ /** `--branch`, `--worktree` or `--no-branch`: whether this task gets a git branch or worktree of its own; `workflow.gitIsolation` when absent. */
122
+ isolation?: GitIsolation;
101
123
  }
102
124
 
103
125
  export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string, options: StartOptions = {}): Promise<Task | undefined> {
@@ -120,12 +142,13 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
120
142
  const config = loadConfig();
121
143
  task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}) });
122
144
  lobbyFeed.log("LOBBY", trackLine(task.track), "info");
145
+ await giveWorkspace(ctx, root, configDir, task, options.isolation ?? config.workflow.gitIsolation);
123
146
  saveTask(root, configDir, task);
124
147
  const budgetMinutes = options.budget ?? config.workflow.taskBudgetMinutes;
125
148
  if (budgetMinutes > 0) setBudget(root, configDir, task.id, budgetMinutes);
126
149
  if (options.auto) setAutoMode(root, configDir, task.id, true, sessionId);
127
- // A session that starts a task is named after it, so /resume and the lobby list it by name.
128
- if (!pi.getSessionName()) pi.setSessionName(task.title);
150
+ // A session that starts a task is named after it (its friendly name), so /resume and the lobby list it by name.
151
+ if (!pi.getSessionName()) pi.setSessionName(task.id);
129
152
  applyStatus(ctx, root, configDir);
130
153
  await applyMasterModel(pi, ctx, loadConfig());
131
154
  ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
@@ -136,18 +159,46 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
136
159
  return task;
137
160
  }
138
161
 
162
+ /**
163
+ * The parts of a split plan that this one builds on and that are not finished:
164
+ * still pending, dropped, or started but not completed. Empty for a plan that
165
+ * was not split, or whose earlier parts are done (or gone).
166
+ */
167
+ export function unfinishedBefore(root: string, configDir: string, plan: PlannedTask): string[] {
168
+ const split = plan.split;
169
+ if (!split || split.after.length === 0) return [];
170
+ const siblings = listPlannedTasks(root, configDir).filter((entry) => entry.split?.group === split.group);
171
+ return split.after.flatMap((number) => {
172
+ const sibling = siblings.find((entry) => entry.split?.part === number);
173
+ if (!sibling) return [];
174
+ const label = `part ${number} (${split.titles[number - 1] ?? sibling.id})`;
175
+ if (sibling.status === "pending") return [`${label} has not been started`];
176
+ const task = sibling.startedTaskId ? loadTask(root, configDir, sibling.startedTaskId) : undefined;
177
+ if (!task || task.state === "completed") return [];
178
+ return [`${label} is ${task.state === "abandoned" ? "abandoned" : "not finished"}`];
179
+ });
180
+ }
181
+
139
182
  /**
140
183
  * Start a task saved from the planning panel in this session. The user
141
184
  * already agreed its plan, so the task carries it as approved and its
142
- * proposal goes through without asking. Returns the task, or a reason.
185
+ * proposal goes through without asking. A part of a split plan that builds on
186
+ * parts not yet finished starts anyway, with a warning: the order is the
187
+ * user's to keep. Returns the task, or a reason.
143
188
  */
144
189
  export async function startPlannedTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, planId: string, options: { auto?: boolean } = {}): Promise<Task | string> {
145
190
  const root = detectProjectRoot(ctx.cwd, configDir);
146
191
  const plan = loadPlannedTask(root, configDir, planId);
147
192
  if (!plan) return `no planned task ${planId}`;
148
193
  if (plan.status !== "pending") return `${planId} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
194
+ const waiting = unfinishedBefore(root, configDir, plan);
149
195
  const task = await startTask(pi, ctx, configDir, plannedTaskRequest(plan), { approvedPlan: plan.id, title: plan.title, ...(options.auto ? { auto: true } : {}) });
150
196
  if (!task) return `this session already drives a task; finish or cancel it first`;
151
197
  markPlannedTaskStarted(root, configDir, plan.id, task.id);
198
+ if (waiting.length > 0) {
199
+ const warning = `${plan.id} builds on ${waiting.join("; ")} — started anyway`;
200
+ lobbyFeed.log("LOBBY", warning, "warning");
201
+ ctx.ui.notify(`bot-lobby: ${warning}`, "warning");
202
+ }
152
203
  return task;
153
204
  }
package/src/pi/tools.ts CHANGED
@@ -5,7 +5,7 @@ import { Type } from "typebox";
5
5
  import type { AgentRun } from "../schemas/findings.ts";
6
6
  import type { ProcessRunner } from "../execution/pi-runner.ts";
7
7
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
8
- import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
8
+ import { classifier, effortFor, hintsFor, knowledgeFor, triageFor } from "../classifier/instance.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
11
  import { whileAsking } from "../state/budget.ts";
@@ -119,11 +119,13 @@ export function workflowDeps(
119
119
  onUpdate,
120
120
  runProcess,
121
121
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
122
+ knowledge: knowledgeFor(),
122
123
  classifier: classifier(),
123
124
  triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
124
125
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
125
126
  // Time spent waiting on the user is not the task's: its budget clock waits too.
126
- ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.input(question)) : undefined),
127
+ // A free-text answer may run to several lines (Shift+Enter), so it is asked in the multi-line editor.
128
+ ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.editor(question)) : undefined),
127
129
  choose: async (title, options) => (hasUI ? whileAsking(() => ctx.ui.select(title, options)) : undefined),
128
130
  // An agent's own questions (the designer's), relayed as the questionnaire.
129
131
  ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),