@a-t-h-i/bot-lobby 0.6.2 → 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 (69) hide show
  1. package/README.md +112 -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 +9 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/feed.ts +76 -8
  21. package/src/lobby/layout.ts +38 -18
  22. package/src/lobby/markdown.ts +92 -19
  23. package/src/lobby/planner.ts +1 -1
  24. package/src/lobby/quickfix.ts +21 -0
  25. package/src/lobby/runtime.ts +51 -37
  26. package/src/lobby/session-files.ts +162 -25
  27. package/src/lobby/sessions.ts +21 -7
  28. package/src/lobby/tabs/home.ts +289 -67
  29. package/src/lobby/tabs/issues.ts +4 -3
  30. package/src/lobby/tabs/plan.ts +4 -4
  31. package/src/lobby/tabs/quickfix.ts +7 -1
  32. package/src/lobby/tabs/tasks.ts +14 -4
  33. package/src/lobby/theme.ts +30 -0
  34. package/src/lobby/view.ts +141 -25
  35. package/src/master/decisions.ts +1 -1
  36. package/src/master/master.ts +41 -4
  37. package/src/master/research.ts +5 -2
  38. package/src/pi/commands.ts +94 -10
  39. package/src/pi/events.ts +54 -12
  40. package/src/pi/fresh-context.ts +134 -0
  41. package/src/pi/owner.ts +19 -10
  42. package/src/pi/quiet.ts +22 -4
  43. package/src/pi/start-task.ts +11 -2
  44. package/src/pi/tools.ts +26 -8
  45. package/src/pi/ui.ts +9 -3
  46. package/src/pi/zen-large.ts +10 -10
  47. package/src/pi/zen-metrics.ts +13 -3
  48. package/src/pi/zen.ts +22 -15
  49. package/src/roles/reviewer.ts +23 -4
  50. package/src/roles/worker.ts +18 -0
  51. package/src/schemas/configuration.ts +8 -0
  52. package/src/schemas/findings.ts +14 -0
  53. package/src/schemas/task.ts +21 -0
  54. package/src/state/archive.ts +12 -3
  55. package/src/state/backlog.ts +13 -3
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/state/file-cache.ts +62 -0
  59. package/src/state/metrics.ts +63 -13
  60. package/src/state/persistence.ts +36 -2
  61. package/src/text.ts +28 -2
  62. package/src/web/extract.ts +332 -0
  63. package/src/web/fetch.ts +232 -0
  64. package/src/web/html.ts +183 -0
  65. package/src/web/read.ts +113 -0
  66. package/src/web/search.ts +202 -0
  67. package/src/web/tools.ts +279 -0
  68. package/src/width.ts +102 -0
  69. package/src/workflow/workflow.ts +592 -42
