@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
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
- import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
- import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
3
+ import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
4
+ import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
5
5
  import {
6
6
  MAX_RUN_LOG,
7
7
  MAX_WORKER_RECORDS,
@@ -30,12 +30,11 @@ import type { AskQuestion, AskResult } from "../ask/types.ts";
30
30
  import { mapConcurrent } from "../execution/agent-runner.ts";
31
31
  import { taskExecution } from "../execution/task-cancellation.ts";
32
32
  import { parseWorkerResult } from "../roles/worker.ts";
33
- import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
33
+ import { autoNote, DeskSession } from "../desk/session.ts";
34
+ import { closeWorkerTrees, mapToTop, mergeWorkerTrees, openWorkerTrees, type MergeOutcome } from "../execution/worker-trees.ts";
34
35
  import type { Handover } from "../desk/desk.ts";
35
36
  import { changedFiles, commitBefore, commitFiles, commitMessage, headCommit, readRepositoryDiff, uncommitted } from "../execution/git.ts";
36
37
  import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
37
- import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
38
- import type { AgentTime } from "../execution/agent-runner.ts";
39
38
  import {
40
39
  loadScoutResults,
41
40
  runReviewer,
@@ -49,6 +48,9 @@ import {
49
48
  import { researchResultPath, runResearch, type ResearchOutcome, type ResearchRequest } from "../master/research.ts";
50
49
  import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
51
50
  import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
51
+ import { boardContext, boardPath, filterPosts, formatPosts, readBoard } from "../desk/board.ts";
52
+ import { runGoalCheck } from "../master/goal-check.ts";
53
+ import { createHash } from "node:crypto";
52
54
  import { tail, truncate } from "../text.ts";
53
55
  import { isAutoMode } from "../state/auto.ts";
54
56
  import { briefRejection } from "./brief.ts";
@@ -56,8 +58,7 @@ import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApp
56
58
  import { withBlockingRequest } from "../state/blocking-requests.ts";
57
59
  import { pingApproval } from "../pi/notify.ts";
58
60
  import { describeRun, runLogEntry } from "../pi/run-summary.ts";
59
- import { planChecklist, planSteps, runSteps, skippedStepsNote } from "../pi/plan-checklist.ts";
60
- import { STEP_LIMIT_MS, grantStepTime, reserveStepTime, stepTimeLeft } from "../state/step-limits.ts";
61
+ import { skippedStepsNote } from "../pi/plan-checklist.ts";
61
62
  import { noteWorkerRun } from "../state/work-time.ts";
62
63
  import { nextStates } from "./transitions.ts";
63
64
  import type { FileHinter } from "../classifier/files.ts";
@@ -67,7 +68,7 @@ import type { EffortRouter } from "../classifier/effort.ts";
67
68
  import { answerClarify } from "../classifier/triage.ts";
68
69
  import { appendMetrics, metricFromRun } from "../state/metrics.ts";
69
70
  import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
70
- import { builtSomething, fastNext, onFastTrack, parseRoster, PLANNED_NEXT, qaRequired, qaStillDue, qaTookPart, readRequest, rosterWords, satOut, trackSummary } from "./track.ts";
71
+ import { builtSomething, fastNext, onFastTrack, parseRoster, PLANNED_NEXT, qaRequired, qaTookPart, readRequest, rosterWords, satOut, trackSummary } from "./track.ts";
71
72
  import { requestBesidePlan } from "../state/backlog.ts";
72
73
  import { missingWorktree, taskCwd } from "../execution/workspace.ts";
73
74
  import { completionReview, deferReview, refreshReview } from "../delivery/review.ts";
@@ -96,7 +97,6 @@ export const ORCHESTRATE_ACTIONS = [
96
97
  "block",
97
98
  "resume",
98
99
  "decide",
99
- "budget",
100
100
  "track",
101
101
  "status",
102
102
  "cancel",
@@ -122,9 +122,7 @@ export interface OrchestrateParams {
122
122
  /** implement: the concrete instruction for the worker. */
123
123
  task?: string;
124
124
  /** implement: several domains at once, run in parallel through the file desk. */
125
- assignments?: Array<{ domain: string; task: string; minutes?: number }>;
126
- /** implement: minutes the step may take under the task's time budget; budget: extra minutes to ask the user for. */
127
- minutes?: number;
125
+ assignments?: Array<{ domain: string; task: string }>;
128
126
  /** knowledge: which persistent file the text belongs to. */
129
127
  kind?: KnowledgeKind;
130
128
  /** compact: the knowledge file being rewritten; script: a drafted script to take instead of `text`. */
@@ -157,6 +155,8 @@ export interface WorkflowDeps {
157
155
  config: BotLobbyConfig;
158
156
  /** Per-run model/thinking/time limit, clamped to each model; plain settings when absent. */
159
157
  profile?: ProfileResolver;
158
+ /** The model and thinking ask_expert consults on, resolved like the profiles; the settings' own when absent. */
159
+ expert?: { model?: string; thinking: string };
160
160
  signal?: AbortSignal;
161
161
  onUpdate?: (run: AgentRun) => void;
162
162
  ask: (question: string) => Promise<string | undefined>;
@@ -361,13 +361,11 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
361
361
  if (task.state === "clarifying") transition(task, "scouting");
362
362
  const verifying = task.state === "synthesizing";
363
363
  const instruction = params.instruction?.trim() || "Investigate this request and report findings the Master needs.";
364
- const time = readerTime(task, deps, "SCOUTS", instruction, SCOUT_SHARE, deps.config.scout.timeoutMs, "scouting");
365
364
  const outcomes = await runScouts(
366
365
  {
367
366
  taskId: task.id,
368
367
  taskText: taskRequest(task),
369
368
  instruction,
370
- ...(time ? { time } : {}),
371
369
  domains,
372
370
  cwd: deps.cwd,
373
371
  dataRoots: readDataRoots(deps.root, deps.configDir),
@@ -382,7 +380,6 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
382
380
  },
383
381
  deps.runProcess ?? spawnPiProcess,
384
382
  );
385
- if (time) settleAllotment(task, deps, time.id, "finished");
386
383
  if (!verifying) transition(task, "synthesizing");
387
384
  const involved = outcomes.filter((outcome) => outcome.usable).map((outcome) => outcome.result.domain);
388
385
  task.domains = [...new Set([...task.domains, ...involved])];
@@ -466,13 +463,11 @@ function researchRequestFor(
466
463
  domain: Domain,
467
464
  instruction: string,
468
465
  taskDir: string,
469
- time?: AgentTime,
470
466
  ): ResearchRequest {
471
467
  return {
472
468
  taskId: task.id,
473
469
  domain,
474
470
  instruction,
475
- ...(time ? { time } : {}),
476
471
  config: deps.config,
477
472
  profile: deps.profile,
478
473
  cwd: deps.cwd,
@@ -490,9 +485,7 @@ async function handleResearch(task: Task, params: OrchestrateParams, deps: Workf
490
485
  const instruction = params.instruction?.trim();
491
486
  if (!instruction) throw new Error("research requires instruction (the question to investigate)");
492
487
  const taskDir = taskDirFor(deps.root, deps.configDir, task.id);
493
- const time = readerTime(task, deps, "RESEARCH", instruction, RESEARCH_SHARE, deps.config.researcher.timeoutMs ?? deps.config.workflow.agentTimeoutMs, "research");
494
- const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir, time), deps.runProcess ?? spawnPiProcess);
495
- if (time) settleAllotment(task, deps, time.id, "finished");
488
+ const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir), deps.runProcess ?? spawnPiProcess);
496
489
  appendResearchLog(taskDir, domain, outcome);
497
490
  recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: domain }]);
498
491
  return researchReport(outcome, researchResultPath(taskDir, domain));
@@ -624,11 +617,12 @@ function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushbac
624
617
 
625
618
  const FULL_NEXT = "Next: inspect the diff, then run action=qa once this domain's work is complete.";
626
619
 
627
- function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT): string {
620
+ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT, fullReport = ""): string {
628
621
  const { result, run, issues } = outcome;
629
622
  const objection = result.pushback;
630
623
  return [
631
624
  `Worker ${result.domain}: ${run.status}${run.error ? ` (${run.error})` : ""}`,
625
+ fullReport ? `Full report: ${fullReport} (read it for detail the lines below shorten)` : "",
632
626
  result.completed ? `Completed: ${truncate(result.completed, 1200)}` : "",
633
627
  result.filesChanged.length > 0
634
628
  ? `Files changed:\n${result.filesChanged.map((file) => `- ${file.path} — ${file.change}`).join("\n")}`
@@ -706,10 +700,22 @@ function workerTaskText(task: Task, planBudget = 6000): string {
706
700
  .join("\n\n");
707
701
  }
708
702
 
709
- function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string, time?: AgentTime): WorkerRequest {
703
+ /** The task's board: one file in its directory that every agent of the task posts to and reads. */
704
+ function taskBoard(deps: WorkflowDeps, task: Task): string {
705
+ return boardPath(taskDirFor(deps.root, deps.configDir, task.id));
706
+ }
707
+
708
+ /** What the agents posted to the board during this action, for the oracle. */
709
+ function boardNews(deps: WorkflowDeps, task: Task, after: number): string {
710
+ const posts = filterPosts(readBoard(taskBoard(deps, task)), { after, limit: 40 });
711
+ return posts.length > 0 ? `Task board, new posts:\n${formatPosts(posts)}` : "";
712
+ }
713
+
714
+ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string, alongside?: readonly Domain[]): WorkerRequest {
710
715
  const ask = askRelay(task, deps, domain);
711
716
  return {
712
- ...(time ? { time } : {}),
717
+ board: taskBoard(deps, task),
718
+ ...(alongside && alongside.length > 1 ? { alongside } : {}),
713
719
  ...(ask ? { ask } : {}),
714
720
  // QA writing tests works to the same budget as the gate: the risk of what is built so far.
715
721
  ...(domain === "qa" && task.qaRisk ? { risk: qaRiskBrief(task.qaRisk) } : {}),
@@ -722,6 +728,7 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
722
728
  dataRoots: readDataRoots(deps.root, deps.configDir),
723
729
  config: deps.config,
724
730
  profile: deps.profile,
731
+ ...(deps.expert ? { expert: deps.expert } : {}),
725
732
  signal: deps.signal,
726
733
  onUpdate: deps.onUpdate,
727
734
  ...(deps.hints ? { hints: deps.hints } : {}),
@@ -799,8 +806,6 @@ function recordWorkerRun(task: Task, run: AgentRun): void {
799
806
  interface Assignment {
800
807
  domain: Domain;
801
808
  instruction: string;
802
- /** Minutes the oracle gave the step, under a time budget. */
803
- minutes?: number;
804
809
  }
805
810
 
806
811
  /** The delegation as a list: `assignments` for a parallel batch, otherwise the single domain/task. */
@@ -809,7 +814,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
809
814
  const list = params.assignments.map((entry) => {
810
815
  const instruction = entry.task?.trim();
811
816
  if (!instruction) throw new Error("every assignment needs a task (what to implement)");
812
- return { domain: parseDomain(entry.domain, "implement"), instruction, ...(entry.minutes ? { minutes: entry.minutes } : {}) };
817
+ return { domain: parseDomain(entry.domain, "implement"), instruction };
813
818
  });
814
819
  const domains = list.map((entry) => entry.domain);
815
820
  if (new Set(domains).size !== domains.length) throw new Error("parallel assignments need distinct domains (one worker per domain)");
@@ -818,7 +823,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
818
823
  const domain = parseDomain(params.domain, "implement");
819
824
  const instruction = params.task?.trim();
820
825
  if (!instruction) throw new Error("implement requires task (what to implement)");
821
- return [{ domain, instruction, ...(params.minutes ? { minutes: params.minutes } : {}) }];
826
+ return [{ domain, instruction }];
822
827
  }
823
828
 
824
829
  /**
@@ -918,10 +923,9 @@ function provenanceNote(files: readonly FileProvenance[] | undefined): string {
918
923
  }
919
924
 
920
925
  /** Record one worker's outcome on the task and return its report for the Master. */
921
- function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome, time?: WorkerTime): string {
926
+ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
922
927
  const domain = outcome.result.domain;
923
928
  recordWorkerRun(task, outcome.run);
924
- if (time) settleAllotment(task, deps, time.id, stoppedForTime(outcome) ? "out of time" : outcome.run.status === "success" ? "finished" : "stopped");
925
929
  appendChange(deps.root, deps.configDir, {
926
930
  source: "worker",
927
931
  id: outcome.run.runId,
@@ -938,8 +942,30 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
938
942
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
939
943
  updateScratchpad(deps, task, outcome);
940
944
  const grown = qaJoinsGrownFastTask(task, outcome);
941
- const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : qaRequired(task) ? FULL_NEXT : PLANNED_NEXT);
942
- return [report, grown, timeReport(outcome)].filter(Boolean).join("\n");
945
+ const full = saveFullReport(deps, task, outcome);
946
+ const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : qaRequired(task) ? FULL_NEXT : PLANNED_NEXT, full);
947
+ return [report, grown].filter(Boolean).join("\n");
948
+ }
949
+
950
+ /** Reports longer than this are kept whole in a file and the oracle gets the path. */
951
+ const LONG_REPORT = 2400;
952
+
953
+ /**
954
+ * Keep a long worker report whole in the task's `reports/` folder, so the
955
+ * oracle reads what it needs from the file instead of getting a shortened
956
+ * copy relayed through its own context. Returns the path, or "".
957
+ */
958
+ function saveFullReport(deps: WorkflowDeps, task: Task, outcome: WorkerOutcome): string {
959
+ const output = outcome.run.output?.trim() ?? "";
960
+ if (output.length <= LONG_REPORT) return "";
961
+ const stamp = (outcome.run.finishedAt ?? new Date().toISOString()).replace(/[:.]/g, "-");
962
+ const path = join(taskDirFor(deps.root, deps.configDir, task.id), "reports", `${outcome.result.domain}-${stamp}.md`);
963
+ try {
964
+ writeFileEnsured(path, `${output}\n`);
965
+ return path;
966
+ } catch {
967
+ return "";
968
+ }
943
969
  }
944
970
 
945
971
  /**
@@ -1005,7 +1031,6 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
1005
1031
  if (short.length > 0) throw new Error(short.join("\n"));
1006
1032
  for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
1007
1033
  for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
1008
- // Under a time budget every step is given its share before any starts; a spent budget starts none.
1009
1034
  if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
1010
1035
  if (starting) startFast(task);
1011
1036
  if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
@@ -1013,24 +1038,16 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
1013
1038
  await takeBaseline(task, deps);
1014
1039
  // A QA step after work is in verifies it at its risk: read the change first.
1015
1040
  if (assignments.some((entry) => entry.domain === "qa") && builtSomething(task)) await assessBuilt(task, deps);
1016
- const times = await workerTimes(task, deps, assignments);
1041
+ const boardBefore = readBoard(taskBoard(deps, task)).at(-1)?.seq ?? 0;
1017
1042
  let report: string;
1018
- try {
1019
- if (assignments.length === 1) {
1020
- const { domain, instruction } = assignments[0]!;
1021
- const outcome = await runWorker(workerRequest(deps, task, domain, instruction, times.get(domain)), deps.runProcess ?? spawnPiProcess);
1022
- report = absorbWorkerOutcome(task, deps, outcome, times.get(domain));
1023
- } else report = await runParallelWorkers(task, deps, assignments, times);
1024
- } finally {
1025
- // A step that never reported (the call failed) does not stay "running" in the budget.
1026
- for (const time of times.values()) {
1027
- settleAllotment(task, deps, time.id, "stopped");
1028
- time.finish?.();
1029
- }
1030
- }
1043
+ if (assignments.length === 1) {
1044
+ const { domain, instruction } = assignments[0]!;
1045
+ const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
1046
+ report = absorbWorkerOutcome(task, deps, outcome);
1047
+ } else report = await runParallelWorkers(task, deps, assignments);
1031
1048
  const tree = await changeState(deps, task, task.baseline?.head);
1032
1049
  const lint = lintNote(await lintTask(task, deps, tree), deps.config.lint.mode, ownersOf(tree?.files));
1033
- return [report, provenanceNote(tree?.files), lint].filter(Boolean).join("\n\n");
1050
+ return [report, boardNews(deps, task, boardBefore), provenanceNote(tree?.files), lint].filter(Boolean).join("\n\n");
1034
1051
  }
1035
1052
 
1036
1053
  /**
@@ -1130,34 +1147,62 @@ function stepLine(instruction: string): string {
1130
1147
  * Several domains at once. The workers share one file desk: each claims a file
1131
1148
  * before editing it, queues for a busy one, and hands it over with a note; a
1132
1149
  * worker that finishes hands over whatever it still holds automatically.
1150
+ * In `worktrees` mode each worker has a checkout of its own instead (no
1151
+ * claims, no queues), and their changes are applied back when the batch ends.
1152
+ * Either way the desk carries the board and the workers' questions to each other.
1133
1153
  */
1134
- async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[], times: ReadonlyMap<Domain, WorkerTime>): Promise<string> {
1135
- const session = new DeskSession({ cwd: deps.cwd });
1154
+ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]): Promise<string> {
1155
+ const batch = assignments.map((entry) => entry.domain);
1156
+ const opened = deps.config.workflow.parallelMode === "worktrees" ? await openWorkerTrees(deps.cwd, batch) : undefined;
1157
+ const trees = opened && typeof opened !== "string" ? opened : undefined;
1158
+ const session = new DeskSession({ cwd: deps.cwd, boardPath: taskBoard(deps, task), peers: true, ...(trees ? { claims: false } : {}) });
1136
1159
  await session.open();
1137
1160
  let outcomes: WorkerOutcome[];
1138
1161
  let unenforced: Domain[];
1162
+ let merged = "";
1139
1163
  try {
1140
- outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
1141
- const request = workerRequest(deps, task, domain, instruction, times.get(domain));
1142
- request.agent = {
1143
- env: session.env(domain),
1144
- extraTools: DESK_TOOLS,
1145
- onStart: (handle) => session.attach(domain, handle),
1146
- onAttemptEnd: (run) => {
1147
- const result = parseWorkerResult(domain, run.output);
1148
- session.release(domain, (path, next) => autoNote(domain, path, next, result.filesChanged, result.completed));
1149
- },
1150
- };
1151
- return runWorker(request, deps.runProcess ?? spawnPiProcess);
1152
- });
1153
- unenforced = assignments.map((entry) => entry.domain).filter((domain) => !session.greetedBy(domain));
1164
+ try {
1165
+ outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
1166
+ const request = workerRequest(deps, task, domain, instruction, batch);
1167
+ const tree = trees?.trees.get(domain);
1168
+ if (tree) {
1169
+ request.cwd = tree.cwd;
1170
+ request.ownCheckout = true;
1171
+ }
1172
+ request.agent = {
1173
+ env: session.env(domain),
1174
+ extraTools: session.tools(),
1175
+ onStart: (handle) => session.attach(domain, handle),
1176
+ onAttemptEnd: (run) => {
1177
+ const result = parseWorkerResult(domain, run.output);
1178
+ session.release(domain, (path, next) => autoNote(domain, path, next, result.filesChanged, result.completed));
1179
+ },
1180
+ };
1181
+ return runWorker(request, deps.runProcess ?? spawnPiProcess);
1182
+ });
1183
+ unenforced = trees ? [] : assignments.map((entry) => entry.domain).filter((domain) => !session.greetedBy(domain));
1184
+ } finally {
1185
+ await session.close();
1186
+ }
1187
+ if (trees) {
1188
+ const results = await mergeWorkerTrees(trees, batch, join(taskDirFor(deps.root, deps.configDir, task.id), "patches")).catch((error: unknown) => {
1189
+ // Keep every worktree: nothing is lost while the oracle sorts it out.
1190
+ for (const tree of trees.trees.values()) tree.keep = true;
1191
+ return batch.map((worker): MergeOutcome => ({ worker, status: "conflict", patch: "", saved: trees.trees.get(worker)?.dir ?? "", reason: `merging back failed (${(error as Error).message}); the worktree is kept as it was` }));
1192
+ });
1193
+ // The edits were made in the worktrees: the task's record names the same files in its own tree.
1194
+ outcomes = outcomes.map((outcome) => (outcome.run.edited?.length ? { ...outcome, run: { ...outcome.run, edited: outcome.run.edited.map((path) => mapToTop(trees, outcome.result.domain, path)) } } : outcome));
1195
+ merged = mergeReport(results);
1196
+ }
1154
1197
  } finally {
1155
- await session.close();
1198
+ if (trees) await closeWorkerTrees(trees);
1156
1199
  }
