@a-t-h-i/bot-lobby 0.6.53 → 0.6.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/prompts/expert.md +17 -0
  4. package/prompts/goal-check.md +44 -0
  5. package/prompts/master.md +70 -32
  6. package/prompts/worker.md +25 -11
  7. package/src/desk/board-client.ts +65 -0
  8. package/src/desk/board.ts +200 -0
  9. package/src/desk/client-extension.ts +31 -1
  10. package/src/desk/ipc.ts +16 -4
  11. package/src/desk/session.ts +129 -1
  12. package/src/execution/agent-runner.ts +27 -132
  13. package/src/execution/delegation.ts +112 -38
  14. package/src/execution/live-run.ts +3 -14
  15. package/src/execution/pi-runner.ts +30 -163
  16. package/src/execution/worker-trees.ts +396 -0
  17. package/src/index.ts +6 -0
  18. package/src/lobby/feed.ts +1 -1
  19. package/src/lobby/host.ts +2 -0
  20. package/src/lobby/planner.ts +2 -2
  21. package/src/lobby/pr-review.ts +3 -3
  22. package/src/lobby/quickfix.ts +4 -3
  23. package/src/lobby/recovery.ts +2 -10
  24. package/src/lobby/runtime.ts +1 -1
  25. package/src/lobby/service.ts +3 -1
  26. package/src/master/goal-check.ts +114 -0
  27. package/src/master/master.ts +60 -24
  28. package/src/master/research.ts +3 -6
  29. package/src/pi/commands.ts +5 -45
  30. package/src/pi/delegation.ts +23 -4
  31. package/src/pi/events.ts +6 -19
  32. package/src/pi/grants.ts +89 -0
  33. package/src/pi/model-settings.ts +2 -4
  34. package/src/pi/model-support.ts +12 -5
  35. package/src/pi/route.ts +1 -1
  36. package/src/pi/run-summary.ts +3 -7
  37. package/src/pi/start-flags.ts +3 -3
  38. package/src/pi/start-task.ts +2 -8
  39. package/src/pi/tools.ts +10 -12
  40. package/src/roles/worker.ts +0 -18
  41. package/src/schemas/configuration.ts +81 -39
  42. package/src/schemas/findings.ts +0 -12
  43. package/src/schemas/task.ts +12 -1
  44. package/src/state/metrics.ts +1 -1
  45. package/src/state/work-clock.ts +140 -0
  46. package/src/state/work-time.ts +4 -7
  47. package/src/webui/api/index.ts +2 -1
  48. package/src/webui/api/settings.ts +6 -18
  49. package/src/webui/dev/fake-service.ts +14 -0
  50. package/src/webui/protocol.ts +23 -0
  51. package/src/workflow/workflow.ts +204 -344
  52. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
  53. package/webui/dist/assets/index-BkXhtB6A.css +2 -0
  54. package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
  55. package/webui/dist/build.json +10 -6
  56. package/webui/dist/index.html +2 -2
  57. package/src/state/budget.ts +0 -301
  58. package/src/state/step-limits.ts +0 -41
  59. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  60. package/webui/dist/assets/index-DsVdXmh5.css +0 -2
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The goal check: before a task is called done, one read-only agent with a
3
+ * clean context compares the change with what the user actually asked for
4
+ * (the request and its amendments, not the plan) and names any requirement
5
+ * the change does not meet. Plans drift and agents can finish every step and
6
+ * still miss the point; this catches it at the one moment it is cheap to fix.
7
+ */
8
+ import type { AgentRun } from "../schemas/findings.ts";
9
+ import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
10
+ import { profileFor } from "../schemas/configuration.ts";
11
+ import { runAgent, watchdogOptions } from "../execution/agent-runner.ts";
12
+ import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
13
+ import { loadPrompt } from "../prompts/loader.ts";
14
+ import { truncate } from "../text.ts";
15
+
16
+ export type GoalVerdict = "met" | "not_met" | "unread";
17
+
18
+ export interface GoalCheckResult {
19
+ verdict: GoalVerdict;
20
+ /** Requirements the change does not meet, one line each. */
21
+ gaps: string[];
22
+ run: AgentRun;
23
+ }
24
+
25
+ export interface GoalCheckRequest {
26
+ taskId: string;
27
+ /** The user's request as they wrote it. */
28
+ request: string;
29
+ amendments: readonly string[];
30
+ diff: string;
31
+ cwd: string;
32
+ config: BotLobbyConfig;
33
+ profile?: ProfileResolver;
34
+ signal?: AbortSignal;
35
+ onUpdate?: (run: AgentRun) => void;
36
+ }
37
+
38
+ /** Most of the change it reads; a longer diff is cut, and the check reads the files it needs. */
39
+ export const GOAL_DIFF_CHARS = 60_000;
40
+
41
+ function section(text: string, title: string): string {
42
+ const match = new RegExp(`^#{1,3}\\s+${title}\\s*:?\\s*$([\\s\\S]*?)(?=^#{1,3}\\s|(?![\\s\\S]))`, "im").exec(text);
43
+ return match?.[1]?.trim() ?? "";
44
+ }
45
+
46
+ /** The verdict as written: on the heading line (`## Verdict: MET`, `**Verdict:** MET`) or the first line under it. */
47
+ function verdictLine(text: string): string {
48
+ const inline = /^\s*(?:#{1,3}\s*)?\**verdict\**\s*:\**\s*(\S.*)$/im.exec(text);
49
+ if (inline?.[1]) return inline[1];
50
+ return section(text, "Verdict").split("\n").find((line) => line.trim()) ?? "";
51
+ }
52
+
53
+ /** Words that make a verdict anything but a plain MET. */
54
+ const QUALIFIED = /\b(NOT|UNMET|PARTIAL(LY)?|MOSTLY|LARGELY|EXCEPT|BUT|SOME|INCOMPLETE|FAIL(ED|S)?)\b/;
55
+
56
+ /**
57
+ * The verdict and gaps from a goal check's report; `unread` when it has no
58
+ * verdict. Only a plain MET is met: a qualified one (`NOT MET`, `NOT-MET`,
59
+ * `PARTIALLY MET`, `MET except …`) is not, and neither is a MET with gaps listed.
60
+ */
61
+ export function parseGoalCheck(output: string): { verdict: GoalVerdict; gaps: string[] } {
62
+ const said = verdictLine(output).toUpperCase().replace(/[*_`]/g, " ").replace(/\s+/g, " ").trim();
63
+ const gaps = section(output, "Gaps")
64
+ .split("\n")
65
+ .map((line) => line.replace(/^\s*[-*]\s*/, "").trim())
66
+ .filter((line) => line.length > 0 && !/^\(?none\b[^a-z]*$/i.test(line) && !/^no gaps\.?$/i.test(line));
67
+ // The format line copied back (`MET or NOT MET`) says nothing.
68
+ if (/\bOR\b/.test(said)) return { verdict: "unread", gaps: [] };
69
+ let verdict: GoalVerdict = QUALIFIED.test(said) || /NOT[-_ ]?MET|UNMET/.test(said) ? "not_met" : /^MET\b/.test(said) ? "met" : "unread";
70
+ if (verdict === "met" && gaps.length > 0) verdict = "not_met";
71
+ // A NOT MET without any gap named is still a gap: point at the requirements list.
72
+ if (verdict === "not_met" && gaps.length === 0) {
73
+ const unmet = section(output, "Requirements").split("\n").filter((line) => /—\s*(not|partly|partially) met\s*—/i.test(line)).map((line) => line.replace(/^\s*[-*]\s*/, "").trim());
74
+ return { verdict, gaps: unmet.length > 0 ? unmet : [`the goal check found the request not fully met (${truncate(said, 120)}) but named no gap`] };
75
+ }
76
+ return { verdict, gaps: verdict === "met" ? [] : gaps };
77
+ }
78
+
79
+ export function goalCheckInstruction(request: GoalCheckRequest): string {
80
+ return [
81
+ "Check this change against what the user asked for.",
82
+ `## The user's request\n\n${request.request.trim()}`,
83
+ request.amendments.length > 0 ? `## The user's amendments\n\n${request.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
84
+ `## The change\n\n${truncate(request.diff, GOAL_DIFF_CHARS)}`,
85
+ ].filter(Boolean).join("\n\n");
86
+ }
87
+
88
+ /** The goal check's prompt, with the QA settings' own instructions when the user gave any. */
89
+ export function goalCheckPrompt(instructions?: string): string {
90
+ const custom = instructions?.trim();
91
+ return custom ? `${loadPrompt("goal-check.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("goal-check.md");
92
+ }
93
+
94
+ export async function runGoalCheck(request: GoalCheckRequest, run: ProcessRunner = spawnPiProcess): Promise<GoalCheckResult> {
95
+ // The QA settings' model reads the change: the same reader the gate trusts, on a much smaller job.
96
+ const profile = profileFor(request.config, request.profile, "qa", "reviewer");
97
+ const agentRun = await runAgent({
98
+ taskId: request.taskId,
99
+ domain: "qa",
100
+ role: "reviewer",
101
+ instruction: goalCheckInstruction(request),
102
+ context: { task: request.request },
103
+ systemPrompt: goalCheckPrompt(profile.instructions),
104
+ ...(profile.model ? { model: profile.model } : {}),
105
+ thinking: profile.thinking,
106
+ cwd: request.cwd,
107
+ ...(request.signal ? { signal: request.signal } : {}),
108
+ ...(request.onUpdate ? { onUpdate: (update: AgentRun) => request.onUpdate!({ ...update, note: update.note ?? "goal check" }) } : {}),
109
+ ...watchdogOptions(request.config.workflow),
110
+ retries: 0,
111
+ }, run);
112
+ if (agentRun.status !== "success") return { verdict: "unread", gaps: [], run: agentRun };
113
+ return { ...parseGoalCheck(agentRun.output), run: agentRun };
114
+ }
@@ -4,7 +4,7 @@ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schema
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { boundedAgentTime, runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
7
+ import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { knowledgeFilePath, readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { taskDir } from "../knowledge/paths.ts";
@@ -19,6 +19,7 @@ import { treeState } from "../execution/git.ts";
19
19
  import { toolBudgetEnv, toolBudgetNote } from "../pi/tool-budget.ts";
20
20
  import { recordFileNotes } from "../knowledge/file-notes.ts";
21
21
  import { readCachedScout, scoutKey, writeCachedScout } from "./scout-cache.ts";
22
+ import { BOARD_ENV, BOARD_TOOLS, boardContext } from "../desk/board.ts";
22
23
  import type { FileHinter } from "../classifier/files.ts";
23
24
  import type { KnowledgePaths, KnowledgePicker } from "../classifier/knowledge.ts";
24
25
  import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
@@ -33,8 +34,6 @@ export interface ScoutOutcome {
33
34
  }
34
35
 
35
36
  export interface ScoutRequest {
36
- /** Under a task time budget: the batch's time (every scout runs within it). */
37
- time?: AgentTime;
38
37
  taskId: string;
39
38
  /** Requirements/objective text used for context selection. */
40
39
  taskText: string;
@@ -45,7 +44,7 @@ export interface ScoutRequest {
45
44
  dataRoots: readonly string[];
46
45
  taskDir: string;
47
46
  config: BotLobbyConfig;
48
- /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
47
+ /** Settings-derived model/thinking/grants, clamped to the model; plain settings when absent. */
49
48
  profile?: ProfileResolver;
50
49
  signal?: AbortSignal;
51
50
  onUpdate?: (run: AgentRun) => void;
@@ -57,10 +56,10 @@ export interface ScoutRequest {
57
56
  effort?: EffortRouter;
58
57
  }
59
58
 
60
- /** Model, thinking and time limit for one run, from settings. */
59
+ /** Model, thinking and grants for one run, from settings. */
61
60
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
62
61
  const profile = profileFor(config, resolver, domain, role);
63
- return { model: profile.model, mcpTools: profile.mcpTools, ...(profile.autoCompact === false ? { autoCompact: false } : {}), thinking: profile.thinking, timeoutMs: profile.timeoutMs, time: boundedAgentTime(profile.timeoutMs) };
62
+ return { model: profile.model, mcpTools: profile.mcpTools, ...(profile.skills ? { skills: profile.skills } : {}), ...(profile.autoCompact === false ? { autoCompact: false } : {}), thinking: profile.thinking };
64
63
  }
65
64
 
66
65
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -103,7 +102,7 @@ function scoutContext(request: ScoutRequest, domain: Domain, selected: Knowledge
103
102
  task: request.taskText,
104
103
  ...selected,
105
104
  instructions: request.config.agents[domain].instructions,
106
- workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, toolBudgetNote(request.config.scout.maxToolCalls), likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
105
+ workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, toolBudgetNote(request.config.scout.maxToolCalls), likely].filter(Boolean).join("\n\n"),
107
106
  };
108
107
  }
109
108
 
@@ -194,7 +193,6 @@ async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<
194
193
  ...(extraTools.length > 0 ? { extraTools } : {}),
195
194
  env: toolBudgetEnv(request.config.scout.maxToolCalls),
196
195
  ...profileFields(request.config, request.profile, domain, "scout"),
197
- ...(request.time ? { time: { ...request.time } } : {}),
198
196
  cwd: request.cwd,
199
197
  signal: request.signal,
200
198
  onUpdate: request.onUpdate,
@@ -206,7 +204,7 @@ async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<
206
204
  const runs = await runParallel(first, request.config.workflow.maxParallelScouts, run);
207
205
  let outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
208
206
  // A routed scout that came back unusable runs again on its configured model.
209
- const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted && requests[index]!.time!.endsAt > Date.now() ? index : -1)).filter((index) => index >= 0);
207
+ const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted ? index : -1)).filter((index) => index >= 0);
210
208
  if (again.length > 0) {
211
209
  const reruns = await runParallel(again.map((index) => requests[index]!), request.config.workflow.maxParallelScouts, run);
212
210
  outcomes = outcomes.map((outcome, index) => {
@@ -269,7 +267,7 @@ export interface WorkerRequest {
269
267
  cwd: string;
270
268
  dataRoots: readonly string[];
271
269
  config: BotLobbyConfig;
272
- /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
270
+ /** Settings-derived model/thinking/grants, clamped to the model; plain settings when absent. */
273
271
  profile?: ProfileResolver;
274
272
  signal?: AbortSignal;
275
273
  onUpdate?: (run: AgentRun) => void;
@@ -279,12 +277,43 @@ export interface WorkerRequest {
279
277
  knowledge?: KnowledgePicker;
280
278
  /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
281
279
  effort?: EffortRouter;
282
- /** Under a task time budget: the step's time, and who decides on more when it runs out. */
283
- time?: AgentTime;
284
280
  /** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
285
281
  ask?: { onAsk: RelayAsk; previews: string };
286
282
  /** A QA step's verification budget: Jev's QA risk read of the change so far. */
287
283
  risk?: string;
284
+ /** The task board's file: the worker reads what its role follows and posts contracts, decisions and blockers. */
285
+ board?: string;
286
+ /** Domains working at the same time as this one, in a parallel batch. */
287
+ alongside?: readonly Domain[];
288
+ /** The worker has a worktree of its own for the batch; its change is applied back when the batch ends. */
289
+ ownCheckout?: boolean;
290
+ /** The model ask_expert consults on. */
291
+ expert?: { model?: string; thinking: string };
292
+ }
293
+
294
+ /** How a worker uses the task board, and what it already says for its role. */
295
+ function boardGuidance(request: WorkerRequest): string {
296
+ if (!request.board) return "";
297
+ return [
298
+ "Task board: post with `post_board` whenever you settle something another agent builds against or must follow (`contract`: a route, payload, type, event or file location; `decision`), when you are blocked on someone else's part (`blocker`), and when a piece is finished and testable (`done`). Exact facts in a line or two; no progress chatter. `read_board` shows what the others posted.",
299
+ boardContext(request.board, request.domain),
300
+ ].filter(Boolean).join("\n\n");
301
+ }
302
+
303
+ /** What a worker in a parallel batch is told about the others, and QA about testing alongside the build. */
304
+ function alongsideGuidance(request: WorkerRequest): string {
305
+ const others = (request.alongside ?? []).filter((domain) => domain !== request.domain);
306
+ if (others.length === 0) return "";
307
+ const names = others.map((domain) => ({ backend: "DEV", designer: "DESIGN", qa: "QA" })[domain]).join(" and ");
308
+ const peers = `You work at the same time as ${names}. When you need a fact about their part (a name, a shape, a route), ask them with \`ask_peer\` instead of guessing; answer their questions with \`answer_peer\`. Questions only: neither of you takes work from the other.`;
309
+ const checkout = request.ownCheckout
310
+ ? " You work in a checkout of your own (no file claims needed): edit only the files your brief names, because when the batch ends your change is applied onto the others', and overlapping lines will not apply."
311
+ : "";
312
+ if (request.domain !== "qa") return `${peers}${checkout}`;
313
+ return [
314
+ `${peers}${checkout}`,
315
+ "You write the tests while the others build, so nobody waits for the build to finish first: work from the approved plan's contracts and acceptance criteria and the board's `contract` posts, not from their code. Write and claim test files only. When a builder posts `done` for a piece (or before you report), run your tests against what is there; a failing test against finished work is a finding for its owner, never a production fix of yours.",
316
+ ].join(" ");
288
317
  }
289
318
 
290
319
  /** What a worker that may ask the user is told about it. */
@@ -312,9 +341,11 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
312
341
  : "No scout findings were collected for your domain; verify the repository yourself.",
313
342
  likely,
314
343
  request.risk ?? "",
315
- request.time?.note ?? "",
344
+ alongsideGuidance(request),
345
+ boardGuidance(request),
316
346
  request.ask ? askGuidance(request.ask.previews) : "",
317
347
  request.dataRoots[0] ? `Scripts you draft for the shared library go in \`${join(taskDir(request.dataRoots[0], request.taskId), "scripts")}\`.` : "",
348
+ request.dataRoots[0] ? `Long findings (an investigation, a list of call sites, a log excerpt) go in a file under \`${join(taskDir(request.dataRoots[0], request.taskId), "notes")}\`: give its path under Notes instead of pasting them into your report, so whoever needs them reads them whole.` : "",
318
349
  ].filter(Boolean).join("\n\n");
319
350
  }
