@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.
- package/README.md +132 -15
- package/package.json +1 -1
- package/prompts/master.md +15 -0
- package/prompts/panel.md +4 -0
- package/prompts/planner.md +5 -0
- package/prompts/pr-review.md +62 -0
- package/prompts/splitter.md +58 -0
- package/src/ask/dialog.ts +19 -5
- package/src/ask/state.ts +8 -2
- package/src/ask/tool.ts +3 -1
- package/src/ask/view.ts +6 -3
- package/src/classifier/instance.ts +6 -0
- package/src/classifier/knowledge.ts +158 -0
- package/src/classifier/review.ts +146 -0
- package/src/execution/workspace.ts +185 -0
- package/src/knowledge/edit.ts +109 -0
- package/src/knowledge/notes.ts +143 -0
- package/src/knowledge/selector.ts +1 -1
- package/src/knowledge/store.ts +16 -2
- package/src/lobby/ask.ts +42 -21
- package/src/lobby/issues.ts +1 -1
- package/src/lobby/knowledge.ts +179 -0
- package/src/lobby/layout.ts +95 -18
- package/src/lobby/planner.ts +199 -7
- package/src/lobby/pr-review.ts +360 -0
- package/src/lobby/pulls.ts +250 -0
- package/src/lobby/runtime.ts +115 -13
- package/src/lobby/split.ts +230 -0
- package/src/lobby/tabs/git.ts +162 -0
- package/src/lobby/tabs/knowledge.ts +135 -0
- package/src/lobby/tabs/plan.ts +3 -1
- package/src/lobby/tabs/tasks.ts +11 -2
- package/src/lobby/view.ts +432 -32
- package/src/master/master.ts +39 -13
- package/src/pi/commands.ts +12 -7
- package/src/pi/model-support.ts +9 -0
- package/src/pi/plan-checklist.ts +42 -0
- package/src/pi/route.ts +2 -1
- package/src/pi/settings-ui.ts +41 -1
- package/src/pi/start-flags.ts +14 -0
- package/src/pi/start-task.ts +56 -5
- package/src/pi/tools.ts +4 -2
- package/src/schemas/configuration.ts +32 -3
- package/src/schemas/task.ts +15 -0
- package/src/state/backlog.ts +27 -4
- package/src/state/metrics.ts +2 -2
- package/src/state/persistence.ts +5 -5
- package/src/text.ts +38 -0
- package/src/workflow/workflow.ts +11 -0
package/src/master/master.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
...
|
|
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(
|
|
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 =
|
|
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);
|
package/src/pi/commands.ts
CHANGED
|
@@ -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 && /^
|
|
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
|
}
|
package/src/pi/model-support.ts
CHANGED
|
@@ -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]> = [
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/pi/settings-ui.ts
CHANGED
|
@@ -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 === "
|
|
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
|
+
}
|
package/src/pi/start-task.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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)) } : {}),
|