1157
- const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome, times.get(outcome.result.domain)));
1200
+ const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome));
1158
1201
  return [
1159
- `Parallel batch: ${assignments.map((entry) => entry.domain).join(", ")}.`,
1202
+ `Parallel batch: ${batch.join(", ")}${trees ? " (a worktree each)" : ""}.`,
1203
+ typeof opened === "string" ? `Worktrees were not used (${opened}); the workers shared the file desk.` : "",
1160
1204
  ...reports,
1205
+ merged,
1161
1206
  handoverLog(session.handovers()),
1162
1207
  unenforced.length > 0
1163
1208
  ? `File checkout was not enforced for ${unenforced.join(", ")} (bot-lobby did not load in those workers); inspect the diff for overlapping edits.`
@@ -1167,6 +1212,24 @@ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: A
1167
1212
  .join("\n\n");
1168
1213
  }
1169
1214
 
1215
+ /** What came of applying each worker's worktree back to the task's tree. */
1216
+ function mergeReport(results: readonly MergeOutcome[]): string {
1217
+ const label = (worker: string): string => AGENT_LABELS[worker as Domain] ?? worker;
1218
+ const applied = results.filter((entry) => entry.status === "applied").map((entry) => label(entry.worker));
1219
+ const kept = results.filter((entry): entry is Extract<MergeOutcome, { status: "conflict" | "partial" }> => entry.status === "conflict" || entry.status === "partial");
1220
+ return [
1221
+ applied.length > 0 ? `Worktrees merged back: ${applied.join(", ")}.` : "",
1222
+ ...kept.map((entry) => {
1223
+ const where = [entry.patch ? `patch ${entry.patch}` : "", entry.saved.startsWith("refs/") ? `git ref ${entry.saved}` : entry.saved ? `worktree ${entry.saved}` : ""].filter(Boolean).join(", ");
1224
+ const state =
1225
+ entry.status === "partial"
1226
+ ? `, and applying it failed part way: these files are left changed and need checking: ${(entry.leftChanged ?? []).slice(0, 10).join(", ")}`
1227
+ : ", so none of it is in the tree";
1228
+ return `${label(entry.worker)}'s change did not apply (${truncate(entry.reason, 300)})${state}.${where ? ` It is saved (${where}): delegate a fix step to ${label(entry.worker)} to bring it onto the current files (\`git apply --3way\` on the patch, or \`git diff <ref>^ <ref>\`), reconciling it with the others' changes.` : ""}`;
1229
+ }),
1230
+ ].filter(Boolean).join("\n");
1231
+ }
1232
+
1170
1233
  function handoverLog(handovers: readonly Handover[]): string {
1171
1234
  if (handovers.length === 0) return "";
1172
1235
  const lines = handovers.map((entry) => `- ${entry.path}: ${entry.from} → ${entry.to}${entry.auto ? " (on finish)" : ""} — ${truncate(entry.note, 200)}`);
@@ -1254,11 +1317,14 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, risk: QaRiskAss
1254
1317
  taskId: task.id,
1255
1318
  domain: "qa",
1256
1319
  taskText: workerTaskText(task, 9000),
1257
- workerSummary: allScratchpads(deps, task),
1320
+ // A clean review sees what was built, not how the builders describe it.
1321
+ workerSummary: deps.config.workflow.cleanReview ? "" : allScratchpads(deps, task),
1322
+ ...(deps.config.workflow.cleanReview ? { clean: true } : {}),
1258
1323
  ...(rounds ? { previousRound: rounds } : {}),
1259
- scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
1324
+ scoutOutcomes: deps.config.workflow.cleanReview ? [] : loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
1260
1325
  diff,
1261
1326
  risk: qaRiskBrief(risk),
1327
+ board: boardContext(taskBoard(deps, task), "qa"),
1262
1328
  ...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
1263
1329
  ...(lint ? { lint: lintContext(lint, deps.config.lint.mode) } : {}),
1264
1330
  instruction: instruction?.trim() || qaInstruction(risk),
@@ -1371,19 +1437,19 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
1371
1437
  ]);
1372
1438
  const provenance = tree?.files;
1373
1439
  // The gate's base is the task's own; a legacy task measured from an older commit lints from a fresh look.
1374
- const lint = await lintTask(task, deps, base === task.baseline?.head ? tree : undefined);
1440
+ // Before any QA agent runs, Jev reads what was built: the gate's depth and test budget follow its risk. Neither waits for the other.
1441
+ const [lint, risk] = await Promise.all([
1442
+ lintTask(task, deps, base === task.baseline?.head ? tree : undefined),
1443
+ assessTask(task, deps, diff, tree, base),
1444
+ ]);
1375
1445
  const lintLine = lintBrief(lint, deps.config.lint.mode, Boolean(task.lintWaiver));
1376
- // Before any QA agent runs, Jev reads what was built: the gate's depth and test budget follow its risk.
1377
- const risk = await assessTask(task, deps, diff, tree, base);
1378
1446
  if (!risk.qaRequired) {
1379
1447
  const result = checksVerdict(lint, deps, Boolean(task.lintWaiver));
1380
1448
  const decision = await settleQaRound(task, result, iterations, deps, true);
1381
1449
  const header = `QA gate: ${result.verdict.toUpperCase()} on the engine's checks; no QA agent ran. ${qaRiskLine(risk)}.\nWhy: ${risk.reasoning}\nChecks:\n${result.verification}`;
1382
1450
  return qaReport(header, result, decision, provenance, lintLine);
1383
1451
  }
1384
- const time = qaTime(task, deps);
1385
- const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), effort: deps.effort, ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
1386
- if (time) settleAllotment(task, deps, time.id, "finished");
1452
+ const outcome = await runReviewer({ ...qaRequest(deps, task, diff, risk, params.task, provenance, lint), effort: deps.effort }, deps.runProcess ?? spawnPiProcess);
1387
1453
  const { result, run } = outcome;
1388
1454
  recordAdvisoryPushbacks(task, [{ pushback: result.pushback, who: "qa" }]);
1389
1455
  if (result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${result.relaxed}.`, "qa");
@@ -1558,6 +1624,12 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1558
1624
  // New lint errors in what the task touched hold it in block mode, unless the user accepted the work as it is.
1559
1625
  const lint = await lintTask(task, deps);
1560
1626
  if (lint && lintHolds(lint, deps.config.lint.mode, Boolean(task.lintWaiver))) throw new Error(`cannot complete: ${lintRefusal(lint)}`);
1627
+ const goal = await checkGoal(task, params, deps);
1628
+ if (goal.refusal) {
1629
+ // The refusal is recorded, so a reason on the next complete is an answer to it.
1630
+ saveTask(deps.root, deps.configDir, task);
1631
+ throw new Error(goal.refusal);
1632
+ }
1561
1633
  // The gate refuses before any side effect, so a refusal leaves the task untouched.
1562
1634
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1563
1635
  if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
@@ -1575,7 +1647,61 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1575
1647
  flushDecisions(deps, task);
1576
1648
  const delivery = await offerDelivery(task, deps);
1577
1649
  const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
1578
- return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}`;
1650
+ return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}${goal.note ?? ""}`;
1651
+ }
1652
+
1653
+ /**
1654
+ * The goal check before completion: one read-only agent compares the change
1655
+ * with the user's own request (not the plan). Gaps refuse completion once;
1656
+ * the oracle fixes them, or completes again with a `reason` when they are
1657
+ * deliberate (the user agreed). The same change is never checked twice, and
1658
+ * an unreadable check holds nothing.
1659
+ */
1660
+ /** Enough of the change to tell one from another; the check itself reads a shorter cut. */
1661
+ const GOAL_HASH_CHARS = 4_000_000;
1662
+
1663
+ async function checkGoal(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<{ refusal?: string; note?: string }> {
1664
+ if (!deps.config.workflow.goalCheck || !builtSomething(task) || task.qaWaiver) return {};
1665
+ const base = await reviewBase(task, deps);
1666
+ // The whole change is hashed, so an edit past what the check reads still counts as a new change.
1667
+ const diff = await readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps), limitChars: GOAL_HASH_CHARS });
1668
+ if (!diff.trim() || /^Unable to read git state/.test(diff)) return {};
1669
+ const key = createHash("sha256").update(diff).digest("hex").slice(0, 16);
1670
+ if (task.goalCheck?.key !== key) {
1671
+ let result: Awaited<ReturnType<typeof runGoalCheck>> | undefined;
1672
+ // One more read when the first could not be read: a lost verdict should not let a gap through unseen.
1673
+ for (let attempt = 0; attempt < 2 && result?.verdict !== "met" && result?.verdict !== "not_met"; attempt++) {
1674
+ deps.signal?.throwIfAborted();
1675
+ result = await runGoalCheck({
1676
+ taskId: task.id,
1677
+ request: taskRequest(task),
1678
+ amendments: task.amendments,
1679
+ diff,
1680
+ cwd: deps.cwd,
1681
+ config: deps.config,
1682
+ ...(deps.profile ? { profile: deps.profile } : {}),
1683
+ ...(deps.signal ? { signal: deps.signal } : {}),
1684
+ ...(deps.onUpdate ? { onUpdate: deps.onUpdate } : {}),
1685
+ }, deps.runProcess ?? spawnPiProcess);
1686
+ }
1687
+ deps.signal?.throwIfAborted();
1688
+ if (!result || result.verdict === "unread") {
1689
+ // Not recorded, so the next complete checks again.
1690
+ const why = result?.run.error ? `: ${truncate(result.run.error, 160)}` : "";
1691
+ return { note: ` The goal check could not be read (twice${why}), so the change was not checked against the request; tell the user.` };
1692
+ }
1693
+ task.goalCheck = { key, verdict: result.verdict, gaps: result.gaps, at: new Date().toISOString() };
1694
+ }
1695
+ const check = task.goalCheck!;
1696
+ if (check.verdict !== "not_met") return {};
1697
+ const gaps = check.gaps.slice(0, 12).map((gap) => `- ${truncate(gap, 300)}`).join("\n");
1698
+ // A reason answers a refusal the oracle has seen; given up front, it is not an answer to anything yet.
1699
+ if (params.reason?.trim() && check.refusedAt) {
1700
+ recordDecision(task, `Completed although the goal check named gaps (${check.gaps.map((gap) => truncate(gap, 120)).join("; ")}): ${params.reason.trim()}`);
1701
+ return { note: ` Completed with the goal check's gaps left open, because: ${truncate(params.reason.trim(), 300)}. Tell the user which parts of their request are not done:\n${gaps}` };
1702
+ }
1703
+ check.refusedAt = new Date().toISOString();
1704
+ return { refusal: `cannot complete: the goal check read the change against the user's request and found it does not meet all of it:\n${gaps}\nDelegate a fix step for each gap and complete again (the next check reads the new change). If a gap is deliberate (the user agreed to leave it out, or it is out of scope), call complete again with reason saying so; tell the user.` };
1579
1705
  }
