@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.
- package/README.md +112 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +38 -18
- package/src/lobby/markdown.ts +92 -19
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +51 -37
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +289 -67
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +141 -25
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +54 -12
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +11 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +9 -3
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +22 -15
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +8 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/width.ts +102 -0
- 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
|
|
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),
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
258
|
-
const
|
|
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));
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 -
|
|
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 -
|
|
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(
|
|
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 =
|
|
298
|
-
const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 -
|
|
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 =
|
|
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(
|
|
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 +
|
|
664
|
+
cursor = cell.offset + textWidth(cell.text);
|
|
665
665
|
}
|
|
666
666
|
return row + " ".repeat(Math.max(0, end - cursor));
|
|
667
667
|
}
|
package/src/pi/zen-metrics.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 [
|
|
407
|
+
if (width < BANNER_MIN_WIDTH) return [clip(BANNER_NARROW, width, "")];
|
|
403
408
|
const inner = BANNER_WIDTH - 2;
|
|
404
|
-
const titleWidth =
|
|
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 =
|
|
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) =>
|
|
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) =>
|
|
656
|
+
return scene.map((line) => clip(line, width));
|
|
650
657
|
}
|
|
651
658
|
return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
|
|
652
659
|
}
|
package/src/roles/reviewer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/roles/worker.ts
CHANGED
|
@@ -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 {
|
package/src/schemas/findings.ts
CHANGED
|
@@ -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
|
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -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(
|
package/src/state/archive.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|