package/src/pi/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Container, Text } from "@earendil-works/pi-tui";
2
+ import { getMarkdownTheme, type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import type { AgentRun } from "../schemas/findings.ts";
6
6
  import type { ProcessRunner } from "../execution/pi-runner.ts";
@@ -8,6 +8,10 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
8
8
  import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
+ import { whileAsking } from "../state/budget.ts";
12
+ import { askUser } from "../ask/dialog.ts";
13
+ import type { AskQuestion } from "../ask/types.ts";
14
+ import { unescapeBreaks } from "../lobby/markdown.ts";
11
15
  import { isQuiet } from "./quiet.ts";
12
16
  import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
13
17
  import { agentName, describeRun } from "./run-summary.ts";
@@ -36,16 +40,18 @@ const OrchestrateSchema = Type.Object({
36
40
  Type.Object({
37
41
  domain: Type.String({ description: "designer, backend, or qa" }),
38
42
  task: Type.String({ description: "the concrete step(s) for that domain's worker" }),
43
+ minutes: Type.Optional(Type.Number({ description: "under a time budget: minutes for this worker, by its scope" })),
39
44
  }),
40
45
  { description: "implement: run several domains in parallel (distinct domains); workers share files through the file desk" },
41
46
  ),
42
47
  ),
48
+ minutes: Type.Optional(Type.Number({ description: "implement (under a time budget): minutes for this step, by its scope; budget: more minutes to ask the user for" })),
43
49
  approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
44
50
  decision: Type.Optional(
45
51
  StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
46
52
  ),
47
53
  note: Type.Optional(Type.String({ description: "resolve_approval: rationale, or what to do instead when rejected" })),
48
- reason: Type.Optional(Type.String({ description: "block: why the task cannot continue" })),
54
+ reason: Type.Optional(Type.String({ description: "block: why the task cannot continue; budget: why the task needs more time" })),
49
55
  text: Type.Optional(Type.String({ description: "decide/complete/knowledge: the decision, completion summary, or knowledge text" })),
50
56
  file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md" })),
51
57
  kind: Type.Optional(
@@ -64,7 +70,8 @@ const DESCRIPTION = [
64
70
  "review), knowledge (record approved knowledge or a decision),",
65
71
  "compact (replace a knowledge file with a rewritten version, archiving the old one),",
66
72
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
67
- "pass), block/resume (escalate or continue), status, cancel.",
73
+ "pass), block/resume (escalate or continue), budget (under a time budget: where it stands, or ask the",
74
+ "user for more minutes with a reason), status, cancel.",
68
75
  "The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
69
76
  ].join(" ");
70
77
 
@@ -109,8 +116,11 @@ export function workflowDeps(
109
116
  classifier: classifier(),
110
117
  triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
111
118
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
112
- ask: async (question) => (hasUI ? ctx.ui.input(question) : undefined),
113
- choose: async (title, options) => (hasUI ? ctx.ui.select(title, options) : undefined),
119
+ // Time spent waiting on the user is not the task's: its budget clock waits too.
120
+ ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.input(question)) : undefined),
121
+ choose: async (title, options) => (hasUI ? whileAsking(() => ctx.ui.select(title, options)) : undefined),
122
+ // An agent's own questions (the designer's), relayed as the questionnaire.
123
+ ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
114
124
  notify: (message, level = "info") => {
115
125
  if (hasUI) ctx.ui.notify(message, level);
116
126
  },
@@ -135,6 +145,14 @@ function runReporter(
135
145
  };
136
146
  }
137
147
 
148
+ /** A header line over Markdown, rendered as pi renders its own messages (a proposal's bullets, a report's code spans). */
149
+ function headedMarkdown(header: string, body: string, color: (text: string) => string): Container {
150
+ const box = new Container();
151
+ box.addChild(new Text(header, 0, 0));
152
+ if (body.trim()) box.addChild(new Markdown(unescapeBreaks(body), 0, 0, getMarkdownTheme(), { color }));
153
+ return box;
154
+ }
155
+
138
156
  /** TUI-only transcript entries; these never enter the model's context. */
139
157
  function registerBotLobbyEntries(pi: ExtensionAPI): void {
140
158
  pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
@@ -145,7 +163,7 @@ function registerBotLobbyEntries(pi: ExtensionAPI): void {
145
163
  }
146
164
  const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
147
165
  const body = data?.text ?? "";
148
- return new Text(`${theme.fg("accent", theme.bold(header))}\n${theme.fg("toolOutput", expanded ? body : truncate(body, 600))}`, 0, 0);
166
+ return headedMarkdown(theme.fg("accent", theme.bold(header)), expanded ? body : truncate(body, 600), (text) => theme.fg("toolOutput", text));
149
167
  });
150
168
  }
151
169
 
@@ -200,7 +218,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
200
218
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
201
219
  const icon = details?.ok ? theme.fg("success", "✓") : theme.fg("warning", "!");
202
220
  const header = `${icon} ${theme.fg("toolTitle", theme.bold("orchestrate"))} ${theme.fg("accent", details?.taskId ?? "")} ${theme.fg("muted", `→ ${details?.state ?? "?"}`)}`;
203
- return new Text(expanded && body ? `${header}\n${theme.fg("dim", truncate(body, 2000))}` : header, 0, 0);
221
+ return expanded && body ? headedMarkdown(header, truncate(body, 2000), (text) => theme.fg("dim", text)) : new Text(header, 0, 0);
204
222
  },
205
223
  });
206
224
  }