1580
1706
 
1581
1707
  /** The files a task's own workers changed: not one that held changes before they started, nor another task's. */
@@ -1769,150 +1895,13 @@ function handleCancel(task: Task): string {
1769
1895
  return `Task ${task.id} abandoned. Its scratchpad is kept until the task is formally resolved.`;
1770
1896
  }
1771
1897
 
1772
- /* ------------------------------------------------------------ time budget */
1773
-
1774
- /** Shares of what is left that a scout batch and the researcher are given (within their configured limits). */
1775
- const SCOUT_SHARE = 0.1;
1776
- const RESEARCH_SHARE = 0.15;
1777
- /** Minutes a worker out of time is taken to ask for when its report names none. */
1778
- const DEFAULT_MORE_MINUTES = 10;
1779
-
1780
- /** A delegation's time, with the allotment it is recorded under. */
1781
- type WorkerTime = AgentTime & { id: string; finish?: () => void };
1782
-
1783
- /** The task's budget and where it stands, when it has one. */
1784
- function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
1785
- const budget = readBudget(deps.root, deps.configDir, task.id);
1786
- return budget ? { budget, state: budgetState(task.id, budget, !qaStillDue(task)) } : undefined;
1787
- }
1788
-
1789
- /** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
1790
- function budgetSpent(state: BudgetState, what: string): Error {
1791
- const reserve = state.reserveMs > 0 ? `, ${formatMinutes(state.reserveMs)} of it kept for the QA gate` : "";
1792
- const wrap = state.reserveMs >= MIN_READ_MS ? "run action=qa on what is done" : "complete or block with what is done";
1793
- return new Error(`the task's time budget has no room for ${what}: ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} used, ${formatMinutes(state.leftMs)} left${reserve}. Ask the user for more time with action=budget (minutes and reason), or wrap up: ${wrap}.`);
1794
- }
1795
-
1796
- /** What an agent is told about its time. */
1797
- function timeNote(allotMs: number, state: BudgetState, worker: boolean): string {
1798
- return [
1799
- "## Time",
1800
- `You have ${formatMinutes(allotMs)} for this ${worker ? "step" : "work"}; the task has ${formatMinutes(state.leftMs)} of its ${formatMinutes(state.totalMs)} left.`,
1801
- worker
1802
- ? "This is the total allowance shared across startup, retries, children, tools and reporting. At about 75% you get a heads-up. Before the hard deadline, stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); only the user can grant it. Land the most important part first, and keep every file consistent as you go."
1803
- : "When the time is up you are asked to stop and report what you have, so cover the most important questions first.",
1804
- ].join("\n");
1805
- }
1806
-
1807
- function recordAllotment(task: Task, deps: WorkflowDeps, entry: Omit<Allotment, "startedAt" | "granted">): void {
1808
- updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1809
- budget.allotments.push({ ...entry, granted: 0, startedAt: new Date().toISOString() });
1810
- });
1811
- }
1812
-
1813
- function settleAllotment(task: Task, deps: WorkflowDeps, id: string, outcome: NonNullable<Allotment["outcome"]>): void {
1814
- updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1815
- const entry = budget.allotments.find((allotment) => allotment.id === id);
1816
- if (entry && !entry.endedAt) Object.assign(entry, { endedAt: new Date().toISOString(), outcome });
1817
- });
1818
- }
1819
-
1820
1898
  function oneLine(text: string, max: number): string {
1821
1899
  return truncate(text.replace(/\s+/g, " ").trim(), max).replace(/\n\[\.\.\.\d+ characters omitted\]$/, "…");
1822
1900
  }