320
351
 
@@ -330,9 +361,11 @@ export async function runWorker(
330
361
  routeFor(request.effort, request.instruction, { ...(configured.model ? { model: configured.model } : {}), thinking: configured.thinking }, { context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) }),
331
362
  ]);
332
363
  const hintTools = request.hints?.tools() ?? [];
333
- const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools];
364
+ const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools, ...(request.board ? BOARD_TOOLS : [])];
365
+ const env = request.board ? { ...request.agent?.env, [BOARD_ENV]: request.board, BOT_LOBBY_BOARD_FROM: request.domain } : request.agent?.env;
334
366
  const base: AgentRequest = {
335
367
  delegationQuota: { used: 0 },
368
+ ...(request.expert ? { expert: request.expert } : {}),
336
369
  effort: request.effort,
337
370
  effortProfile: configured,
338
371
  taskId: request.taskId,
@@ -351,14 +384,13 @@ export async function runWorker(
351
384
  onUpdate: request.onUpdate,
352
385
  ...watchdogOptions(request.config.workflow),
353
386
  ...request.agent,
387
+ ...(env ? { env } : {}),
354
388
  ...(extraTools.length > 0 ? { extraTools } : {}),
355
- // One allotment for the step: a routed attempt that falls short re-runs on what is left of it.
356
- ...(request.time ? { time: request.time } : {}),
357
389
  ...(request.ask ? { onAsk: request.ask.onAsk } : {}),
358
390
  };
359
391
  let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
360
392
  // A routed step that fell short runs again at the configured model and thinking.
361
- if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted && base.time!.endsAt > Date.now()) {
393
+ if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
362
394
  const routed = outcome.run.edited ?? [];
363
395
  outcome = workerOutcome(request.domain, await runAgent(base, run));
364
396
  // The step owns what the routed attempt edited too.
@@ -399,13 +431,15 @@ export interface ReviewerRequest {
399
431
  lint?: string;
400
432
  /** Jev's QA risk read of the change: the gate's depth, test budget and focus. */
401
433
  risk?: string;
402
- /** Under a task time budget: the gate's time. */
403
- time?: AgentTime;
434
+ /** The task board's contracts, decisions, blockers and finished pieces, as posted by the task's agents. */
435
+ board?: string;
436
+ /** Review with a clean context: no worker summaries or scout findings, only the request, plan, board and diff. */
437
+ clean?: boolean;
404
438
  instruction?: string;
405
439
  cwd: string;
406
440
  dataRoots: readonly string[];
407
441
  config: BotLobbyConfig;
408
- /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
442
+ /** Settings-derived model/thinking/grants, clamped to the model; plain settings when absent. */
409
443
  profile?: ProfileResolver;
410
444
  signal?: AbortSignal;
411
445
  onUpdate?: (run: AgentRun) => void;
@@ -422,11 +456,14 @@ function reviewerContext(request: ReviewerRequest): string {
422
456
  "You may not modify implementation. Report required changes instead.",
423
457
  request.risk ?? "",
424
458
  request.previousRound ?? "",
425
- request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
459
+ request.clean
460
+ ? "You review with a clean context: the request, the approved plan, the task board and the diff, not the workers' own accounts of what they did. Judge the change by what the code does, and verify any claim yourself before you rely on it."
461
+ : request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
462
+ // QA scouts planned these tests: their findings are the reviewer's own groundwork, not a worker's account.
426
463
  owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
427
464
  request.provenance ? `Who changed each file (bot-lobby's record of every agent's edit and write calls; judge each as your role's Change provenance says):\n${request.provenance}` : "",
428
465
  request.lint ?? "",
429
- request.time?.note ?? "",
466
+ request.board ?? "",
430
467
  `Repository changes:\n${request.diff}`,
431
468
  ]
432
469
  .filter((line) => line.length > 0)
@@ -459,7 +496,6 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
459
496
  workflowContext: reviewerContext(request),
460
497
  },
461
498
  ...profileFields(request.config, request.profile, request.domain, "reviewer"),
462
- ...(request.time ? { time: request.time } : {}),
463
499
  cwd: request.cwd,
464
500
  signal: request.signal,
465
501
  onUpdate: request.onUpdate,
@@ -483,7 +519,7 @@ export async function runReviewer(
483
519
  const agentRequest = reviewerAgentRequest(request, selected);
484
520
  const attempts = Math.max(1, request.config.workflow.maxAgentRetries + 1);
485
521
  let agentRun = await runAgent(agentRequest, run);
486
- for (let attempt = 2; attempt <= attempts && isRetryableReviewRun(agentRun) && agentRequest.time!.endsAt > Date.now(); attempt++) {
522
+ for (let attempt = 2; attempt <= attempts && isRetryableReviewRun(agentRun); attempt++) {
487
523
  agentRun = await runAgent(agentRequest, run);
488
524
  }
489
525
  const result = parseReviewResult(request.domain, agentRun.output);
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import type { Domain } from "../schemas/agent.ts";
3
3
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { AgentRun, ResearchResult } from "../schemas/findings.ts";
5
- import { runAgent, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
5
+ import { runAgent, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
6
6
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
7
7
  import { writeFileEnsured } from "../knowledge/store.ts";
8
8
  import { isResearchResultUsable, parseResearchResult, validateResearchResult } from "../roles/researcher.ts";
@@ -26,8 +26,6 @@ export interface ResearchRequest {
26
26
  profile?: ProfileResolver;
27
27
  signal?: AbortSignal;
28
28
  onUpdate?: (run: AgentRun) => void;
29
- /** Under a task time budget: the researcher's time. */
30
- time?: AgentTime;
31
29
  }
32
30
 
33
31
  export function researchResultPath(taskDir: string, domain: Domain): string {
@@ -42,7 +40,7 @@ function researchContext(request: ResearchRequest): AgentRequest["context"] {
42
40
  return {
43
41
  task: request.instruction,
44
42
  instructions: request.config.agents[request.domain].instructions,
45
- workflowContext: [`Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
43
+ workflowContext: [`Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`].join("\n\n"),
46
44
  };
47
45
  }
48
46
 
@@ -71,10 +69,9 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
71
69
  context: researchContext(request),
72
70
  model: profile.model,
73
71
  mcpTools: profile.mcpTools,
72
+ ...(profile.skills ? { skills: profile.skills } : {}),
74
73
  ...(profile.autoCompact === false ? { autoCompact: false } : {}),
75
74
  thinking: profile.thinking,
76
- timeoutMs: profile.timeoutMs,
77
- ...(request.time ? { time: request.time } : {}),
78
75
  cwd: request.cwd,
79
76
  signal: request.signal,
80
77
  onUpdate: request.onUpdate,
@@ -30,7 +30,6 @@ import { registerWebServer, webCommand } from "../webui/command.ts";
30
30
  import { modelRef, thinkingMismatches } from "./model-support.ts";
31
31
  import { describeRun, runFromLog } from "./run-summary.ts";
32
32
  import { modelLookup } from "./tools.ts";
33
- import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startClock } from "../state/budget.ts";
34
33
  import { qaStillDue } from "../workflow/track.ts";
35
34
  import type { GitIsolation } from "../schemas/configuration.ts";
36
35
  import { START_FLAG } from "./start-flags.ts";
@@ -41,10 +40,8 @@ const HELP = [
41
40
  "/bot-lobby Open the web lobby in your browser: tasks, plan, quick fix, metrics",
42
41
  "/bot-lobby <request> Start a request: a quick fix when one agent can do it alone (the oracle confirms), else a task",
43
42
  "/bot-lobby --task [--auto] <request> Always a task (also when the request begins with a subcommand word)",
44
- "/bot-lobby --budget 90m <request> Start a task with a time budget the oracle divides between its agents",
45
43
  "/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",
46
44
  "/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",
47
- "/bot-lobby budget [90m|off] Show or set this session's task time budget",
48
45
  "/bot-lobby status [taskId] Show the active task",
49
46
  "/bot-lobby tasks List tasks",
50
47
  "/bot-lobby pause|resume Pause or resume the active task",
@@ -66,7 +63,7 @@ const HELP = [
66
63
  ].join("\n");
67
64
 
68
65
  /** Subcommands only win when no free-form text follows (so tasks still start). */
69
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
66
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
70
67
 
71
68
  /** Subcommands that act on a task or open the lobby turn bot-lobby on first; the rest work while it is off. */
72
69
  const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
@@ -81,10 +78,6 @@ export interface ParsedCommand {
81
78
  rest: string[];
82
79
  restText: string;
83
80
  auto?: boolean;
84
- /** Minutes from `--budget`. */
85
- budget?: number;
86
- /** `--budget` with something that is not a time. */
87
- budgetError?: string;
88
81
  /** `--fast` or `--full`: the task's path, whatever its request reads as. */
89
82
  track?: "fast" | "full";
90
83
  /** `--task`: a task, never routed to the quick-fix agent. */
@@ -98,7 +91,7 @@ export function parseCommand(args: string): ParsedCommand {
98
91
  // Leading flags start a task: what follows is its request, even when it begins with a subcommand word
99
92
  // (a background session started from the lobby sends `--task [--auto] <request>`).
100
93
  let flagged = false;
101
- const extras: Pick<ParsedCommand, "auto" | "budget" | "budgetError" | "track" | "task" | "isolation"> = {};
94
+ const extras: Pick<ParsedCommand, "auto" | "track" | "task" | "isolation"> = {};
102
95
  for (let match = START_FLAG.exec(trimmed); match; match = START_FLAG.exec(trimmed)) {
103
96
  flagged = true;
104
97
  if (match[1] === "auto") extras.auto = true;
@@ -106,11 +99,6 @@ export function parseCommand(args: string): ParsedCommand {
106
99
  else if (match[1] === "fast" || match[1] === "full") extras.track = match[1];
107
100
  else if (match[1] === "branch" || match[1] === "worktree") extras.isolation = match[1];
108
101
  else if (match[1] === "no-branch") extras.isolation = "off";
109
- else if (match[2] !== undefined) {
110
- const minutes = parseMinutes(match[2]);
111
- if (minutes) extras.budget = minutes;
112
- else extras.budgetError = `"${match[2]}" is not a time budget (try 90m, 1h or 1h30m)`;
113
- }
114
102
  trimmed = trimmed.slice(match[0].length);
115
103
  }
116
104
  if (flagged) return { sub: undefined, rest: [], restText: trimmed.trim(), ...extras };
@@ -118,7 +106,6 @@ export function parseCommand(args: string): ParsedCommand {
118
106
  if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
119
107
  const takesArgs = sub === "amend" || sub === "claim"
120
108
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
121
- || (sub === "budget" && rest.length >= 1 && rest.length <= 2 && (/^off$/i.test(rest[0]!) || parseMinutes(rest.join("")) !== undefined))
122
109
  || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
123
110
  || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")))
124
111
  || (sub === "web" && rest.length === 1 && ["stop", "link", "reset"].includes(rest[0]!));
@@ -142,9 +129,7 @@ function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: st
142
129
  broken.length > 0 ? `Unreadable task state: ${broken.join(", ")}` : "",
143
130
  ].filter(Boolean).join("\n");
144
131
  const footer = knowledge ? `\n${knowledge}` : "";
145
- const budget = task ? readBudget(root, configDir, task.id) : undefined;
146
- const time = task && budget ? `\n${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : "";
147
- ctx.ui.notify(task ? `${describeTask(task)}${time}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
132
+ ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
148
133
  }
149
134
 
150
135
  /** Recent runs of the active (or named) task, newest last, so slow models are easy to spot. */
@@ -288,28 +273,6 @@ function acceptWork(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: s
288
273
  pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
289
274
  }
290
275
 
291
- /**
292
- * `/bot-lobby budget [time|off]`: show, set or remove the time budget of this
293
- * session's task. Time already spent is kept; the oracle sees the change on
294
- * its next step.
295
- */
296
- function budgetCommand(ctx: ExtensionCommandContext, configDir: string, value: string): void {
297
- const root = detectProjectRoot(ctx.cwd, configDir);
298
- const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
299
- if (!task) return ctx.ui.notify("bot-lobby: no active task in this session — a time budget applies to a task (start one with /bot-lobby --budget 90m <request>)", "warning");
300
- if (!value) {
301
- const budget = readBudget(root, configDir, task.id);
302
- return ctx.ui.notify(budget ? `${task.id} — ${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : `${task.id} has no time budget. Set one with /bot-lobby budget 90m.`, "info");
303
- }
304
- const minutes = /^off$/i.test(value) ? 0 : parseMinutes(value);
305
- if (minutes === undefined) return ctx.ui.notify(`bot-lobby: "${value}" is not a time budget (try 90m, 1h or 1h30m)`, "warning");
306
- const budget = setBudget(root, configDir, task.id, minutes);
307
- // Set while the oracle works: its clock runs from now, not from its next turn.
308
- if (budget && !ctx.isIdle()) startClock(root, configDir, task.id);
309
- applyStatus(ctx, root, configDir);
310
- ctx.ui.notify(budget ? `bot-lobby: ${task.id} — ${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : `bot-lobby: ${task.id} has no time budget now.`, "info");
311
- }
312
-
313
276
  function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
314
277
  const root = detectProjectRoot(ctx.cwd, configDir);
315
278
  const cfg = loadConfig();
@@ -373,14 +336,13 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
373
336
  return filtered.length > 0 ? filtered : null;
374
337
  },
375
338
  handler: async (args, ctx) => {
376
- const { sub, rest, restText, auto, budget, budgetError, track, task, isolation } = parseCommand(args ?? "");
377
- if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
339
+ const { sub, rest, restText, auto, track, task, isolation } = parseCommand(args ?? "");
378
340
  // A request, the bare command or anything that drives a task needs bot-lobby on (`web stop` does not).
379
341
  if (!sub || (TURNS_ON.has(sub) && !(sub === "web" && rest[0] === "stop"))) turnOn(pi, ctx, configDir);
380
342
  if (!sub) {
381
343
  if (!restText) return webCommand(ctx, undefined);
382
344
  // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
383
- await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
345
+ await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
384
346
  return;
385
347
  }
386
348
  switch (sub) {
@@ -408,8 +370,6 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
408
370
  return answerProposal(ctx, configDir, "decline");
409
371
  case "accept":
410
372
  return acceptWork(pi, ctx, configDir, rest[0]);
411
- case "budget":
412
- return budgetCommand(ctx, configDir, rest.join(""));
413
373
  case "knowledge":
414
374
  return showKnowledge(ctx, configDir);
415
375
  case "settings":
@@ -1,14 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import { createDeskClient } from "../desk/ipc.ts";
4
- import { AGENT_ENV, DELEGATION_ENV, DELEGATE_TOOL, MAX_CHILDREN, type AgentPolicy } from "../execution/delegation.ts";
5
+ import { AGENT_ENV, DELEGATION_ENV, DELEGATE_TOOL, EXPERT_TOOL, MAX_CHILDREN, MAX_CONSULTS, type AgentPolicy } from "../execution/delegation.ts";
5
6
  import { loadConfig } from "../state/project.ts";
6
7
  import { isSubagentProcess } from "./quiet.ts";
7
8
 
8
9
  export function allowedAgentTool(policy: AgentPolicy, name: string, readOnlyHint?: boolean, hidden = false): boolean {
9
10
  if (hidden) return false;
10
11
  if (name.startsWith("mcp__")) return policy.mcpTools.includes(name) && (!policy.readOnly || readOnlyHint === true);
11
- if (name === DELEGATE_TOOL && policy.depth > 1) return false;
12
+ if ((name === DELEGATE_TOOL || name === EXPERT_TOOL) && policy.depth > 1) return false;
12
13
  return policy.tools.includes(name);
13
14
  }
14
15
 
@@ -44,7 +45,7 @@ export function registerAgentPermissions(pi: ExtensionAPI): void {
44
45
 
45
46
  export function registerDelegation(pi: ExtensionAPI): void {
46
47
  if (!isSubagentProcess() || !process.env[DELEGATION_ENV]) return;
47
- const target = JSON.parse(process.env[DELEGATION_ENV]!) as { address: string; token: string; timeoutMs: number };
48
+ const target = JSON.parse(process.env[DELEGATION_ENV]!) as { address: string; token: string };
48
49
  const client = createDeskClient(target.address, target.token);
49
50
  pi.on("session_shutdown", () => client.close());
50
51
  pi.registerTool({
@@ -58,11 +59,29 @@ export function registerDelegation(pi: ExtensionAPI): void {
58
59
  const cancel = () => { void client.request({ op: "cancel_delegate" }); };
59
60
  signal?.addEventListener("abort", cancel, { once: true });
60
61
  try {
61
- const answer = await client.request({ op: "delegate", briefs: params.briefs }, target.timeoutMs + 10_000);
62
+ const answer = await client.request({ op: "delegate", briefs: params.briefs }, 0);
62
63
  if (!answer.ok) throw new Error(answer.text);
63
64
  const children = JSON.parse(answer.text) as Array<{ runId: string; status: string; report: string; reportPath?: string; error?: string }>;
64
65
  return { content: [{ type: "text", text: children.map((child) => `Child ${child.runId}: ${child.status}\n${child.report}${child.reportPath ? `\nFull report: ${child.reportPath}` : ""}${child.error ? `\nError: ${child.error}` : ""}`).join("\n\n") }], details: { children } };
65
66
  } finally { signal?.removeEventListener("abort", cancel); }
66
67
  },
67
68
  });
69
+ pi.registerTool({
70
+ name: EXPERT_TOOL,
71
+ label: "Ask an expert",
72
+ description: `Ask a stronger model for advice on a hard step: a bug you cannot find after a real attempt, a tricky algorithm, an unfamiliar API, or a choice you are unsure of. Say what you are trying to do, what you tried and what happened, and point to the files and lines. It reads the repository and answers; it does not change anything, and you still do the work. At most ${MAX_CONSULTS} per step.`,
73
+ parameters: Type.Object({ question: Type.String({ minLength: 1, maxLength: 6000, description: "The question, with what you tried and the files and lines involved" }) }),
74
+ executionMode: "sequential",
75
+ async execute(_id, params, signal) {
76
+ signal?.throwIfAborted();
77
+ const consultId = randomUUID();
78
+ const cancel = () => { void client.request({ op: "cancel_consult", consultId }); };
79
+ signal?.addEventListener("abort", cancel, { once: true });
80
+ try {
81
+ const answer = await client.request({ op: "consult", text: params.question, consultId }, 0);
82
+ if (!answer.ok) throw new Error(answer.text);
83
+ return { content: [{ type: "text", text: `Expert advice:\n${answer.text}` }], details: undefined };
84
+ } finally { signal?.removeEventListener("abort", cancel); }
85
+ },
86
+ });
68
87
  }
package/src/pi/events.ts CHANGED
@@ -20,7 +20,7 @@ import { triageContext } from "../classifier/triage.ts";
20
20
  import { qaStillDue, rosterWords } from "../workflow/track.ts";
21
21
  import { previousTaskNote } from "./fresh-context.ts";
22
22
  import { skippedStepsNote } from "./plan-checklist.ts";
23
- import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
23
+ import { clockTask, pauseClocks, resumeClocks, startClock, stopClocks, turnStarted } from "../state/work-clock.ts";
24
24
 
25
25
  /** Tools that wait on the user: the task's clock waits with them. */
26
26
  const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
@@ -28,22 +28,9 @@ const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
28
28
  /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
29
29
  const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
30
30
 
31
- /** The Master's workflow context: the task's state, where its time budget stands, and the classifier's triage while the task is being shaped. */
32
- export function masterWorkflowContext(task: Task, time = ""): string {
33
- return [describeTask(task), time, SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
34
- }
35
-
36
- /** The oracle's view of the task's time budget: where it stands and how to spend it; "" without one. */
37
- export function budgetContext(root: string, configDir: string, task: Task): string {
38
- const budget = readBudget(root, configDir, task.id);
39
- if (!budget) return "";
40
- const state = budgetState(task.id, budget, !qaStillDue(task));
41
- const running = budget.allotments.filter((entry) => !entry.endedAt).map((entry) => `${entry.who} (${entry.minutes}m)`);
42
- return [
43
- budgetLine(budget, state),
44
- running.length > 0 ? `Running now: ${running.join(", ")}.` : "",
45
- "Divide what is left by scope: pass `minutes` on each implement (per assignment in a parallel batch), keeping the QA gate's reserve. Every agent is told its minutes; one that runs out reports where it left off and the user decides on more.",
46
- ].filter(Boolean).join(" ");
31
+ /** The Master's workflow context: the task's state, and the classifier's triage while the task is being shaped. */
32
+ export function masterWorkflowContext(task: Task): string {
33
+ return [describeTask(task), SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
47
34
  }
48
35
 
49
36
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
@@ -84,7 +71,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
84
71
  });
85
72
 
86
73
 
87
- // A task's work time (and its time budget) counts while the oracle works on it, not while it waits on the user.
74
+ // A task's work time counts while the oracle works on it, not while it waits on the user.
88
75
  const asking = new Set<string>();
89
76
  pi.on("agent_start", (_event, ctx) => {
90
77
  if (isSubagentProcess()) return;
@@ -150,7 +137,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
150
137
  knowledge: selected.knowledge,
151
138
  decisions: selected.decisions,
152
139
  scripts: dataRoots[0] ? libraryBlock(dataRoots[0]) : "",
153
- workflowContext: masterWorkflowContext(task, budgetContext(root, configDir, task)),
140
+ workflowContext: masterWorkflowContext(task),
154
141
  instructions: loadConfig().master.instructions,
155
142
  });
156
143
  });