package/src/pi/ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Key, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
2
+ import { Key, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { clip } from "../width.ts";
3
4
  import type { AgentRun } from "../schemas/findings.ts";
4
5
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
5
6
  import { activeTask } from "../state/persistence.ts";
@@ -25,6 +26,7 @@ import { slotSituations } from "./zen-metrics.ts";
25
26
  import type { Situation } from "./kaomoji.ts";
26
27
  import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
27
28
  import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
29
+ import { budgetClock } from "../state/budget.ts";
28
30
 
29
31
  export const STATUS_KEY = "bot-lobby";
30
32
 
@@ -51,6 +53,8 @@ let zenOn = false;
51
53
  * keys the widget's render cache.
52
54
  */
53
55
  let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
56
+ /** Where the widget's task lives, so its time budget can be read. */
57
+ let zenPaths: { root: string; configDir: string } | undefined;
54
58
  let zenVersion = 0;
55
59
 
56
60
  /** Latest master tool activity; the oracle's speech bubble shows it. */
@@ -254,8 +258,9 @@ export class ZenScene {
254
258
  const motion = this.motion(now);
255
259
  const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
256
260
  if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
257
- const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still };
258
- const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
261
+ const time = zenState.task && zenPaths ? budgetClock(zenPaths.root, zenPaths.configDir, zenState.task.id, now) : undefined;
262
+ const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still, ...(time ? { time } : {}) };
263
+ const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
259
264
  this.cache = { key, theme, lines };
260
265
  return lines;
261
266
  }
@@ -355,6 +360,7 @@ function leaveZen(ctx: ExtensionContext): void {
355
360
  export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
356
361
  const sessionId = ctx.sessionManager.getSessionId();
357
362
  const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
363
+ zenPaths = { root, configDir };
358
364
  const sameTask = zenState.task?.id === task?.id;
359
365
  setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
360
366
  ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
@@ -1,4 +1,4 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { clip, textWidth } from "../width.ts";
2
2
  import {
3
3
  BAR,
4
4
  BUBBLE,
@@ -243,11 +243,11 @@ function labelPaint(status: SlotState, theme?: PanelTheme): ((text: string) => s
243
243
  }
244
244
 
245
245
  function padTo(text: string, width: number): string {
246
- return truncateToWidth(text, width, "", true);
246
+ return clip(text, width, "", true);
247
247
  }
248
248
 
249
249
  function centre(text: string, width: number): string {
250
- return padTo(" ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) + text, width);
250
+ return padTo(" ".repeat(Math.max(0, Math.floor((width - textWidth(text)) / 2))) + text, width);
251
251
  }
252
252
 
253
253
  function mod(index: number, length: number): number {
@@ -273,7 +273,7 @@ function sceneCentre(width: number): number {
273
273
 
274
274
  /** Place a row of at most `SCENE_WIDTH` columns on the scene's centre column. */
275
275
  function place(row: string, width: number): string {
276
- return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - visibleWidth(row)) / 2))) + row;
276
+ return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - textWidth(row)) / 2))) + row;
277
277
  }
278
278
 
279
279
  function alertLine(
@@ -283,7 +283,7 @@ function alertLine(
283
283
  kind: "warning" | "error" = "warning",
284
284
  ): string {
285
285
  const left = sceneLeft(width);
286
- const line = paint(truncateToWidth(`! ${alert}`, width - left, "…"), kind, theme, true);
286
+ const line = paint(clip(`! ${alert}`, width - left, "…"), kind, theme, true);
287
287
  return " ".repeat(left) + line;
288
288
  }
289
289
 
@@ -294,8 +294,8 @@ function boxLines(input: LargeSceneInput, width: number, theme?: PanelTheme): st
294
294
 
295
295
  function topBorder(input: LargeSceneInput, theme?: PanelTheme): string {
296
296
  const lead = "┌─ BOT-LOBBY ── ";
297
- const label = truncateToWidth(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
298
- const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - visibleWidth(lead) - visibleWidth(label)));
297
+ const label = clip(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
298
+ const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - textWidth(lead) - textWidth(label)));
299
299
  return paint(lead, "muted", theme) + paint(label, "accent", theme, true) + paint(`${tail}┐`, "muted", theme);
300
300
  }
301
301
 