1823
1901
 
1824
- /** A read-only batch's time (scouts, the researcher): a share of what is left, within its configured limit. */
1825
- function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, share: number, limitMs: number, label: string): WorkerTime | undefined {
1826
- const now = budgetFor(task, deps);
1827
- if (!now) return undefined;
1828
- const ms = readerAllotment(now.state, share, Math.min(STEP_LIMIT_MS, limitMs));
1829
- if (ms === undefined) throw budgetSpent(now.state, label);
1830
- const id = `${who.toLowerCase()}-${Date.now().toString(36)}`;
1831
- recordAllotment(task, deps, { id, who, what: oneLine(what, 120), minutes: Math.round(ms / 60_000) });
1832
- return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
1833
- }
1834
-
1835
- /** The QA gate's time: its reserve at least, never more than is left. */
1836
- function qaTime(task: Task, deps: WorkflowDeps): WorkerTime | undefined {
1837
- const now = budgetFor(task, deps);
1838
- if (!now) return undefined;
1839
- const ms = qaAllotment(now.state, Math.min(STEP_LIMIT_MS, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs));
1840
- if (ms === undefined) throw budgetSpent(now.state, "the QA gate");
1841
- const id = `qa-${Date.now().toString(36)}`;
1842
- recordAllotment(task, deps, { id, who: "QA gate", what: "review the task's work", minutes: Math.round(ms / 60_000) });
1843
- return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
1844
- }
1845
-
1846
- /** Plan domains still to build, the batch counted once: what is left before the QA reserve is split across them by default. */
1847
- function openSlots(task: Task, batch: readonly Domain[]): number {
1848
- const built = new Set((task.workerRuns ?? []).filter((run) => run.status === "success").map((run) => run.domain));
1849
- return task.domains.filter((domain) => !built.has(domain) && !batch.includes(domain)).length + 1;
1850
- }
1851
-
1852
- /**
1853
- * Each step's time, given before any starts: the minutes the oracle asked
1854
- * for (it divides by scope) or an even share, never past what is left before
1855
- * the QA gate's reserve. A spent budget starts nothing.
1856
- */
1857
- async function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Promise<Map<Domain, WorkerTime>> {
1858
- const times = new Map<Domain, WorkerTime>();
1859
- const now = budgetFor(task, deps);
1860
- const slots = openSlots(task, assignments.map((entry) => entry.domain));
1861
- const steps = planSteps(task.plan ?? "");
1862
- const completed = new Set(planChecklist(task.plan ?? "", (task.workerRuns ?? []).map((record) => ({ ...record, taskId: task.id, role: "worker" as const, output: "", attempts: 1 })), task.skippedSteps).flatMap((step, index) => step.status === "done" || step.status === "skipped" ? [index] : []));
1863
- const prepared = [];
1864
- for (const { domain, instruction, minutes } of assignments) {
1865
- const covered = runSteps(steps, instruction, completed);
1866
- const keys = covered.length ? covered.map((index) => `${domain}:step-${index + 1}`) : [`${domain}:unplanned`];
1867
- const limitMs = Math.min(STEP_LIMIT_MS, profileFor(deps.config, deps.profile, domain, "worker").timeoutMs);
1868
- let left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
1869
- if (left <= 0) {
1870
- const wanted = DEFAULT_MORE_MINUTES;
1871
- const granted = await decideMoreTime(task, deps, {
1872
- label: AGENT_LABELS[domain], instruction, wanted,
1873
- result: parseWorkerResult(domain, scratchpadSummary(deps, task, domain)),
1874
- run: { runId: "", taskId: task.id, domain, role: "worker", status: "timeout", output: "", attempts: 1, startedAt: new Date().toISOString(), allotMs: limitMs },
1875
- state: now?.state,
1876
- });
1877
- if (granted <= 0) throw new Error(`${AGENT_LABELS[domain]}'s step allowance is exhausted. Return the unfinished report; continuing requires user-approved time.`);
1878
- grantStepTime(deps.root, deps.configDir, task.id, keys, limitMs, granted * 60_000);
1879
- recordDecision(task, `The user gave ${AGENT_LABELS[domain]}'s exhausted step ${granted} more minutes.`);
1880
- left = stepTimeLeft(deps.root, deps.configDir, task.id, keys, limitMs);
1881
- }
1882
- const given = now ? workerAllotment(now.state, minutes, slots) : { ms: left };
1883
- if (!given) throw budgetSpent(now!.state, `the ${domain} step`);
1884
- const ms = Math.min(left, given.ms);
1885
- prepared.push({ domain, instruction, keys, limitMs, ms });
1886
- }
1887
- try {
1888
- for (const { domain, instruction, keys, limitMs, ms } of prepared) {
1889
- const reservation = reserveStepTime(deps.root, deps.configDir, task.id, keys, limitMs, ms);
1890
- const id = `${domain}-${Date.now().toString(36)}`;
1891
- let exhausted = false;
1892
- const askMore = moreTimeFor(task, deps, domain, instruction, id, (extraMs) => reservation.grant(extraMs));
1893
- recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(ms / 60_000) });
1894
- const time: WorkerTime = { id, endsAt: Date.now() + ms, allotMs: ms, strict: true,
1895
- note: now ? timeNote(ms, now.state, true) : `## Time\nYou have ${formatMinutes(ms)} total for this step, shared across retries and children. Report unfinished work before the deadline; only the user can grant more time.`,
1896
- onTimeUp: async (run, report) => {
1897
- const extraMs = await askMore(run, report);
1898
- const result = parseWorkerResult(domain, report);
1899
- if (extraMs <= 0 && (result.leftOff || result.moreTime)) exhausted = true;
1900
- return extraMs;
1901
- },
1902
- };
1903
- time.finish = () => reservation.finish(time.usedMs ?? ms, exhausted);
1904
- times.set(domain, time);
1905
- }
1906
- } catch (error) {
1907
- for (const time of times.values()) { time.usedMs = 0; time.finish?.(); }
1908
- throw error;
1909
- }
1910
- return times;
1911
- }
1912
-
1913
1902
  const AGENT_LABELS: Record<Domain, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
