@a-t-h-i/bot-lobby 0.6.3 → 0.6.4

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 (57) hide show
  1. package/README.md +102 -11
  2. package/package.json +1 -4
  3. package/prompts/master.md +47 -1
  4. package/prompts/researcher.md +8 -2
  5. package/prompts/reviewer.md +42 -0
  6. package/prompts/worker.md +7 -0
  7. package/src/ask/dialog.ts +167 -0
  8. package/src/ask/image.ts +202 -0
  9. package/src/ask/png.ts +179 -0
  10. package/src/ask/relay.ts +89 -0
  11. package/src/ask/state.ts +160 -0
  12. package/src/ask/tool.ts +126 -0
  13. package/src/ask/types.ts +55 -0
  14. package/src/ask/view.ts +159 -0
  15. package/src/execution/agent-runner.ts +103 -11
  16. package/src/execution/git.ts +111 -14
  17. package/src/execution/pi-runner.ts +164 -11
  18. package/src/index.ts +6 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/layout.ts +27 -8
  21. package/src/lobby/markdown.ts +36 -6
  22. package/src/lobby/planner.ts +1 -1
  23. package/src/lobby/quickfix.ts +21 -0
  24. package/src/lobby/runtime.ts +14 -36
  25. package/src/lobby/tabs/home.ts +112 -48
  26. package/src/lobby/tabs/issues.ts +4 -3
  27. package/src/lobby/tabs/plan.ts +4 -4
  28. package/src/lobby/tabs/quickfix.ts +7 -1
  29. package/src/lobby/tabs/tasks.ts +14 -4
  30. package/src/lobby/theme.ts +30 -0
  31. package/src/lobby/view.ts +27 -5
  32. package/src/master/decisions.ts +1 -1
  33. package/src/master/master.ts +41 -4
  34. package/src/master/research.ts +5 -2
  35. package/src/pi/commands.ts +94 -10
  36. package/src/pi/events.ts +47 -10
  37. package/src/pi/quiet.ts +22 -4
  38. package/src/pi/start-task.ts +8 -2
  39. package/src/pi/tools.ts +26 -8
  40. package/src/pi/ui.ts +6 -1
  41. package/src/pi/zen-metrics.ts +13 -3
  42. package/src/pi/zen.ts +16 -9
  43. package/src/roles/reviewer.ts +23 -4
  44. package/src/roles/worker.ts +18 -0
  45. package/src/schemas/configuration.ts +4 -0
  46. package/src/schemas/findings.ts +14 -0
  47. package/src/schemas/task.ts +21 -0
  48. package/src/state/budget.ts +274 -0
  49. package/src/state/changes.ts +231 -0
  50. package/src/text.ts +28 -2
  51. package/src/web/extract.ts +332 -0
  52. package/src/web/fetch.ts +232 -0
  53. package/src/web/html.ts +183 -0
  54. package/src/web/read.ts +113 -0
  55. package/src/web/search.ts +202 -0
  56. package/src/web/tools.ts +279 -0
  57. package/src/workflow/workflow.ts +592 -42