@@ -349,7 +349,7 @@ function bodyLines(
349
349
  /** One dim line under the agent strip; warnings take the warning colour. */
350
350
  function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
351
351
  const left = sceneLeft(width) + 1;
352
- const text = truncateToWidth(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
352
+ const text = clip(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
353
353
  return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
354
354
  }
355
355
 
@@ -494,7 +494,7 @@ export function oracleAside(input: LargeSceneInput): string {
494
494
  function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
495
495
  const accent = ORACLE_COLORS[input.oracle.pose];
496
496
  const border = (text: string) => paint(text, "muted", theme);
497
- const text = (value: string) => padTo(truncateToWidth(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
497
+ const text = (value: string) => padTo(clip(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
498
498
  const rule = "─".repeat(BUBBLE.width - 2);
499
499
  return [
500
500
  border(`╭${rule}╮`),
@@ -661,7 +661,7 @@ function assemble(end: number, cells: readonly Cell[]): string {
661
661
  let cursor = 0;
662
662
  for (const cell of cells) {
663
663
  row += " ".repeat(Math.max(0, cell.offset - cursor)) + (cell.paint ? cell.paint(cell.text) : cell.text);
664
- cursor = cell.offset + visibleWidth(cell.text);
664
+ cursor = cell.offset + textWidth(cell.text);
665
665
  }
666
666
  return row + " ".repeat(Math.max(0, end - cursor));
667
667
  }
@@ -11,6 +11,13 @@ import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art
11
11
  import { formatDuration, planChecklist } from "./zen.ts";
12
12
  import { QUIET_MS, quietFor, slotOf } from "./run-summary.ts";
13
13
  import type { Situation } from "./kaomoji.ts";
14
+ import { formatMinutes } from "../state/budget.ts";
15
+
16
+ /** A task's time budget as the scene shows it: work time spent of the whole. */
17
+ export interface SceneTime {
18
+ usedMs: number;
19
+ totalMs: number;
20
+ }
14
21
 
15
22
  export type { SlotId, SlotState };
16
23
 
@@ -70,7 +77,10 @@ function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
70
77
  function runElapsedLabel(run: AgentRun | undefined, now: number): string {
71
78
  if (!run) return "—";
72
79
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
73
- return formatDuration(end - Date.parse(run.startedAt));
80
+ const elapsed = end - Date.parse(run.startedAt);
81
+ // Under a time budget: minutes spent of the minutes given (`12/30m`).
82
+ if (run.allotMs) return `${Math.max(0, Math.floor(elapsed / 60_000))}/${Math.round((run.allotMs + (run.extendedMs ?? 0)) / 60_000)}m`;
83
+ return formatDuration(elapsed);
74
84
  }
75
85
 
76
86
  function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
@@ -89,7 +99,7 @@ function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
89
99
  };
90
100
  }
91
101
 
92
- export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
102
+ export function sceneMetrics(task: Task, runs: AgentRun[], now: number, time?: SceneTime): SceneMetrics {
93
103
  const checklist = planChecklist(task.plan ?? "", runs);
94
104
  const total = checklist.length;
95
105
  const done = checklist.filter((step) => step.status === "done").length;
@@ -98,7 +108,7 @@ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMe
98
108
  const elapsed = Number.isFinite(created) ? Math.max(0, now - created) : 0;
99
109
  return {
100
110
  ...summary,
101
- elapsedLabel: formatDuration(elapsed),
111
+ elapsedLabel: time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(elapsed),
102
112
  slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
103
113
  };
104
114
  }
package/src/pi/zen.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * arrive on its run. Everything here is pure: expression frames and the spinner
8
8
  * tick arrive from the caller, and every timestamp arrives as `now`.
9
9
  */
10
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { clip, textWidth } from "../width.ts";
11
11
  import type { AgentRun } from "../schemas/findings.ts";
12
12
  import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
13
13
  import { truncate } from "../text.ts";
@@ -37,7 +37,8 @@ import {
37
37
  type LargeSlot,
38
38
  type LargeTaskRow,
39
39
  } from "./zen-large.ts";
40
- import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
40
+ import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SceneTime, type SlotView } from "./zen-metrics.ts";
41
+ import { formatMinutes } from "../state/budget.ts";
41
42
  import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
42
43
  import { shortDuration } from "../text.ts";
43
44
 
@@ -378,18 +379,22 @@ function checklistRow(step: PlanStep, ordinal: number): string {
378
379
  return ` ${icon} ${ordinal}. ${step.text}`;
379
380
  }
380
381
 
381
- function headerLine(task: Task, now: number, quiet: boolean): string {
382
+ function headerLine(task: Task, now: number, quiet: boolean, time?: SceneTime): string {
382
383
  const paused = task.paused ? " (paused)" : "";
383
- const elapsed = formatDuration(now - Date.parse(task.createdAt));
384
+ const elapsed = time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(now - Date.parse(task.createdAt));
384
385
  const mode = quiet ? "tools hidden (alt+t)" : "tools shown";
385
386
  return `bot-lobby ${task.id} · ${task.state}${paused} ⏱ ${elapsed} · ${mode}`;
386
387
  }
387
388
 
388
- /** The pending approval or blocker the user must resolve, with its severity. */
389
- function taskAlert(task: Task): { text: string; kind: "warning" | "error" } | undefined {
389
+ /** The pending approval or blocker the user must resolve, with its severity; then a spent time budget. */
390
+ function taskAlert(task: Task, time?: SceneTime): { text: string; kind: "warning" | "error" } | undefined {
390
391
  const pending = task.approvals.filter((approval) => approval.status === "pending");
391
392
  if (pending.length > 0) return { text: `approvals pending: ${pending.map((approval) => approval.id).join(", ")}`, kind: "warning" };
392
393
  if (task.blockers.length > 0) return { text: `blocked: ${truncate(task.blockers[0]!.reason, 60)}`, kind: "error" };
394
+ if (time && time.usedMs >= time.totalMs * 0.9) {
395
+ const spent = time.usedMs >= time.totalMs;
396
+ return { text: `time budget ${spent ? "spent" : "nearly spent"}: ${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}`, kind: "warning" };
397
+ }
393
398
  return undefined;
394
399
  }
395
400
 
@@ -399,9 +404,9 @@ const BANNER_MIN_WIDTH = 60;
399
404
  /** Boxed title for wide terminals, one-line title otherwise; never wider than `width`. */
400
405
  export function bannerLines(width: number): string[] {
401
406
  if (width < 1) return [];
402
- if (width < BANNER_MIN_WIDTH) return [truncateToWidth(BANNER_NARROW, width, "")];
407
+ if (width < BANNER_MIN_WIDTH) return [clip(BANNER_NARROW, width, "")];
403
408
  const inner = BANNER_WIDTH - 2;
404
- const titleWidth = visibleWidth(BANNER_TITLE);
409
+ const titleWidth = textWidth(BANNER_TITLE);
405
410
  const left = Math.floor((inner - titleWidth) / 2);
406
411
  const border = "─".repeat(inner);
407
412
  return [`┌${border}┐`, `│${" ".repeat(left)}${BANNER_TITLE}${" ".repeat(inner - titleWidth - left)}│`, `└${border}┘`];
@@ -476,7 +481,7 @@ function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
476
481
  }
477
482
 
478
483
  function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
479
- const body = truncateToWidth(content, COMPACT_INNER, "", true);
484
+ const body = clip(content, COMPACT_INNER, "", true);
480
485
  return `|${theme ? theme.fg(color, body) : body}|`;
481
486
  }
482
487
 
@@ -518,13 +523,13 @@ function compactPanel(
518
523
  opts: PanelOptions,
519
524
  width: number,
520
525
  ): string[] {
521
- const metrics = sceneMetrics(task, runs, now);
526
+ const metrics = sceneMetrics(task, runs, now, opts.time);
522
527
  const expressions = opts.expressions ?? {};
523
528
  const strip = opts.still ? [] : compactStrip(task.state, metrics.slots, expressions, opts.theme);
524
- const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...strip];
529
+ const fixed = [...bannerLines(width), headerLine(task, now, quiet, opts.time), ...strip];
525
530
  const tail = tailLines(task, runs, now, tick, steps, opts.theme);
526
531
  const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
527
- return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => truncateToWidth(line, width));
532
+ return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
528
533
  }
529
534
 
530
535
  /* -------------------------------------------------------------------------
@@ -577,8 +582,8 @@ function sceneInput(
577
582
  steps: PlanStep[],
578
583
  opts: PanelOptions,
579
584
  ): LargeSceneInput {
580
- const metrics = sceneMetrics(task, runs, now);
581
- const alert = taskAlert(task);
585
+ const metrics = sceneMetrics(task, runs, now, opts.time);
586
+ const alert = taskAlert(task, opts.time);
582
587
  return {
583
588
  taskId: task.id,
584
589
  taskTitle: task.title,
@@ -616,6 +621,8 @@ export interface PanelOptions {
616
621
  oracleMotion?: OracleMotion;
617
622
  /** Leave out the oracle and agent animations: status, alert, activity and checklist only. */
618
623
  still?: boolean;
624
+ /** The task's time budget: the elapsed label reads against it. */
625
+ time?: SceneTime;
619
626
  }
620
627
 
621
628
  /** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
@@ -646,7 +653,7 @@ export function panelLines(
646
653
  // The still scene has no tower or agent strip to fit, so any height keeps the large tier's status box.
647
654
  if (width >= LARGE_MIN_WIDTH && (budget >= MIN_LARGE_LINES || opts.still)) {
648
655
  const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme, opts.still);
649
- return scene.map((line) => truncateToWidth(line, width));
656
+ return scene.map((line) => clip(line, width));
650
657
  }
651
658
  return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
652
659
  }
@@ -30,15 +30,23 @@ function parseVerdict(text: string | undefined): Verdict {
30
30
  return "blocked";
31
31
  }
32
32
 
33
+ /** The severity a finding was tagged with, or undefined when it carries none. */
34
+ function taggedSeverity(text: string): Severity | undefined {
35
+ const match = /^\[([^\]]+)\]/.exec(text);
36
+ return SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate));
37
+ }
38
+
33
39
  function parseFinding(text: string): ReviewFinding {
34
40
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(text);
35
- const severity = SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate)) ?? "info";
36
- return { severity, text: (match?.[2] ?? text).trim() };
41
+ return { severity: taggedSeverity(text) ?? "info", text: (match?.[2] ?? text).trim() };
37
42
  }