1914
1903
 
1915
- /** Out-of-time questions one at a time, even when parallel workers run out together. */
1904
+ /** Questions to the user one at a time, even when parallel workers ask together. */
1916
1905
  let asking: Promise<unknown> = Promise.resolve();
1917
1906
  function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
1918
1907
  const next = asking.then(ask, ask);
@@ -1920,134 +1909,6 @@ function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
1920
1909
  return next;
1921
1910
  }
1922
1911
 
1923
- /**
1924
- * A worker out of time has reported what it did, where it left off and how
1925
- * much more it needs. Only the user grants more time; a grant past the task budget grows it,
1926
- * and the same agent carries on. Resolves with the ms granted, 0 to stop it.
1927
- */
1928
- function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string, onGrant?: (ms: number) => void): NonNullable<AgentTime["onTimeUp"]> {
1929
- return (run, report) => oneAtATime(async () => {
1930
- const result = parseWorkerResult(domain, report);
1931
- // A report with nothing left to do finished in time.
1932
- if (!result.leftOff && !result.moreTime) return 0;
1933
- const label = AGENT_LABELS[domain];
1934
- const wanted = result.moreTime?.minutes ?? DEFAULT_MORE_MINUTES;
1935
- const now = budgetFor(task, deps);
1936
- const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, state: now?.state });
1937
- if (minutes <= 0) return 0;
1938
- const over = Math.max(0, Math.ceil((minutes * 60_000 - (now?.state.windowMs ?? minutes * 60_000)) / 60_000));
1939
- updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1940
- budget.granted += over;
1941
- const entry = budget.allotments.find((allotment) => allotment.id === id);
1942
- if (entry) Object.assign(entry, { minutes: entry.minutes + minutes, granted: entry.granted + minutes });
1943
- });
1944
- recordDecision(task, `${label} ran out of time; the user gave it ${minutes} more minutes${over > 0 ? `, ${over} of them added to the task's budget` : ""}. Left off: ${oneLine(result.leftOff ?? result.moreTime?.reason ?? "", 200)}`);
1945
- onGrant?.(minutes * 60_000);
1946
- return minutes * 60_000;
1947
- });
1948
- }
1949
-
1950
- interface MoreTimeAsk {
1951
- label: string;
1952
- instruction: string;
1953
- result: WorkerResult;
1954
- wanted: number;
1955
- run: AgentRun;
1956
- state?: BudgetState;
1957
- }
1958
-
1959
- const MORE_TIME = "Give it the time it asks for";
1960
- const OTHER_TIME = "Give a different amount";
1961
-
1962
- async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk): Promise<number> {
1963
- const { label, instruction, result, wanted, run, state } = ask;
1964
- if (isAutoMode(deps.root, deps.configDir, task.id)) {
1965
- recordDecision(task, `Auto mode: ${label}'s step allowance is spent. More time requires the user; return the unfinished report.`);
1966
- return 0;
1967
- }
1968
- const over = Math.max(0, Math.ceil((wanted * 60_000 - (state?.windowMs ?? wanted * 60_000)) / 60_000));
1969
- const had = run.allotMs ?? 0;
1970
- const choice = await deps.choose(
1971
- [
1972
- `${label} is out of time: it had ${formatMinutes(had + (run.extendedMs ?? 0))} for "${oneLine(instruction, 160)}".`,
1973
- result.completed ? `Done so far: ${oneLine(result.completed, 400)}` : "",
1974
- result.leftOff ? `Left to do: ${oneLine(result.leftOff, 400)}` : "",
1975
- `It needs about ${wanted} more minutes${result.moreTime?.reason ? `: ${oneLine(result.moreTime.reason, 200)}` : ""}.`,
1976
- state ? `The task has used ${formatMinutes(state.usedMs)} of ${formatMinutes(state.totalMs)} (${formatMinutes(state.leftMs)} left)${over > 0 ? `; ${wanted} more minutes adds ${over} to its budget` : ""}.` : "This step has a shared allowance even though the task has no overall time budget.",
1977
- `Give ${label} ${wanted} more minutes to finish?`,
1978
- ].filter(Boolean).join("\n\n"),
1979
- [MORE_TIME, OTHER_TIME, `Stop ${label} here`],
1980
- );
1981
- if (choice === MORE_TIME) return wanted;
1982
- if (choice === OTHER_TIME) return parseMinutes((await deps.ask(`How many more minutes for ${label}?`)) ?? "") ?? 0;
1983
- recordDecision(task, `The user stopped ${label} at its time limit. Left off: ${oneLine(result.leftOff ?? "", 200)}`);
1984
- return 0;
1985
- }
1986
-
1987
- /** Stopped at its time with work left: a report that finished everything as time ran out is not. */
1988
- function stoppedForTime(outcome: WorkerOutcome): boolean {
1989
- return Boolean(outcome.run.status === "timeout" || (outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime)));
1990
- }
1991
-
1992
- /** How a step's time went, for the oracle: out of time and stopped, or given more. */
1993
- function timeReport(outcome: WorkerOutcome): string {
1994
- const { run, result } = outcome;
1995
- const label = AGENT_LABELS[result.domain];
1996
- if (stoppedForTime(outcome)) {
1997
- return [
1998
- `${label} ran out of its time and was not given more: this step is unfinished.`,
1999
- result.leftOff ? `Left off: ${oneLine(result.leftOff, 600)}` : "",
2000
- result.moreTime ? `It asked for ${result.moreTime.minutes ?? "more"} minutes${result.moreTime.reason ? `: ${oneLine(result.moreTime.reason, 300)}` : ""}.` : "",
2001
- "Decide with the user: trim the scope, ask for task time with action=budget, or wrap up with what is done.",
2002
- ].filter(Boolean).join("\n");
2003
- }
2004
- return run.extendedMs ? `${label} ran out of its ${formatMinutes(run.allotMs ?? 0)} and was given ${formatMinutes(run.extendedMs)} more.` : "";
2005
- }
2006
-
2007
- /**
2008
- * `action=budget`: with no minutes, where the budget stands; with minutes and
2009
- * a reason, the oracle asks the user for more task time. Only the user can
2010
- * grant it; in auto mode nobody can, and the task wraps up.
2011
- */
2012
- async function handleBudget(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
2013
- const now = budgetFor(task, deps);
2014
- if (!now) return "This task has no time budget. The user sets one with /bot-lobby budget <minutes>.";
2015
- const minutes = params.minutes;
2016
- if (!minutes || minutes <= 0) return allotmentReport(now.budget);
2017
- const reason = params.reason?.trim() || params.text?.trim();
2018
- if (!reason) throw new Error("budget requires reason: why the task needs more time");
2019
- if (isAutoMode(deps.root, deps.configDir, task.id)) {
2020
- recordDecision(task, `Auto mode: asked for ${minutes} more minutes (${oneLine(reason, 160)}), which only the user can give.`);
2021
- return "Auto mode: nobody can give the task more time. Wrap up with what is done: finish or drop the step in hand, run the QA gate if there is room, and tell the user what is left.";
2022
- }
2023
- const choice = await deps.choose(
2024
- [`The oracle asks for ${minutes} more minutes on ${task.id}: ${oneLine(reason, 400)}`, budgetLine(now.budget, now.state)].join("\n\n"),
2025
- [`Give ${minutes} more minutes`, OTHER_TIME, "No"],
2026
- );
2027
- const granted = choice === `Give ${minutes} more minutes` ? minutes : choice === OTHER_TIME ? parseMinutes((await deps.ask(`How many more minutes for ${task.id}?`)) ?? "") ?? 0 : 0;
2028
- if (granted <= 0) {
2029
- recordDecision(task, `The user did not give ${minutes} more minutes: ${oneLine(reason, 200)}`);
2030
- return "The user did not give more time. Wrap up with what is done and tell them what is left.";
2031
- }
2032
- updateBudget(deps.root, deps.configDir, task.id, (budget) => {
2033
- budget.granted += granted;
2034
- });
2035
- recordDecision(task, `The user gave the task ${granted} more minutes: ${oneLine(reason, 200)}`);
2036
- return `The user gave the task ${granted} more minutes.`;
2037
- }
2038
-
2039
- /** What each delegation was given, newest last. */
2040
- function allotmentReport(budget: TaskBudget): string {
2041
- const rows = budget.allotments.slice(-8).map((entry) => `- ${entry.who}: ${entry.minutes}m${entry.granted > 0 ? ` (${entry.granted} granted)` : ""} — ${entry.what}${entry.outcome ? ` · ${entry.outcome}` : entry.endedAt ? "" : " · running"}`);
2042
- return rows.length > 0 ? `Allotted so far:\n${rows.join("\n")}` : "Nothing allotted yet.";
2043
- }
2044
-
2045
- /** Every orchestrate result ends with where the budget stands, when the task has one. */
2046
- function budgetFooter(task: Task, deps: WorkflowDeps): string {
2047
- const now = budgetFor(task, deps);
2048
- return now ? `\n\n${budgetLine(now.budget, now.state)}` : "";
2049
- }
2050
-
2051
1912
  const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams, deps: WorkflowDeps) => Promise<string> | string> = {
2052
1913
  clarify: handleClarify,
2053
1914
  scout: handleScout,
@@ -2064,7 +1925,6 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
2064
1925
  block: handleBlock,
2065
1926
  resume: handleResume,
2066
1927
  decide: handleDecide,
2067
- budget: handleBudget,
2068
1928
  track: handleTrack,
2069
1929
  status: (task) => describeTask(task),
2070
1930
  cancel: handleCancel,
@@ -2150,11 +2010,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
2150
2010
  execution.signal.throwIfAborted();
2151
2011
  const runs = recordRunLog(task, finished, deps);
2152
2012
  saveTask(deps.root, deps.configDir, task);
2153
- return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}${budgetFooter(task, deps)}`, runs };
2013
+ return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
2154
2014
  } catch (error) {
2155
2015
  const runs = recordRunLog(task, finished, deps);
2156
2016
  saveTask(deps.root, deps.configDir, task);
2157
- return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}${budgetFooter(task, deps)}`, runs };
2017
+ return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
2158
2018
  } finally {
2159
2019
  execution.release();
2160
2020
  for (const runId of working) noteWorkerRun(deps.root, deps.configDir, task.id, { runId, startedAt: "", status: "cancelled" });