@@ -1,6 +1,7 @@
1
- import { join } from "node:path";
1
+ import { mkdirSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
2
3
  import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
3
- import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
4
+ import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
4
5
  import {
5
6
  MAX_RUN_LOG,
6
7
  MAX_WORKER_RECORDS,
@@ -21,12 +22,17 @@ import { appendCompletedTask, appendDecision, applyKnowledge, readFileOr, writeF
21
22
  import { compactKnowledgeFile, overThreshold } from "../knowledge/compactor.ts";
22
23
  import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
23
24
  import { writeScratchpad } from "../state/persistence.ts";
24
- import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
25
+ import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
26
+ import { previewDir } from "../ask/relay.ts";
27
+ import type { AskQuestion, AskResult } from "../ask/types.ts";
25
28
  import { mapConcurrent } from "../execution/agent-runner.ts";
26
29
  import { parseWorkerResult } from "../roles/worker.ts";
27
30
  import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
28
31
  import type { Handover } from "../desk/desk.ts";
29
- import { readRepositoryDiff } from "../execution/git.ts";
32
+ import { changedFiles, commitBefore, headCommit, readRepositoryDiff } from "../execution/git.ts";
33
+ import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
34
+ import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
35
+ import type { AgentTime } from "../execution/agent-runner.ts";
30
36
  import {
31
37
  loadScoutResults,
32
38
  runReviewer,
@@ -41,7 +47,7 @@ import {
41
47
  import { researchResultPath, runResearch, type ResearchOutcome, type ResearchRequest } from "../master/research.ts";
42
48
  import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
43
49
  import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
44
- import { truncate } from "../text.ts";
50
+ import { tail, truncate } from "../text.ts";
45
51
  import { isAutoMode } from "../state/auto.ts";
46
52
  import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
47
53
  import { pingApproval } from "../pi/notify.ts";
@@ -69,6 +75,7 @@ export const ORCHESTRATE_ACTIONS = [
69
75
  "block",
70
76
  "resume",
71
77
  "decide",
78
+ "budget",
72
79
  "status",
73
80
  "cancel",
74
81
  ] as const;
@@ -93,7 +100,9 @@ export interface OrchestrateParams {
93
100
  /** implement: the concrete instruction for the worker. */
94
101
  task?: string;
95
102
  /** implement: several domains at once, run in parallel through the file desk. */
96
- assignments?: Array<{ domain: string; task: string }>;
103
+ assignments?: Array<{ domain: string; task: string; minutes?: number }>;
104
+ /** implement: minutes the step may take under the task's time budget; budget: extra minutes to ask the user for. */
105
+ minutes?: number;
97
106
  /** knowledge: which persistent file the text belongs to. */
98
107
  kind?: KnowledgeKind;
99
108
  /** compact: the knowledge file being rewritten. */
@@ -118,6 +127,8 @@ export interface WorkflowDeps {
118
127
  onUpdate?: (run: AgentRun) => void;
119
128
  ask: (question: string) => Promise<string | undefined>;
120
129
  choose: (title: string, options: string[]) => Promise<string | undefined>;
130
+ /** Puts an agent's questions to the user (the questionnaire, `from` naming the agent); absent without a UI. */
131
+ askQuestions?: (questions: AskQuestion[], from: string, signal?: AbortSignal) => Promise<AskResult>;
121
132
  notify: (message: string, level?: "info" | "warning" | "error") => void;
122
133
  runProcess?: ProcessRunner;
123
134
  /** Likely files for scouts and workers, while the classifier's file hints are on. */
@@ -302,11 +313,14 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
302
313
  if (task.state === "created") transition(task, "clarifying");
303
314
  if (task.state === "clarifying") transition(task, "scouting");
304
315
  const verifying = task.state === "synthesizing";
316
+ const instruction = params.instruction?.trim() || "Investigate this request and report findings the Master needs.";
317
+ const time = readerTime(task, deps, "SCOUTS", instruction, SCOUT_SHARE, deps.config.scout.timeoutMs, "scouting");
305
318
  const outcomes = await runScouts(
306
319
  {
307
320
  taskId: task.id,
308
321
  taskText: taskRequest(task),
309
- instruction: params.instruction?.trim() || "Investigate this request and report findings the Master needs.",
322
+ instruction,
323
+ ...(time ? { time } : {}),
310
324
  domains,
311
325
  cwd: deps.cwd,
312
326
  dataRoots: readDataRoots(deps.root, deps.configDir),
@@ -320,6 +334,7 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
320
334
  },
321
335
  deps.runProcess ?? spawnPiProcess,
322
336
  );
337
+ if (time) settleAllotment(task, deps, time.id, "finished");
323
338
  if (!verifying) transition(task, "synthesizing");
324
339
  const involved = outcomes.filter((outcome) => outcome.usable).map((outcome) => outcome.result.domain);
325
340
  task.domains = [...new Set([...task.domains, ...involved])];
@@ -331,7 +346,7 @@ async function handleScout(task: Task, params: OrchestrateParams, deps: Workflow
331
346
  const RESEARCH_STATES: TaskState[] = TASK_STATES.filter((state) => !TERMINAL_STATES.includes(state));
332
347
 
333
348
  const RESEARCH_DEGRADED =
334
- "The researcher is spawned with read-only repository tools plus web_search, fetch_content, source_check and get_search_content. If pi-web-access is not installed, the pi CLI silently ignores those tool names, so research degrades to repository-only and cannot cite the internet.";
349
+ "The researcher is spawned with read-only repository tools plus bot-lobby's web tools (web_search, fetch_content, source_check, get_search_content). A run without sources usually means the web could not be reached or the search was refused: its tool errors say which (DuckDuckGo, the default, throttles automated searches; BRAVE_API_KEY, TAVILY_API_KEY, EXA_API_KEY or SEARXNG_URL gives a dependable search).";
335
350
 
336
351
  function bulletSection(label: string, items: string[], limit: number): string {
337
352
  if (items.length === 0) return "";
@@ -403,11 +418,13 @@ function researchRequestFor(
403
418
  domain: Domain,
404
419
  instruction: string,
405
420
  taskDir: string,
421
+ time?: AgentTime,
406
422
  ): ResearchRequest {
407
423
  return {
408
424
  taskId: task.id,
409
425
  domain,
410
426
  instruction,
427
+ ...(time ? { time } : {}),
411
428
  config: deps.config,
412
429
  profile: deps.profile,
413
430
  cwd: deps.cwd,
@@ -423,7 +440,9 @@ async function handleResearch(task: Task, params: OrchestrateParams, deps: Workf
423
440
  const instruction = params.instruction?.trim();
424
441
  if (!instruction) throw new Error("research requires instruction (the question to investigate)");
425
442
  const taskDir = taskDirFor(deps.root, deps.configDir, task.id);
426
- const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir), deps.runProcess ?? spawnPiProcess);
443
+ const time = readerTime(task, deps, "RESEARCH", instruction, RESEARCH_SHARE, deps.config.researcher.timeoutMs ?? deps.config.workflow.agentTimeoutMs, "research");
444
+ const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir, time), deps.runProcess ?? spawnPiProcess);
445
+ if (time) settleAllotment(task, deps, time.id, "finished");
427
446
  appendResearchLog(taskDir, domain, outcome);
428
447
  recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: domain }]);
429
448
  return researchReport(outcome, researchResultPath(taskDir, domain));
@@ -592,19 +611,48 @@ function updateScratchpad(deps: WorkflowDeps, task: Task, outcome: WorkerOutcome
592
611
  writeScratchpad(dir, domain, [existing, entry].filter(Boolean).join("\n\n"), deps.config.knowledge);
593
612
  }
594
613
 
595
- function workerTaskText(task: Task): string {
614
+ /** Plan sections a reader must never lose to a cut: what the work is for, and how it is judged. */
615
+ const KEEP_PLAN_SECTIONS = /objective|goal|acceptance|criteria|testing|tests?\b/i;
616
+
617
+ /**
618
+ * The plan within `budget` characters: whole when it fits; otherwise the
619
+ * objective, acceptance criteria and testing sections whole (a head cut used
620
+ * to drop them, as they come last), then the others in order while they fit,
621
+ * naming any left out.
622
+ */
623
+ export function planWithin(plan: string, budget: number): string {
624
+ if (plan.length <= budget) return plan;
625
+ const sections = plan.split(/\n(?=#{1,4}\s)/);
626
+ const keep = sections.map((section) => KEEP_PLAN_SECTIONS.test(section.split("\n")[0] ?? ""));
627
+ let used = sections.reduce((total, section, index) => total + (keep[index] ? section.length + 1 : 0), 0);
628
+ const chosen = sections.map((section, index) => {
629
+ if (keep[index]) return true;
630
+ if (used + section.length + 1 > budget) return false;
631
+ used += section.length + 1;
632
+ return true;
633
+ });
634
+ const left = sections.filter((_, index) => !chosen[index]).map((section) => (section.split("\n")[0] ?? "").replace(/^#+\s*/, "").trim() || "preamble");
635
+ const text = sections.filter((_, index) => chosen[index]).join("\n");
636
+ const note = left.length > 0 ? `\n\n[Left out for length: ${left.join(", ")}. The whole plan is plan.md in the task folder.]` : "";
637
+ return `${truncate(text, budget)}${note}`;
638
+ }
639
+
640
+ function workerTaskText(task: Task, planBudget = 6000): string {
596
641
  return [
597
642
  `Requirements: ${taskRequest(task)}`,
598
643
  task.proposal ? `Approved objective: ${task.proposal}` : "",
599
- task.plan ? `Approved plan:\n${truncate(task.plan, 6000)}` : "",
644
+ task.plan ? `Approved plan:\n${planWithin(task.plan, planBudget)}` : "",
600
645
  task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
601
646
  ]
602
647
  .filter((line) => line.length > 0)
603
648
  .join("\n\n");
604
649
  }
605
650
 
606
- function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string): WorkerRequest {
651
+ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string, time?: AgentTime): WorkerRequest {
652
+ const ask = askRelay(task, deps, domain);
607
653
  return {
654
+ ...(time ? { time } : {}),
655
+ ...(ask ? { ask } : {}),
608
656
  taskId: task.id,
609
657
  domain,
610
658
  instruction,
@@ -621,6 +669,49 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
621
669
  };
622
670
  }
623
671
 
672
+ /** Agents that may ask the user themselves: the designer, whose choices are the user's to see. */
673
+ const ASKING_DOMAINS: ReadonlySet<Domain> = new Set(["designer"]);
674
+
675
+ /**
676
+ * The relay for a worker allowed to ask the user: its questions wait their
677
+ * turn behind any other dialog, go to the user with every clock stopped, and
678
+ * the answers become task decisions. Never in auto mode (nobody to ask), and
679
+ * never without a UI.
680
+ */
681
+ function askRelay(task: Task, deps: WorkflowDeps, domain: Domain): { onAsk: RelayAsk; previews: string } | undefined {
682
+ if (!ASKING_DOMAINS.has(domain) || !deps.askQuestions || isAutoMode(deps.root, deps.configDir, task.id)) return undefined;
683
+ const askQuestions = deps.askQuestions;
684
+ const label = AGENT_LABELS[domain];
685
+ const previews = previewDir(task.id);
686
+ try {
687
+ mkdirSync(previews, { recursive: true });
688
+ } catch {
689
+ // Without the folder it can still ask with Markdown previews.
690
+ }
691
+ const onAsk: RelayAsk = (questions, signal) =>
692
+ oneAtATime(async () => {
693
+ if (signal.aborted) return { answers: [], cancelled: true };
694
+ if (isAutoMode(deps.root, deps.configDir, task.id)) {
695
+ return { answers: [], cancelled: true, globalNote: "Auto mode is on, so nobody can answer: decide with the options you recommend and say in your report what you chose and why." };
696
+ }
697
+ const result = await askQuestions(questions, label, signal);
698
+ recordDecision(task, askedDecision(label, questions, result), domain);
699
+ return result;
700
+ });
701
+ return { onAsk, previews };
702
+ }
703
+
704
+ /** An agent's questions and the user's answers, as the task's decision record keeps them. */
705
+ export function askedDecision(label: string, questions: readonly AskQuestion[], result: AskResult): string {
706
+ if (result.answers.length === 0) return `${label} asked the user about ${questions.map((question) => question.header).join(", ")}; they did not answer, so ${label} decides.`;
707
+ const lines = questions.map((question, index) => {
708
+ const answer = result.answers.find((entry) => entry.questionIndex === index);
709
+ const text = !answer ? "not answered" : answer.kind === "multi" ? (answer.selected ?? []).join(", ") : `${answer.answer ?? ""}${answer.kind === "custom" ? " (in their words)" : ""}`;
710
+ return `[${question.header}] ${oneLine(question.question, 120)} → ${oneLine(text, 200)}`;
711
+ });
712
+ return `${label} asked the user: ${lines.join("; ")}`;
713
+ }
714
+
624
715
  /** Remember a worker delegation so the checklist replays it after a reload. */
625
716
  function recordWorkerRun(task: Task, run: AgentRun): void {
626
717
  const record: WorkerRunRecord = {
@@ -637,6 +728,8 @@ function recordWorkerRun(task: Task, run: AgentRun): void {
637
728
  interface Assignment {
638
729
  domain: Domain;
639
730
  instruction: string;
731
+ /** Minutes the oracle gave the step, under a time budget. */
732
+ minutes?: number;
640
733
  }
641
734
 
642
735
  /** The delegation as a list: `assignments` for a parallel batch, otherwise the single domain/task. */
@@ -645,7 +738,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
645
738
  const list = params.assignments.map((entry) => {
646
739
  const instruction = entry.task?.trim();
647
740
  if (!instruction) throw new Error("every assignment needs a task (what to implement)");
648
- return { domain: parseDomain(entry.domain, "implement"), instruction };
741
+ return { domain: parseDomain(entry.domain, "implement"), instruction, ...(entry.minutes ? { minutes: entry.minutes } : {}) };
649
742
  });
650
743
  const domains = list.map((entry) => entry.domain);
651
744
  if (new Set(domains).size !== domains.length) throw new Error("parallel assignments need distinct domains (one worker per domain)");
@@ -654,18 +747,96 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
654
747
  const domain = parseDomain(params.domain, "implement");
655
748
  const instruction = params.task?.trim();
656
749
  if (!instruction) throw new Error("implement requires task (what to implement)");
657
- return [{ domain, instruction }];
750
+ return [{ domain, instruction, ...(params.minutes ? { minutes: params.minutes } : {}) }];
751
+ }
752
+
753
+ /**
754
+ * What the working tree held when this task's agents started: the QA gate
755
+ * reads those files as pre-existing. Taken before the first worker only, so
756
+ * a task already under way when provenance arrived is not misread.
757
+ */
758
+ async function takeBaseline(task: Task, deps: WorkflowDeps): Promise<void> {
759
+ if (task.baseline || (task.workerRuns?.length ?? 0) > 0) return;
760
+ const [tree, head] = await Promise.all([treeChanges(deps), headCommit(deps.cwd)]);
761
+ task.baseline = { at: new Date().toISOString(), files: tree?.files ?? [], ...(head ? { head } : {}) };
762
+ }
763
+
764
+ /**
765
+ * The commit the task's work is measured from: HEAD when its first worker
766
+ * started, or, for a task begun before that was recorded, the newest commit
767
+ * before the task was created. Work committed since then is still the task's
768
+ * to review; a diff against HEAD alone showed it as nothing.
769
+ */
770
+ async function reviewBase(task: Task, deps: WorkflowDeps): Promise<string | undefined> {
771
+ return task.baseline?.head ?? (await commitBefore(deps.cwd, task.createdAt));
772
+ }
773
+
774
+ /** bot-lobby's own records, relative to the working folder, when they sit inside it. */
775
+ function ownRecords(deps: WorkflowDeps): string[] {
776
+ const own = relative(deps.cwd, dataRoot(deps.root, deps.configDir));
777
+ return own && !own.startsWith("..") && !isAbsolute(own) ? [own.split("\\").join("/")] : [];
778
+ }
779
+
780
+ /** The working tree's changed files, without bot-lobby's own records (its data folder may sit untracked in the project). */
781
+ async function treeChanges(deps: WorkflowDeps, base?: string): Promise<{ top: string; files: string[] } | undefined> {
782
+ const tree = await changedFiles(deps.cwd, base);
783
+ if (!tree) return undefined;
784
+ let data = dataRoot(deps.root, deps.configDir);
785
+ try {
786
+ data = realpathSync(data);
787
+ } catch {
788
+ // Not created yet: nothing of it can be in the tree.
789
+ }
790
+ const own = relative(tree.top, data);
791
+ if (!own || own.startsWith("..") || isAbsolute(own)) return tree;
792
+ const prefix = `${own.split("\\").join("/")}/`;
793
+ return { top: tree.top, files: tree.files.filter((file) => !file.startsWith(prefix)) };
794
+ }
795
+
796
+ /** Every changed file of the working tree, explained for this task; undefined without a baseline or git. */
797
+ async function changeProvenance(deps: WorkflowDeps, task: Task, base?: string): Promise<FileProvenance[] | undefined> {
798
+ if (!task.baseline) return undefined;
799
+ const tree = await treeChanges(deps, base);
800
+ if (!tree) return undefined;
801
+ return explainChanges(task, tree.files, tree.top, readChanges(deps.root, deps.configDir, task.createdAt));
802
+ }
803
+
804
+ /** Who changed the tree, for the Master: counts, and what the changes that are not this task's mean for it. */
805
+ function provenanceNote(files: readonly FileProvenance[] | undefined): string {
806
+ if (!files || files.length === 0) return "";
807
+ const kinds = new Set(files.flatMap((file) => file.kinds));
808
+ return [
809
+ `Changed files: ${provenanceSummary(files)}.`,
810
+ kinds.has("quickfix") ? "The user asked for the quick fixes directly: never revert them or send them back as fixes." : "",
811
+ kinds.has("pre-existing") || kinds.has("other-task") ? "Pre-existing changes and other tasks' are not this task's to review or revert." : "",
812
+ kinds.has("unattributed") ? "No agent recorded the unattributed edits: ask the user before counting them in or reverting them." : "",
813
+ ]
814
+ .filter((line) => line.length > 0)
815
+ .join(" ");
658
816
  }
659
817
 
660
818
  /** Record one worker's outcome on the task and return its report for the Master. */
661
- function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
819
+ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome, time?: WorkerTime): string {
662
820
  const domain = outcome.result.domain;
663
821
  recordWorkerRun(task, outcome.run);
822
+ if (time) settleAllotment(task, deps, time.id, stoppedForTime(outcome) ? "out of time" : outcome.run.status === "success" ? "finished" : "stopped");
823
+ appendChange(deps.root, deps.configDir, {
824
+ source: "worker",
825
+ id: outcome.run.runId,
826
+ taskId: task.id,
827
+ domain,
828
+ what: (outcome.run.instruction ?? "").split("\n").find((line) => line.trim())?.trim() ?? domain,
829
+ files: outcome.run.edited ?? [],
830
+ startedAt: outcome.run.startedAt,
831
+ finishedAt: outcome.run.finishedAt ?? new Date().toISOString(),
832
+ status: outcome.run.status,
833
+ });
664
834
  const approvals = recordWorkerApprovals(task, outcome, deps.config, isAutoMode(deps.root, deps.configDir, task.id));
665
835
  const pushback = recordPushback(task, outcome);
666
836
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
667
837
  updateScratchpad(deps, task, outcome);
668
- return workerReport(outcome, approvals, pushback);
838
+ const spent = timeReport(outcome);
839
+ return spent ? `${workerReport(outcome, approvals, pushback)}\n${spent}` : workerReport(outcome, approvals, pushback);
669
840
  }
670
841
 
671
842
  async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
@@ -673,13 +844,23 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
673
844
  const assignments = parseAssignments(params);
674
845
  for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
675
846
  for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
847
+ // Under a time budget every step is given its share before any starts; a spent budget starts none.
848
+ const times = workerTimes(task, deps, assignments);
676
849
  if (task.state !== "implementing") transition(task, "implementing");
677
- if (assignments.length === 1) {
678
- const { domain, instruction } = assignments[0]!;
679
- const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
680
- return absorbWorkerOutcome(task, deps, outcome);
850
+ await takeBaseline(task, deps);
851
+ let report: string;
852
+ try {
853
+ if (assignments.length === 1) {
854
+ const { domain, instruction } = assignments[0]!;
855
+ const outcome = await runWorker(workerRequest(deps, task, domain, instruction, times.get(domain)), deps.runProcess ?? spawnPiProcess);
856
+ report = absorbWorkerOutcome(task, deps, outcome, times.get(domain));
857
+ } else report = await runParallelWorkers(task, deps, assignments, times);
858
+ } finally {
859
+ // A step that never reported (the call failed) does not stay "running" in the budget.
860
+ for (const time of times.values()) settleAllotment(task, deps, time.id, "stopped");
681
861
  }
682
- return runParallelWorkers(task, deps, assignments);
862
+ const note = provenanceNote(await changeProvenance(deps, task, task.baseline?.head));
863
+ return note ? `${report}\n\n${note}` : report;
683
864
  }
684
865
 
685
866
  /**
@@ -687,14 +868,14 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
687
868
  * before editing it, queues for a busy one, and hands it over with a note; a
688
869
  * worker that finishes hands over whatever it still holds automatically.
689
870
  */
690
- async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[]): Promise<string> {
871
+ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: Assignment[], times: ReadonlyMap<Domain, WorkerTime>): Promise<string> {
691
872
  const session = new DeskSession({ cwd: deps.cwd });
692
873
  await session.open();
693
874
  let outcomes: WorkerOutcome[];
694
875
  let unenforced: Domain[];
695
876
  try {
696
877
  outcomes = await mapConcurrent(assignments, deps.config.workflow.maxParallelWorkers, ({ domain, instruction }) => {
697
- const request = workerRequest(deps, task, domain, instruction);
878
+ const request = workerRequest(deps, task, domain, instruction, times.get(domain));
698
879
  request.agent = {
699
880
  env: session.env(domain),
700
881
  extraTools: DESK_TOOLS,
@@ -710,7 +891,7 @@ async function runParallelWorkers(task: Task, deps: WorkflowDeps, assignments: A
710
891
  } finally {
711
892
  await session.close();
712
893
  }
713
- const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome));
894
+ const reports = outcomes.map((outcome) => absorbWorkerOutcome(task, deps, outcome, times.get(outcome.result.domain)));
714
895
  return [
715
896
  `Parallel batch: ${assignments.map((entry) => entry.domain).join(", ")}.`,
716
897
  ...reports,
@@ -765,28 +946,52 @@ function recordReview(task: Task, domain: Domain, result: ReviewResult): void {
765
946
  createdAt: new Date().toISOString(),
766
947
  });
767
948
  }
768
- function allScratchpads(deps: WorkflowDeps, task: Task): string {
949
+ /** Each domain's scratchpad, its newest entries kept when it is long (the latest fix round matters most). */
950
+ function allScratchpads(deps: WorkflowDeps, task: Task, perDomain = 1500): string {
769
951
  return (["designer", "backend", "qa"] as Domain[])
770
- .map((domain) => scratchpadSummary(deps, task, domain).trim())
952
+ .map((domain) => tail(scratchpadSummary(deps, task, domain).trim(), perDomain))
771
953
  .filter((text) => text.length > 0)
772
954
  .join("\n\n---\n\n");
773
955
  }
774
956
 
957
+ /**
958
+ * What the last QA round asked for, so the next one verifies it instead of
959
+ * reviewing everything from scratch (and finding new things each time).
960
+ */
961
+ function previousRound(task: Task): string {
962
+ const rounds = task.reviewRecords.filter((record) => record.domain === "qa");
963
+ const last = rounds.at(-1);
964
+ if (!last || last.verdict === "pass") return "";
965
+ const asks = [
966
+ ...last.findings.filter((finding) => finding.severity === "critical" || finding.severity === "major").map((finding) => `[${finding.severity}] ${finding.text}`),
967
+ ...last.requiredChanges,
968
+ ];
969
+ if (asks.length === 0) return "";
970
+ return [
971
+ `This is QA round ${rounds.length + 1}. Round ${rounds.length} (${last.verdict.toUpperCase()}) asked for:`,
972
+ ...asks.slice(0, 20).map((ask) => `- ${truncate(ask, 300)}`),
973
+ "Verify each of these first and say which are addressed. Do not start the review over: a new blocking finding must be critical or major (a problem the fixes introduced, or an unmet acceptance criterion); anything else goes under Optional Improvements.",
974
+ ].join("\n");
975
+ }
976
+
775
977
  const QA_INSTRUCTION = [
776
978
  "Run the QA quality gate for the completed feature.",
777
979
  "Verify requirements, acceptance criteria, regression risk, edge cases, security, accessibility,",
778
980
  "UX, reliability, and tests. Passing automated tests alone is not acceptance.",
779
981
  ].join(" ");
780
982
 
781
- /** The QA gate looks at every domain's work, not just one worker's diff. */
782
- function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string): ReviewerRequest {
983
+ /** The QA gate looks at every domain's work, not just one worker's diff, knowing who changed each file. */
984
+ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string, provenance?: readonly FileProvenance[]): ReviewerRequest {
985
+ const rounds = previousRound(task);
783
986
  return {
784
987
  taskId: task.id,
785
988
  domain: "qa",
786
- taskText: `${workerTaskText(task)}\n\nAcceptance criteria and plan:\n${truncate(task.plan ?? "", 5000)}`,
989
+ taskText: workerTaskText(task, 9000),
787
990
  workerSummary: allScratchpads(deps, task),
991
+ ...(rounds ? { previousRound: rounds } : {}),
788
992
  scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
789
993
  diff,
994
+ ...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
790
995
  instruction: instruction?.trim() || QA_INSTRUCTION,
791
996
  cwd: deps.cwd,
792
997
  dataRoots: readDataRoots(deps.root, deps.configDir),
@@ -797,22 +1002,30 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
797
1002
  };
798
1003
  }
799
1004
 
800
- function qaReport(outcome: ReviewerOutcome, decision: "accept" | "iterate" | "blocked"): string {
1005
+ /** What the loop does after a QA round: finish, fix and review again, stop, or (the user's call) accept the work as it is. */
1006
+ type LoopDecision = "accept" | "iterate" | "blocked" | "waived";
1007
+
1008
+ function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?: readonly FileProvenance[]): string {
801
1009
  const { result, run, issues } = outcome;
1010
+ const passed = result.verdict === "pass";
802
1011
  return [
803
- `QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})`,
1012
+ `QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})${result.relaxed ? " — only minor findings, which never hold the gate" : ""}`,
804
1013
  result.findings.length > 0
805
1014
  ? `Findings:\n${result.findings.map((finding) => `- [${finding.severity}] ${truncate(finding.text, 300)}`).join("\n")}`
806
1015
  : "",
807
1016
  result.requiredChanges.length > 0
808
- ? `Required changes:\n${result.requiredChanges.map((change) => `- ${truncate(change, 300)}`).join("\n")}`
1017
+ ? `${passed ? "Follow-ups (not blocking; mention them to the user, do not start a fix round for them)" : "Required changes"}:\n${result.requiredChanges.map((change) => `- ${truncate(change, 300)}`).join("\n")}`
809
1018
  : "",
810
1019
  decision === "accept"
811
1020
  ? "The QA gate passed. Record any distilled knowledge, then call action=complete."
812
- : "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa.",
813
- decision === "blocked" ? "The review limit is reached: mark the task blocked and tell the user." : "",
1021
+ : decision === "waived"
1022
+ ? "The user accepted the work as it is, without a QA pass. Call action=complete now with a short summary that names what QA still asked for."
1023
+ : decision === "iterate"
1024
+ ? "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa."
1025
+ : "The review limit is reached and the user did not accept the work: mark the task blocked and tell the user what QA still asks for. They can accept it with /bot-lobby accept.",
814
1026
  result.pushback ? pushbackLine(result.pushback, "qa") : "",
815
1027
  issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
1028
+ provenanceNote(provenance),
816
1029
  ]
817
1030
  .filter((line) => line.length > 0)
818
1031
  .join("\n");
@@ -823,14 +1036,87 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
823
1036
  if (task.state !== "reviewing") transition(task, "reviewing");
824
1037
  const iterations = (task.reviewIterations?.qa ?? 0) + 1;
825
1038
  task.reviewIterations = { qa: iterations };
826
- const diff = await readRepositoryDiff(deps.cwd);
827
- const outcome = await runReviewer(qaRequest(deps, task, diff, params.task), deps.runProcess ?? spawnPiProcess);
1039
+ const time = qaTime(task, deps);
1040
+ const base = await reviewBase(task, deps);
1041
+ const [diff, provenance] = await Promise.all([
1042
+ readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
1043
+ changeProvenance(deps, task, base),
1044
+ ]);
1045
+ const outcome = await runReviewer({ ...qaRequest(deps, task, diff, params.task, provenance), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
1046
+ if (time) settleAllotment(task, deps, time.id, "finished");
828
1047
  task.qaVerdict = outcome.result.verdict;
829
1048
  recordReview(task, "qa", outcome.result);
830
1049
  recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: "qa" }]);
831
- const decision = decideReviewLoop(outcome.result.verdict, iterations, deps.config.workflow.maxReviewIterations);
1050
+ if (outcome.result.relaxed) recordDecision(task, `QA round ${iterations} passed: ${outcome.result.relaxed}.`, "qa");
1051
+ let decision: LoopDecision = decideReviewLoop(outcome.result.verdict, iterations, reviewLimit(task, deps));
1052
+ if (decision === "blocked") decision = await askAtReviewLimit(task, outcome.result, iterations, deps);
832
1053
  if (decision === "accept") task.blockers = task.blockers.filter((blocker) => blocker.domain !== "qa");
833
- return qaReport(outcome, decision);
1054
+ return qaReport(outcome, decision, provenance);
1055
+ }
1056
+
1057
+ /** Review rounds allowed: the configured limit plus any the user granted. */
1058
+ function reviewLimit(task: Task, deps: WorkflowDeps): number {
1059
+ return deps.config.workflow.maxReviewIterations + (task.extraReviewRounds ?? 0);
1060
+ }
1061
+
1062
+ /** What QA still asks for, one line each: its blocking findings and required changes. */
1063
+ function openAsks(result: Pick<ReviewResult, "findings" | "requiredChanges">): string[] {
1064
+ return [
1065
+ ...result.findings.filter((finding) => finding.severity === "critical" || finding.severity === "major").map((finding) => `[${finding.severity}] ${finding.text}`),
1066
+ ...result.requiredChanges,
1067
+ ];
1068
+ }
1069
+
1070
+ const ACCEPT_WORK = "Accept the work as it is and complete the task";
1071
+ const ONE_MORE_ROUND = "Run one more fix round";
1072
+
1073
+ /**
1074
+ * The review limit is reached (or QA says BLOCKED): the user decides, not the
1075
+ * loop. They can accept the work as it is, grant one more fix round, or leave
1076
+ * the task blocked. Nobody is asked in auto mode or without a UI: it blocks.
1077
+ */
1078
+ async function askAtReviewLimit(task: Task, result: ReviewResult, iterations: number, deps: WorkflowDeps): Promise<LoopDecision> {
1079
+ if (isAutoMode(deps.root, deps.configDir, task.id)) return "blocked";
1080
+ const asks = openAsks(result);
1081
+ const checks = result.verification.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("-")).slice(0, 4);
1082
+ const choice = await deps.choose(
1083
+ [
1084
+ `QA has not passed ${task.id} after ${iterations} round${iterations === 1 ? "" : "s"} (this one: ${result.verdict.toUpperCase()}).`,
1085
+ asks.length > 0 ? `It still asks for:\n${asks.slice(0, 8).map((ask) => `- ${truncate(ask, 200)}`).join("\n")}` : "",
1086
+ checks.length > 0 ? `Its checks:\n${checks.map((line) => truncate(line, 160)).join("\n")}` : "",
1087
+ ].filter(Boolean).join("\n\n"),
1088
+ [ACCEPT_WORK, ONE_MORE_ROUND, "Leave the task blocked"],
1089
+ );
1090
+ if (choice === ACCEPT_WORK) {
1091
+ waiveQa(task, asks, `after ${iterations} QA round${iterations === 1 ? "" : "s"}`);
1092
+ return "waived";
1093
+ }
1094
+ if (choice === ONE_MORE_ROUND) {
1095
+ task.extraReviewRounds = (task.extraReviewRounds ?? 0) + 1;
1096
+ recordDecision(task, `The user granted one more QA round after ${iterations}.`);
1097
+ return "iterate";
1098
+ }
1099
+ return "blocked";
1100
+ }
1101
+
1102
+ /**
1103
+ * The user accepts the work as it stands: the QA gate is waived, blockers are
1104
+ * cleared, and a blocked task returns to review so it can complete. Only the
1105
+ * user's own choice (a dialog or /bot-lobby accept) ever gets here.
1106
+ */
1107
+ export function waiveQa(task: Task, open: readonly string[], why: string): void {
1108
+ task.qaWaiver = { at: new Date().toISOString(), open: open.map((ask) => truncate(ask, 300)) };
1109
+ const cleared = task.blockers.map((blocker) => blocker.reason);
1110
+ task.blockers = [];
1111
+ if (task.state === "blocked") transition(task, "implementing");
1112
+ if (task.state === "implementing") transition(task, "reviewing");
1113
+ recordDecision(task, `The user accepted the work without a QA pass (${why}).${open.length > 0 ? ` QA still asked for: ${open.map((ask) => truncate(ask, 160)).join("; ")}.` : ""}${cleared.length > 0 ? ` Cleared blockers: ${cleared.join("; ")}.` : ""}`);
1114
+ }
1115
+
1116
+ /** The last QA round's open asks, for a waiver made outside the loop. */
1117
+ export function lastQaAsks(task: Task): string[] {
1118
+ const last = task.reviewRecords.filter((record) => record.domain === "qa").at(-1);
1119
+ return last && last.verdict !== "pass" ? openAsks(last) : [];
834
1120
  }
835
1121
 
836
1122
  /**
@@ -872,8 +1158,11 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
872
1158
  }
873
1159
 
874
1160
  /** §63: record history, drop scratchpads, then mark the task completed. */
875
- function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
876
- requireState(task, ["reviewing"]);
1161
+ async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1162
+ requireState(task, ["reviewing", "blocked"]);
1163
+ // Without a QA pass only the user can let the task finish: they are asked, never overruled.
1164
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) await offerAcceptance(task, deps);
1165
+ if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
877
1166
  const blockers = completionBlockers(task, pendingApprovals(task).length);
878
1167
  if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
879
1168
  const summary = params.text?.trim() || task.proposal || task.title;
@@ -890,6 +1179,21 @@ function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDep
890
1179
  return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${advice}`;
891
1180
  }
892
1181
 
1182
+ /** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */
1183
+ async function offerAcceptance(task: Task, deps: WorkflowDeps): Promise<void> {
1184
+ if (isAutoMode(deps.root, deps.configDir, task.id)) return;
1185
+ const asks = lastQaAsks(task);
1186
+ const rounds = task.reviewIterations.qa;
1187
+ const choice = await deps.choose(
1188
+ [
1189
+ `Complete ${task.id} without a QA pass? ${rounds > 0 ? `QA ran ${rounds} round${rounds === 1 ? "" : "s"}; the last said ${(task.qaVerdict ?? "nothing").toUpperCase()}.` : "QA has not run."}`,
1190
+ asks.length > 0 ? `It still asks for:\n${asks.slice(0, 8).map((ask) => `- ${truncate(ask, 200)}`).join("\n")}` : "",
1191
+ ].filter(Boolean).join("\n\n"),
1192
+ ["Complete it anyway", "Not yet"],
1193
+ );
1194
+ if (choice === "Complete it anyway") waiveQa(task, asks, "when the oracle completed it");
1195
+ }
1196
+
893
1197
  function flushDecisions(deps: WorkflowDeps, task: Task): void {
894
1198
  const root = dataRoot(deps.root, deps.configDir);
895
1199
  for (const decision of task.decisions) {
@@ -936,6 +1240,251 @@ function handleCancel(task: Task): string {
936
1240
  return `Task ${task.id} abandoned. Its scratchpad is kept until the task is formally resolved.`;
937
1241
  }
938
1242
 
1243
+ /* ------------------------------------------------------------ time budget */
1244
+
1245
+ /** Shares of what is left that a scout batch and the researcher are given (within their configured limits). */
1246
+ const SCOUT_SHARE = 0.1;
1247
+ const RESEARCH_SHARE = 0.15;
1248
+ /** Minutes a worker out of time is taken to ask for when its report names none. */
1249
+ const DEFAULT_MORE_MINUTES = 10;
1250
+
1251
+ /** A delegation's time, with the allotment it is recorded under. */
1252
+ type WorkerTime = AgentTime & { id: string };
1253
+
1254
+ /** The task's budget and where it stands, when it has one. */
1255
+ function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
1256
+ const budget = readBudget(deps.root, deps.configDir, task.id);
1257
+ return budget ? { budget, state: budgetState(task.id, budget, task.qaVerdict === "pass") } : undefined;
1258
+ }
1259
+
1260
+ /** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
1261
+ function budgetSpent(state: BudgetState, what: string): Error {
1262
+ const reserve = state.reserveMs > 0 ? `, ${formatMinutes(state.reserveMs)} of it kept for the QA gate` : "";
1263
+ const wrap = state.reserveMs >= MIN_READ_MS ? "run action=qa on what is done" : "complete or block with what is done";
1264
+ 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}.`);
1265
+ }
1266
+
1267
+ /** What an agent is told about its time. */
1268
+ function timeNote(allotMs: number, state: BudgetState, worker: boolean): string {
1269
+ return [
1270
+ "## Time",
1271
+ `You have ${formatMinutes(allotMs)} for this ${worker ? "step" : "work"}; the task has ${formatMinutes(state.leftMs)} of its ${formatMinutes(state.totalMs)} left.`,
1272
+ worker
1273
+ ? "At about 75% you get a heads-up. When the time is up you are asked to stop and report where you left off (`## Left Off`) and how much more you need (`## More Time`); the user decides whether you get it. Land the most important part first, and keep every file consistent as you go."
1274
+ : "When the time is up you are asked to stop and report what you have, so cover the most important questions first.",
1275
+ ].join("\n");
1276
+ }
1277
+
1278
+ function recordAllotment(task: Task, deps: WorkflowDeps, entry: Omit<Allotment, "startedAt" | "granted">): void {
1279
+ updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1280
+ budget.allotments.push({ ...entry, granted: 0, startedAt: new Date().toISOString() });
1281
+ });
1282
+ }
1283
+
1284
+ function settleAllotment(task: Task, deps: WorkflowDeps, id: string, outcome: NonNullable<Allotment["outcome"]>): void {
1285
+ updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1286
+ const entry = budget.allotments.find((allotment) => allotment.id === id);
1287
+ if (entry && !entry.endedAt) Object.assign(entry, { endedAt: new Date().toISOString(), outcome });
1288
+ });
1289
+ }
1290
+
1291
+ function oneLine(text: string, max: number): string {
1292
+ return truncate(text.replace(/\s+/g, " ").trim(), max).replace(/\n\[\.\.\.\d+ characters omitted\]$/, "…");
1293
+ }
1294
+
1295
+ /** A read-only batch's time (scouts, the researcher): a share of what is left, within its configured limit. */
1296
+ function readerTime(task: Task, deps: WorkflowDeps, who: string, what: string, share: number, limitMs: number, label: string): WorkerTime | undefined {
1297
+ const now = budgetFor(task, deps);
1298
+ if (!now) return undefined;
1299
+ const ms = readerAllotment(now.state, share, limitMs);
1300
+ if (ms === undefined) throw budgetSpent(now.state, label);
1301
+ const id = `${who.toLowerCase()}-${Date.now().toString(36)}`;
1302
+ recordAllotment(task, deps, { id, who, what: oneLine(what, 120), minutes: Math.round(ms / 60_000) });
1303
+ return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
1304
+ }
1305
+
1306
+ /** The QA gate's time: its reserve at least, never more than is left. */
1307
+ function qaTime(task: Task, deps: WorkflowDeps): WorkerTime | undefined {
1308
+ const now = budgetFor(task, deps);
1309
+ if (!now) return undefined;
1310
+ const ms = qaAllotment(now.state, deps.config.agents.qa.timeoutMs ?? deps.config.workflow.agentTimeoutMs);
1311
+ if (ms === undefined) throw budgetSpent(now.state, "the QA gate");
1312
+ const id = `qa-${Date.now().toString(36)}`;
1313
+ recordAllotment(task, deps, { id, who: "QA gate", what: "review the task's work", minutes: Math.round(ms / 60_000) });
1314
+ return { id, endsAt: Date.now() + ms, allotMs: ms, note: timeNote(ms, now.state, false) };
1315
+ }
1316
+
1317
+ /** Plan domains still to build, the batch counted once: what is left before the QA reserve is split across them by default. */
1318
+ function openSlots(task: Task, batch: readonly Domain[]): number {
1319
+ const built = new Set((task.workerRuns ?? []).filter((run) => run.status === "success").map((run) => run.domain));
1320
+ return task.domains.filter((domain) => !built.has(domain) && !batch.includes(domain)).length + 1;
1321
+ }
1322
+
1323
+ /**
1324
+ * Each step's time, given before any starts: the minutes the oracle asked
1325
+ * for (it divides by scope) or an even share, never past what is left before
1326
+ * the QA gate's reserve. A spent budget starts nothing.
1327
+ */
1328
+ function workerTimes(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): Map<Domain, WorkerTime> {
1329
+ const times = new Map<Domain, WorkerTime>();
1330
+ const now = budgetFor(task, deps);
1331
+ if (!now) return times;
1332
+ const slots = openSlots(task, assignments.map((entry) => entry.domain));
1333
+ for (const { domain, instruction, minutes } of assignments) {
1334
+ const given = workerAllotment(now.state, minutes, slots);
1335
+ if (!given) throw budgetSpent(now.state, `the ${domain} step`);
1336
+ const id = `${domain}-${Date.now().toString(36)}`;
1337
+ recordAllotment(task, deps, { id, who: AGENT_LABELS[domain], what: oneLine(instruction, 120), minutes: Math.round(given.ms / 60_000) });
1338
+ if (given.note) recordDecision(task, `${AGENT_LABELS[domain]}'s step: ${given.note}.`);
1339
+ times.set(domain, { id, endsAt: Date.now() + given.ms, allotMs: given.ms, note: timeNote(given.ms, now.state, true), onTimeUp: moreTimeFor(task, deps, domain, instruction, id) });
1340
+ }
1341
+ return times;
1342
+ }
1343
+
1344
+ const AGENT_LABELS: Record<Domain, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
1345
+
1346
+ /** Out-of-time questions one at a time, even when parallel workers run out together. */
1347
+ let asking: Promise<unknown> = Promise.resolve();
1348
+ function oneAtATime<T>(ask: () => Promise<T>): Promise<T> {
1349
+ const next = asking.then(ask, ask);
1350
+ asking = next.catch(() => undefined);
1351
+ return next;
1352
+ }
1353
+
1354
+ /**
1355
+ * A worker out of time has reported what it did, where it left off and how
1356
+ * much more it needs. The user decides (auto mode: once, and only from what
1357
+ * is left before the QA gate's reserve); a grant past that grows the budget,
1358
+ * and the same agent carries on. Resolves with the ms granted, 0 to stop it.
1359
+ */
1360
+ function moreTimeFor(task: Task, deps: WorkflowDeps, domain: Domain, instruction: string, id: string): NonNullable<AgentTime["onTimeUp"]> {
1361
+ let asked = 0;
1362
+ return (run, report) => oneAtATime(async () => {
1363
+ const result = parseWorkerResult(domain, report);
1364
+ // A report with nothing left to do finished in time.
1365
+ if (!result.leftOff && !result.moreTime) return 0;
1366
+ asked += 1;
1367
+ const label = AGENT_LABELS[domain];
1368
+ const wanted = result.moreTime?.minutes ?? DEFAULT_MORE_MINUTES;
1369
+ const now = budgetFor(task, deps);
1370
+ if (!now) return 0;
1371
+ const minutes = await decideMoreTime(task, deps, { label, instruction, result, wanted, run, asked, state: now.state });
1372
+ if (minutes <= 0) return 0;
1373
+ const over = Math.max(0, Math.ceil((minutes * 60_000 - now.state.windowMs) / 60_000));
1374
+ updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1375
+ budget.granted += over;
1376
+ const entry = budget.allotments.find((allotment) => allotment.id === id);
1377
+ if (entry) Object.assign(entry, { minutes: entry.minutes + minutes, granted: entry.granted + minutes });
1378
+ });
1379
+ recordDecision(task, `${label} ran out of time; ${isAutoMode(deps.root, deps.configDir, task.id) ? "auto mode gave it" : "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)}`);
1380
+ return minutes * 60_000;
1381
+ });
1382
+ }
1383
+
1384
+ interface MoreTimeAsk {
1385
+ label: string;
1386
+ instruction: string;
1387
+ result: WorkerResult;
1388
+ wanted: number;
1389
+ run: AgentRun;
1390
+ asked: number;
1391
+ state: BudgetState;
1392
+ }
1393
+
1394
+ const MORE_TIME = "Give it the time it asks for";
1395
+ const OTHER_TIME = "Give a different amount";
1396
+
1397
+ async function decideMoreTime(task: Task, deps: WorkflowDeps, ask: MoreTimeAsk): Promise<number> {
1398
+ const { label, instruction, result, wanted, run, asked, state } = ask;
1399
+ if (isAutoMode(deps.root, deps.configDir, task.id)) {
1400
+ // Nobody to ask: once, and only from time the task still has before the QA gate's reserve.
1401
+ if (asked === 1 && wanted * 60_000 <= state.windowMs) return wanted;
1402
+ recordDecision(task, `Auto mode: ${label} ran out of time and was not given more (${asked > 1 ? "it already had more once" : `it asked for ${wanted} minutes, ${formatMinutes(state.windowMs)} were left`}).`);
1403
+ return 0;
1404
+ }
1405
+ const over = Math.max(0, Math.ceil((wanted * 60_000 - state.windowMs) / 60_000));
1406
+ const had = run.allotMs ?? 0;
1407
+ const choice = await deps.choose(
1408
+ [
1409
+ `${label} is out of time: it had ${formatMinutes(had + (run.extendedMs ?? 0))} for "${oneLine(instruction, 160)}".`,
1410
+ result.completed ? `Done so far: ${oneLine(result.completed, 400)}` : "",
1411
+ result.leftOff ? `Left to do: ${oneLine(result.leftOff, 400)}` : "",
1412
+ `It needs about ${wanted} more minutes${result.moreTime?.reason ? `: ${oneLine(result.moreTime.reason, 200)}` : ""}.`,
1413
+ `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` : ""}.`,
1414
+ `Give ${label} ${wanted} more minutes to finish?`,
1415
+ ].filter(Boolean).join("\n\n"),
1416
+ [MORE_TIME, OTHER_TIME, `Stop ${label} here`],
1417
+ );
1418
+ if (choice === MORE_TIME) return wanted;
1419
+ if (choice === OTHER_TIME) return parseMinutes((await deps.ask(`How many more minutes for ${label}?`)) ?? "") ?? 0;
1420
+ recordDecision(task, `The user stopped ${label} at its time limit. Left off: ${oneLine(result.leftOff ?? "", 200)}`);
1421
+ return 0;
1422
+ }
1423
+
1424
+ /** Stopped at its time with work left: a report that finished everything as time ran out is not. */
1425
+ function stoppedForTime(outcome: WorkerOutcome): boolean {
1426
+ return Boolean(outcome.run.timeUp && (outcome.result.leftOff || outcome.result.moreTime));
1427
+ }
1428
+
1429
+ /** How a step's time went, for the oracle: out of time and stopped, or given more. */
1430
+ function timeReport(outcome: WorkerOutcome): string {
1431
+ const { run, result } = outcome;
1432
+ const label = AGENT_LABELS[result.domain];
1433
+ if (stoppedForTime(outcome)) {
1434
+ return [
1435
+ `${label} ran out of its time and was not given more: this step is unfinished.`,
1436
+ result.leftOff ? `Left off: ${oneLine(result.leftOff, 600)}` : "",
1437
+ result.moreTime ? `It asked for ${result.moreTime.minutes ?? "more"} minutes${result.moreTime.reason ? `: ${oneLine(result.moreTime.reason, 300)}` : ""}.` : "",
1438
+ "Decide with the user: trim the scope, ask for task time with action=budget, or wrap up with what is done.",
1439
+ ].filter(Boolean).join("\n");
1440
+ }
1441
+ return run.extendedMs ? `${label} ran out of its ${formatMinutes(run.allotMs ?? 0)} and was given ${formatMinutes(run.extendedMs)} more.` : "";
1442
+ }
1443
+
1444
+ /**
1445
+ * `action=budget`: with no minutes, where the budget stands; with minutes and
1446
+ * a reason, the oracle asks the user for more task time. Only the user can
1447
+ * grant it; in auto mode nobody can, and the task wraps up.
1448
+ */
1449
+ async function handleBudget(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1450
+ const now = budgetFor(task, deps);
1451
+ if (!now) return "This task has no time budget. The user sets one with /bot-lobby budget <minutes>.";
1452
+ const minutes = params.minutes;
1453
+ if (!minutes || minutes <= 0) return allotmentReport(now.budget);
1454
+ const reason = params.reason?.trim() || params.text?.trim();
1455
+ if (!reason) throw new Error("budget requires reason: why the task needs more time");
1456
+ if (isAutoMode(deps.root, deps.configDir, task.id)) {
1457
+ recordDecision(task, `Auto mode: asked for ${minutes} more minutes (${oneLine(reason, 160)}), which only the user can give.`);
1458
+ 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.";
1459
+ }
1460
+ const choice = await deps.choose(
1461
+ [`The oracle asks for ${minutes} more minutes on ${task.id}: ${oneLine(reason, 400)}`, budgetLine(now.budget, now.state)].join("\n\n"),
1462
+ [`Give ${minutes} more minutes`, OTHER_TIME, "No"],
1463
+ );
1464
+ const granted = choice === `Give ${minutes} more minutes` ? minutes : choice === OTHER_TIME ? parseMinutes((await deps.ask(`How many more minutes for ${task.id}?`)) ?? "") ?? 0 : 0;
1465
+ if (granted <= 0) {
1466
+ recordDecision(task, `The user did not give ${minutes} more minutes: ${oneLine(reason, 200)}`);
1467
+ return "The user did not give more time. Wrap up with what is done and tell them what is left.";
1468
+ }
1469
+ updateBudget(deps.root, deps.configDir, task.id, (budget) => {
1470
+ budget.granted += granted;
1471
+ });
1472
+ recordDecision(task, `The user gave the task ${granted} more minutes: ${oneLine(reason, 200)}`);
1473
+ return `The user gave the task ${granted} more minutes.`;
1474
+ }
1475
+
1476
+ /** What each delegation was given, newest last. */
1477
+ function allotmentReport(budget: TaskBudget): string {
1478
+ 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"}`);
1479
+ return rows.length > 0 ? `Allotted so far:\n${rows.join("\n")}` : "Nothing allotted yet.";
1480
+ }
1481
+
1482
+ /** Every orchestrate result ends with where the budget stands, when the task has one. */
1483
+ function budgetFooter(task: Task, deps: WorkflowDeps): string {
1484
+ const now = budgetFor(task, deps);
1485
+ return now ? `\n\n${budgetLine(now.budget, now.state)}` : "";
1486
+ }
1487
+
939
1488
  const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams, deps: WorkflowDeps) => Promise<string> | string> = {
940
1489
  clarify: handleClarify,
941
1490
  scout: handleScout,
@@ -951,6 +1500,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
951
1500
  block: handleBlock,
952
1501
  resume: handleResume,
953
1502
  decide: handleDecide,
1503
+ budget: handleBudget,
954
1504
  status: (task) => describeTask(task),
955
1505
  cancel: handleCancel,
956
1506
  };
@@ -991,11 +1541,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
991
1541
  const message = await handler(task, params, tracked);
992
1542
  const runs = recordRunLog(task, finished, deps);
993
1543
  saveTask(deps.root, deps.configDir, task);
994
- return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}`, runs };
1544
+ return { ok: true, taskId: task.id, state: task.state, message: `${message}${runsFooter(runs)}${budgetFooter(task, deps)}`, runs };
995
1545
  } catch (error) {
996
1546
  const runs = recordRunLog(task, finished, deps);
997
1547
  saveTask(deps.root, deps.configDir, task);
998
- return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}`, runs };
1548
+ return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}${budgetFooter(task, deps)}`, runs };
999
1549
  }
1000
1550
  }
1001
1551