38
43
 
39
44
  /** The QA gate never passes by default: a PASS must cite at least one executed check. */
40
45
  export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks under ## Verification, downgraded to CHANGES_REQUIRED";
41
46
 
47
+ /** Minor and info findings never hold the gate; what the reviewer asked for is kept as follow-ups. */
48
+ export const MINOR_ONLY = "CHANGES_REQUIRED for minor or info findings only: passed, its asks kept as follow-ups";
49
+
42
50
  /** A verification bullet names a command or check and its result (`- cmd — result`). */
43
51
  function executedChecks(verification: string): string[] {
44
52
  return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
@@ -49,23 +57,34 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
49
57
  const sections = parseSections(raw);
50
58
  const verification = findSection(sections, "verification") ?? "";
51
59
  const requiredChanges = bullets(findSection(sections, "required changes"));
60
+ const entries = bullets(findSection(sections, "findings"));
52
61
  let verdict = parseVerdict(findSection(sections, "verdict"));
53
62
  let downgraded: string | undefined;
54
- if (verdict === "pass" && executedChecks(verification).length === 0) {
63
+ let relaxed: string | undefined;
64
+ const checked = executedChecks(verification).length > 0;
65
+ if (verdict === "pass" && !checked) {
55
66
  verdict = "changes_required";
56
67
  downgraded = UNVERIFIED_PASS;
57
68
  requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
69
+ } else if (
70
+ verdict === "changes_required" && checked && entries.length > 0
71
+ // Only findings explicitly tagged minor or info: an untagged one may be serious.
72
+ && entries.every((entry) => { const severity = taggedSeverity(entry); return severity === "minor" || severity === "info"; })
73
+ ) {
74
+ verdict = "pass";
75
+ relaxed = MINOR_ONLY;
58
76
  }
59
77
  return {
60
78
  domain,
61
79
  role: "reviewer",
62
80
  verdict,
63
- findings: bullets(findSection(sections, "findings")).map(parseFinding),
81
+ findings: entries.map(parseFinding),
64
82
  verification,
65
83
  requiredChanges,
66
84
  optionalImprovements: bullets(findSection(sections, "optional improvements")),
67
85
  pushback: parsePushback(sections),
68
86
  ...(downgraded ? { downgraded } : {}),
87
+ ...(relaxed ? { relaxed } : {}),
69
88
  raw,
70
89
  };
71
90
  }
@@ -25,6 +25,8 @@ export const workerSpec: RoleSpec = {
25
25
  "the matching section instead. Do not narrate. Report only what you actually changed and verified.",
26
26
  "An optional `## Pushback` (`**Request:**`, `**Reason:**`, optional `**Alternative:**`) states a change you",
27
27
  "believe is wrong, with your reason, instead of doing it; still complete everything else you safely can.",
28
+ "Stopped because your time is up, also add `## Left Off` (what you were doing, what is still to do) and",
29
+ "`## More Time` (`N minutes — why`).",
28
30
  ].join(" "),
29
31
  };
30
32
 
@@ -65,6 +67,15 @@ export function parseBlockers(
65
67
  ];
66
68
  }
67
69
 
70
+ /** `## More Time` as the minutes asked for (`15 minutes`, `1h`, `20m`) and the reason given. */
71
+ export function parseMoreTime(text: string): { minutes?: number; reason: string } {
72
+ const flat = text.replace(/^[-*]\s*/gm, "").replace(/\s+/g, " ").trim();
73
+ const match = /(\d+(?:\.\d+)?)\s*(h|hrs?|hours?|m|mins?|minutes?)\b/i.exec(flat);
74
+ const minutes = match ? Math.round(Number(match[1]) * (/^h/i.test(match[2]!) ? 60 : 1)) : undefined;
75
+ const reason = match ? flat.slice(match.index + match[0].length).replace(/^\s*(?:—|–|-|:|,|because|to)?\s*/i, "").trim() : flat;
76
+ return { ...(minutes && minutes > 0 ? { minutes } : {}), reason: reason || flat };
77
+ }
78
+
68
79
  /** Parse a worker's markdown into a structured result (never throws). */
69
80
  export function parseWorkerResult(domain: Domain, raw: string, now = new Date().toISOString()): WorkerResult {
70
81
  const sections = parseSections(raw);
@@ -72,6 +83,7 @@ export function parseWorkerResult(domain: Domain, raw: string, now = new Date().
72
83
  domain,
73
84
  role: "worker",
74
85
  completed: findSection(sections, "completed") ?? "",
86
+ ...leftOffFields(sections),
75
87
  filesChanged: bullets(findSection(sections, "files changed")).map((entry): FileChange => {
76
88
  const file = parseFileBullet(entry);
77
89
  return { path: file.path, change: file.reason };
@@ -87,6 +99,12 @@ export function parseWorkerResult(domain: Domain, raw: string, now = new Date().
87
99
  };
88
100
  }
89
101
 
102
+ function leftOffFields(sections: Map<string, string>): Pick<WorkerResult, "leftOff" | "moreTime"> {
103
+ const leftOff = findSection(sections, "left off")?.trim();
104
+ const more = findSection(sections, "more time")?.trim();
105
+ return { ...(leftOff ? { leftOff } : {}), ...(more ? { moreTime: parseMoreTime(more) } : {}) };
106
+ }
107
+
90
108
  /** Report contract deviations so the Master does not accept unverified work. */
91
109
  export function validateWorkerResult(result: WorkerResult): string[] {
92
110
  const issues: string[] = [];
@@ -66,6 +66,10 @@ export interface WorkflowConfig {
66
66
  wrapUpAt: number;
67
67
  /** Workers that may run at once when the Master delegates several domains together. */
68
68
  maxParallelWorkers: number;
69
+ /** The oracle starts each task with a clean context: its model is sent only the conversation since the task started, or since the last one ended. */
70
+ freshContext: boolean;
71
+ /** Minutes of work time a new task gets unless it is started with its own (`--budget`); 0 = no budget. */
72
+ taskBudgetMinutes: number;
69
73
  }
70
74
 
71
75
  export interface KnowledgeConfig {
@@ -201,6 +205,8 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
201
205
  toolStallTimeoutMs: 10 * 60 * 1000,
202
206
  wrapUpAt: 0.75,
203
207
  maxParallelWorkers: 3,
208
+ freshContext: true,
209
+ taskBudgetMinutes: 0,
204
210
  },
205
211
  knowledge: {
206
212
  compactionThreshold: 20000,
@@ -336,6 +342,8 @@ function normalizeClassifier(value: unknown): ClassifierConfig {
336
342
  export function resolveConfig(partial: unknown): BotLobbyConfig {
337
343
  const src = (partial ?? {}) as Record<string, unknown>;
338
344
  const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
345
+ workflow.freshContext = workflow.freshContext !== false;
346
+ workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
339
347
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
340
348
  const srcAgents = (src.agents ?? {}) as Partial<BotLobbyConfig["agents"]>;
341
349
  return {
@@ -42,6 +42,10 @@ export interface WorkerResult {
42
42
  domain: Domain;
43
43
  role: "worker";
44
44
  completed: string;
45
+ /** Stopped for time: what it was doing and what is still to do. */
46
+ leftOff?: string;
47
+ /** Stopped for time: the minutes it asks for, and why. */
48
+ moreTime?: { minutes?: number; reason: string };
45
49
  filesChanged: FileChange[];
46
50
  verification: string;
47
51
  notes: string;
@@ -69,6 +73,8 @@ export interface ReviewResult {
69
73
  pushback?: Pushback;
70
74
  /** Set when the engine downgraded an unsupported PASS. */
71
75
  downgraded?: string;
76
+ /** Set when the engine passed a CHANGES_REQUIRED that named only minor or info findings. */
77
+ relaxed?: string;
72
78
  raw: string;
73
79
  }
74
80
 
@@ -137,4 +143,12 @@ export interface AgentRun {
137
143
  routedFrom?: string;
138
144
  /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
139
145
  route?: string;
146
+ /** Files the run changed with `edit`/`write`, absolute, across its attempts. */
147
+ edited?: string[];
148
+ /** Under a task time budget: the time it was given, when that runs out (epoch ms), and any granted since. */
149
+ allotMs?: number;
150
+ endsAt?: number;
151
+ extendedMs?: number;
152
+ /** Stopped at its allotted time and not given more: its report says where it left off. */
153
+ timeUp?: boolean;
140
154
  }
@@ -114,6 +114,21 @@ export interface TaskTriage {
114
114
  at: string;
115
115
  }
116
116
 
117
+ /** What the working tree held when a task's agents started editing: its changed files, repository-relative, and the commit it stood on. */
118
+ export interface ChangeBaseline {
119
+ at: string;
120
+ files: string[];
121
+ /** HEAD when the first worker started: the QA gate reviews everything since, committed work included. */
122
+ head?: string;
123
+ }
124
+
125
+ /** The user accepted a task's work without a QA pass. */
126
+ export interface QaWaiver {
127
+ at: string;
128
+ /** What QA still asked for when the user accepted. */
129
+ open: string[];
130
+ }
131
+
117
132
  export interface Task {
118
133
  id: string;
119
134
  title: string;
@@ -145,6 +160,12 @@ export interface Task {
145
160
  archivedAt?: string;
146
161
  /** The classifier's read of the request, when it was on as the task started. */
147
162
  triage?: TaskTriage;
163
+ /** Files already changed when the first worker started: the QA gate reads them as pre-existing, not as this task's work. */
164
+ baseline?: ChangeBaseline;
165
+ /** Review rounds the user granted past `workflow.maxReviewIterations`. */
166
+ extraReviewRounds?: number;
167
+ /** Set only by the user: the work is accepted without a QA pass, so the task may complete. */
168
+ qaWaiver?: QaWaiver;
148
169
  }
149
170
 
150
171
  export function createTask(
@@ -6,11 +6,12 @@
6
6
  * abandoned before it is archived, so it never comes back half-driven.
7
7
  */
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join } from "node:path";
9
+ import { dirname, join, sep } from "node:path";
10
10
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
11
11
  import { taskDir, tasksRoot } from "../knowledge/paths.ts";
12
12
  import { transition } from "./task-state.ts";
13
13
  import { dataRoot, readDataRoots } from "./project.ts";
14
+ import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
14
15
 
15
16
  export function archiveRoot(root: string, configDir: string): string {
16
17
  return join(dataRoot(root, configDir), "archive", "tasks");
@@ -43,15 +44,23 @@ function archivedDir(root: string, configDir: string, taskId: string): string {
43
44
  return join(archiveRoot(root, configDir), checkId(taskId));
44
45
  }
45
46
 
46
- /** Archived tasks, most recently archived first. */
47
+ function isObject(value: unknown): value is Task {
48
+ return typeof value === "object" && value !== null;
49
+ }
50
+
51
+ /** Archived tasks, most recently archived first (parsed again only when a file changes; shared, for reading). */
47
52
  export function listArchivedTasks(root: string, configDir: string): Task[] {
48
53
  const dir = archiveRoot(root, configDir);
49
54
  if (!existsSync(dir)) return [];
50
55
  const tasks: Task[] = [];
56
+ const seen = new Set<string>();
51
57
  for (const entry of readdirSync(dir)) {
52
- const task = readAt(join(dir, entry));
58
+ const path = join(dir, entry, "state.json");
59
+ seen.add(path);
60
+ const task = readJsonCached(path, isObject);
53
61
  if (task) tasks.push(task);
54
62
  }
63
+ forgetCachedUnder(`${dir}${sep}`, seen);
55
64
  return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
56
65
  }
57
66