@a-t-h-i/bot-lobby 0.6.24 → 0.6.25
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 +8 -2
- package/package.json +1 -1
- package/prompts/designer.md +14 -0
- package/prompts/master.md +18 -0
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +17 -1
- package/prompts/qa.md +23 -3
- package/prompts/quickfix.md +4 -0
- package/prompts/reviewer.md +17 -0
- package/prompts/worker.md +21 -6
- package/src/ask/tool.ts +3 -3
- package/src/delivery/review.ts +1 -1
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/lobby/ask.ts +4 -2
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +19 -2
- package/src/lobby/service.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +3 -1
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/configuration.ts +51 -1
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/changes.ts +8 -2
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +1 -0
- package/src/webui/dev/fixtures.ts +2 -1
- package/src/webui/protocol.ts +10 -1
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +70 -13
- package/webui/dist/assets/index-BRVbs5In.js +105 -0
- package/webui/dist/assets/index-BeRXVi_r.css +2 -0
- package/webui/dist/build.json +40 -32
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BSewK1QO.css +0 -2
- package/webui/dist/assets/index-CHQYSZXA.js +0 -105
package/src/lobby/pr-review.ts
CHANGED
|
@@ -258,7 +258,7 @@ export class PullReviews {
|
|
|
258
258
|
|
|
259
259
|
private onEvent(review: PullReview, event: PiStreamEvent): void {
|
|
260
260
|
if (event.type === "tool_execution_start") this.step(review, describeToolCall(event.toolName, event.args));
|
|
261
|
-
else if (event.type === "thought") this.deps.feed?.
|
|
261
|
+
else if (event.type === "thought") this.deps.feed?.liveThought(REVIEW_SOURCE, event.text, `pr-${review.number}-${review.startedAt}`);
|
|
262
262
|
else if (event.type === "retry") this.step(review, `provider retry ${event.attempt}/${event.maxAttempts}`);
|
|
263
263
|
}
|
|
264
264
|
|
|
@@ -299,6 +299,7 @@ export class PullReviews {
|
|
|
299
299
|
const ok = review.status === "done";
|
|
300
300
|
const outcome = ok ? `reviewed #${review.number}${review.verdict ? `: ${review.verdict === "changes" ? "request changes" : review.verdict}` : ""}` : `review of #${review.number} ${review.status}${review.error ? ` — ${review.error.split("\n")[0]}` : ""}`;
|
|
301
301
|
this.deps.feed?.end(`pr-${review.number}-${review.startedAt}`, !ok);
|
|
302
|
+
this.deps.feed?.settleThought(`pr-${review.number}-${review.startedAt}`);
|
|
302
303
|
this.deps.feed?.log(REVIEW_SOURCE, outcome, ok ? "success" : review.status === "cancelled" ? "warning" : "error", review.finishedAt);
|
|
303
304
|
appendMetrics(this.deps.root, this.deps.configDir, [{
|
|
304
305
|
id: `review-${review.number}-${review.startedAt}`,
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -16,6 +16,12 @@ import type { FileHinter } from "../classifier/files.ts";
|
|
|
16
16
|
import type { Classifier } from "../classifier/classifier.ts";
|
|
17
17
|
import { quickFixSize } from "../classifier/triage.ts";
|
|
18
18
|
import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
21
|
+
import type { LintConfig } from "../schemas/configuration.ts";
|
|
22
|
+
import { repositoryTop } from "../execution/git.ts";
|
|
23
|
+
import type { LintExec } from "../execution/lint.ts";
|
|
24
|
+
import { lintFeed, runLintGate } from "../workflow/lint.ts";
|
|
19
25
|
|
|
20
26
|
/** A quick fix edits code, so it gets the full coding tool set. */
|
|
21
27
|
export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
@@ -97,6 +103,10 @@ export interface QuickFixDeps {
|
|
|
97
103
|
classifier?: Classifier;
|
|
98
104
|
/** Lowers thinking or the model for a request the classifier judges simple or trivial. */
|
|
99
105
|
effort?: EffortRouter;
|
|
106
|
+
/** The lint settings, read as each job finishes: a finished job's touched files are linted, for the activity log only. */
|
|
107
|
+
lint?: () => LintConfig;
|
|
108
|
+
/** Runs a linter (tests); the real one otherwise. */
|
|
109
|
+
lintExec?: LintExec;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
export const MAX_JOBS = 30;
|
|
@@ -222,7 +232,7 @@ export class QuickFixQueue {
|
|
|
222
232
|
if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
|
|
223
233
|
this.addStep(job, describeToolCall(event.toolName, event.args), now);
|
|
224
234
|
} else if (event.type === "turn_start") job.turns += 1;
|
|
225
|
-
else if (event.type === "thought") this.deps.feed?.
|
|
235
|
+
else if (event.type === "thought") this.deps.feed?.liveThought(QUICK_FIX_SOURCE, event.text, job.id, now);
|
|
226
236
|
else if (event.type === "retry") this.addStep(job, `provider retry ${event.attempt}/${event.maxAttempts}`, now);
|
|
227
237
|
else return;
|
|
228
238
|
this.changed();
|
|
@@ -360,14 +370,37 @@ export class QuickFixQueue {
|
|
|
360
370
|
});
|
|
361
371
|
}
|
|
362
372
|
this.deps.feed?.end(job.id, job.status !== "success");
|
|
373
|
+
this.deps.feed?.settleThought(job.id);
|
|
363
374
|
const ok = job.status === "success";
|
|
364
375
|
const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
|
|
365
376
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
|
|
366
377
|
appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
|
|
367
378
|
this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
|
|
379
|
+
if (ok && edited.length > 0) void this.lintAfter(job, edited);
|
|
368
380
|
this.changed();
|
|
369
381
|
this.pump();
|
|
370
382
|
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The lint gate's pass over what a quick fix touched (only problems on the
|
|
386
|
+
* lines it changed are its own): a line in the activity log, never a hold,
|
|
387
|
+
* as a quick fix has no gate. Lint failing to run says nothing.
|
|
388
|
+
*/
|
|
389
|
+
private async lintAfter(job: QuickFixJob, edited: readonly string[]): Promise<void> {
|
|
390
|
+
const config = this.deps.lint?.();
|
|
391
|
+
if (!config || config.mode === "off") return;
|
|
392
|
+
try {
|
|
393
|
+
const top = await repositoryTop(this.deps.cwd);
|
|
394
|
+
if (!top) return;
|
|
395
|
+
const files = edited.map((file) => relative(top, file).split("\\").join("/")).filter((file) => file && !file.startsWith("..") && !isAbsolute(file) && existsSync(join(top, file)));
|
|
396
|
+
const report = await runLintGate({ top, files, config, ...(this.deps.lintExec ? { exec: this.deps.lintExec } : {}) });
|
|
397
|
+
if (report.state === "skipped") return;
|
|
398
|
+
const line = lintFeed(job.id, report);
|
|
399
|
+
this.deps.feed?.log("LINT", line.text, line.kind);
|
|
400
|
+
} catch {
|
|
401
|
+
// Advisory: a quick fix is never held or failed for lint.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
371
404
|
}
|
|
372
405
|
|
|
373
406
|
export function quickFixMetric(job: QuickFixJob): MetricRecord {
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
9
9
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
10
10
|
import { appendMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
11
11
|
import { describeToolCall } from "../pi/activity.ts";
|
|
12
|
-
import { currentZenTask, onRunUpdates } from "../pi/ui.ts";
|
|
12
|
+
import { currentZenTask, isMinimized, onRunUpdates } from "../pi/ui.ts";
|
|
13
13
|
import type { WorkspaceInfo } from "../execution/workspace.ts";
|
|
14
14
|
import { basename } from "node:path";
|
|
15
15
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
@@ -77,6 +77,21 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
77
77
|
return serviceAnswerPanel(state);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** What the lobby is running now (a quick fix, a review, a planning round), in words, or undefined. */
|
|
81
|
+
export function lobbyWorkRunning(): string | undefined {
|
|
82
|
+
const state = runtime;
|
|
83
|
+
if (!state) return undefined;
|
|
84
|
+
if (state.quickfix.running) return "A quick fix";
|
|
85
|
+
if (state.planner?.busy) return "A planning round";
|
|
86
|
+
if (state.reviews.busy) return "A pull request review";
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stop the lobby's backend (bot-lobby turned off, or the session ended). */
|
|
91
|
+
export function stopLobbyService(): void {
|
|
92
|
+
shutdown();
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
function shutdown(): void {
|
|
81
96
|
const state = runtime;
|
|
82
97
|
if (!state) return;
|
|
@@ -165,6 +180,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
|
|
|
165
180
|
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
166
181
|
classifier: classifier(),
|
|
167
182
|
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
183
|
+
lint: () => loadConfig().lint,
|
|
168
184
|
});
|
|
169
185
|
runtime = state;
|
|
170
186
|
lobbyService = createLobbyService(state);
|
|
@@ -233,7 +249,8 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
233
249
|
if (runtime) runtime.ctx = ctx;
|
|
234
250
|
};
|
|
235
251
|
pi.on("session_start", (_event, ctx) => {
|
|
236
|
-
|
|
252
|
+
// Off (the default for a new session) runs no lobby until bot-lobby is turned on (see pi/switch.ts).
|
|
253
|
+
if (!isMinimized()) startLobbyService(pi, ctx, configDir);
|
|
237
254
|
});
|
|
238
255
|
pi.on("session_shutdown", () => shutdown());
|
|
239
256
|
pi.on("ui_prompt_start", () => {
|
package/src/lobby/service.ts
CHANGED
|
@@ -47,6 +47,7 @@ import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
|
|
|
47
47
|
import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
|
|
48
48
|
import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
|
|
49
49
|
import type { Runtime } from "./runtime.ts";
|
|
50
|
+
import { findLinters } from "../execution/lint.ts";
|
|
50
51
|
|
|
51
52
|
function deliveryTask(state: Runtime, taskId: string): Task {
|
|
52
53
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
|
|
@@ -562,6 +563,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
562
563
|
let appliedMaster = masterKey();
|
|
563
564
|
return {
|
|
564
565
|
projectRoot: () => state.root,
|
|
566
|
+
linters: () => findLinters(state.root),
|
|
565
567
|
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
566
568
|
zen: () => {
|
|
567
569
|
const snapshot = taskSnapshot();
|
package/src/master/master.ts
CHANGED
|
@@ -342,6 +342,8 @@ export interface ReviewerRequest {
|
|
|
342
342
|
provenance?: string;
|
|
343
343
|
/** What the previous QA round asked for, when there was one: this round verifies it. */
|
|
344
344
|
previousRound?: string;
|
|
345
|
+
/** The engine's lint result on the touched files and the suppressions the task added. */
|
|
346
|
+
lint?: string;
|
|
345
347
|
/** Under a task time budget: the gate's time. */
|
|
346
348
|
time?: AgentTime;
|
|
347
349
|
instruction?: string;
|
|
@@ -367,6 +369,7 @@ function reviewerContext(request: ReviewerRequest): string {
|
|
|
367
369
|
request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
|
|
368
370
|
owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
|
|
369
371
|
request.provenance ? `Who changed each file (bot-lobby's record of every agent's edit and write calls; judge each as your role's Change provenance says):\n${request.provenance}` : "",
|
|
372
|
+
request.lint ?? "",
|
|
370
373
|
request.time?.note ?? "",
|
|
371
374
|
`Repository changes:\n${request.diff}`,
|
|
372
375
|
]
|
package/src/pi/commands.ts
CHANGED
|
@@ -17,8 +17,9 @@ import { transition } from "../state/task-state.ts";
|
|
|
17
17
|
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
18
18
|
import { readFirstExisting } from "../knowledge/store.ts";
|
|
19
19
|
import { overThreshold } from "../knowledge/compactor.ts";
|
|
20
|
-
import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
|
|
21
|
-
import { applyStatus, registerRevealShortcut
|
|
20
|
+
import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveLint, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
|
|
21
|
+
import { applyStatus, registerRevealShortcut } from "./ui.ts";
|
|
22
|
+
import { isOn, registerSwitch, turnOff, turnOn } from "./switch.ts";
|
|
22
23
|
import { classifierSummary } from "./model-settings.ts";
|
|
23
24
|
import { keyStatus } from "../classifier/instance.ts";
|
|
24
25
|
import { kickoff, startPlannedTask } from "./start-task.ts";
|
|
@@ -51,7 +52,7 @@ const HELP = [
|
|
|
51
52
|
"/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
|
|
52
53
|
"/bot-lobby settings Open the web lobby's Settings: per-agent model, effort and instructions",
|
|
53
54
|
"/bot-lobby config Show effective configuration",
|
|
54
|
-
"/bot-lobby
|
|
55
|
+
"/bot-lobby on|off Turn bot-lobby on or off for this session (ctrl+shift+m); a new session starts off",
|
|
55
56
|
"/bot-lobby claim <taskId> Take ownership of an orphaned task",
|
|
56
57
|
"/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
|
|
57
58
|
"/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
|
|
@@ -61,7 +62,10 @@ const HELP = [
|
|
|
61
62
|
].join("\n");
|
|
62
63
|
|
|
63
64
|
/** Subcommands only win when no free-form text follows (so tasks still start). */
|
|
64
|
-
const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
|
|
65
|
+
const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
|
|
66
|
+
|
|
67
|
+
/** Subcommands that act on a task or open the lobby turn bot-lobby on first; the rest work while it is off. */
|
|
68
|
+
const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "auto", "start-plan", "switch", "web"]);
|
|
65
69
|
|
|
66
70
|
/** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
|
|
67
71
|
function isTaskId(value: string | undefined): boolean {
|
|
@@ -241,10 +245,12 @@ function acceptWork(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: s
|
|
|
241
245
|
return ctx.ui.notify(`bot-lobby: ${task.id} is still ${task.state}; there is no work to accept yet.`, "warning");
|
|
242
246
|
}
|
|
243
247
|
if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, lastQaAsks(task), "with /bot-lobby accept");
|
|
248
|
+
if (task.lint?.state === "failing") waiveLint(task, "with /bot-lobby accept");
|
|
244
249
|
saveTask(root, configDir, task);
|
|
245
250
|
applyStatus(ctx, root, configDir);
|
|
246
251
|
ctx.ui.notify(`bot-lobby: ${task.id} accepted as it is; the oracle will complete it.`, "info");
|
|
247
|
-
const
|
|
252
|
+
const waived = [task.qaWaiver ? "the QA gate" : "", task.lintWaiver ? "the lint gate" : ""].filter(Boolean);
|
|
253
|
+
const message = `The user accepted ${task.id}'s work as it is${waived.length ? ` (${waived.join(" and ")} ${waived.length === 1 ? "is" : "are"} waived)` : ""}. Call orchestrate action=complete now with a short summary that names anything QA or lint still asked for.`;
|
|
248
254
|
pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
249
255
|
}
|
|
250
256
|
|
|
@@ -323,6 +329,7 @@ function autoCommand(ctx: ExtensionCommandContext, configDir: string, value: str
|
|
|
323
329
|
|
|
324
330
|
export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
325
331
|
registerRevealShortcut(pi, configDir);
|
|
332
|
+
registerSwitch(pi, configDir);
|
|
326
333
|
registerWebServer(pi);
|
|
327
334
|
pi.registerCommand("bot-lobby", {
|
|
328
335
|
description: "Structured multi-agent engineering orchestrator",
|
|
@@ -334,6 +341,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
|
334
341
|
handler: async (args, ctx) => {
|
|
335
342
|
const { sub, rest, restText, auto, budget, budgetError, track, task, isolation } = parseCommand(args ?? "");
|
|
336
343
|
if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
|
|
344
|
+
// A request, the bare command or anything that drives a task needs bot-lobby on (`web stop` does not).
|
|
345
|
+
if (!sub || (TURNS_ON.has(sub) && !(sub === "web" && rest[0] === "stop"))) turnOn(pi, ctx, configDir);
|
|
337
346
|
if (!sub) {
|
|
338
347
|
if (!restText) return webCommand(ctx, undefined);
|
|
339
348
|
// A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
|
|
@@ -371,12 +380,15 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
|
371
380
|
return showKnowledge(ctx, configDir);
|
|
372
381
|
case "settings":
|
|
373
382
|
return webCommand(ctx, undefined);
|
|
383
|
+
case "off":
|
|
374
384
|
case "minimize":
|
|
375
|
-
|
|
376
|
-
|
|
385
|
+
if (!isOn()) return ctx.ui.notify("bot-lobby is already off.", "info");
|
|
386
|
+
await turnOff(ctx, configDir);
|
|
387
|
+
return;
|
|
388
|
+
case "on":
|
|
377
389
|
case "restore":
|
|
378
|
-
|
|
379
|
-
return
|
|
390
|
+
if (isOn()) return ctx.ui.notify("bot-lobby is already on.", "info");
|
|
391
|
+
return turnOn(pi, ctx, configDir);
|
|
380
392
|
case "claim":
|
|
381
393
|
return claimTaskCommand(ctx, configDir, rest[0]);
|
|
382
394
|
case "auto":
|
|
@@ -398,6 +410,9 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
|
398
410
|
|
|
399
411
|
pi.registerCommand("bot-lobby-settings", {
|
|
400
412
|
description: "Open the web lobby's settings: per-agent model, effort, and instructions",
|
|
401
|
-
handler: async (_args, ctx) =>
|
|
413
|
+
handler: async (_args, ctx) => {
|
|
414
|
+
turnOn(pi, ctx, configDir);
|
|
415
|
+
return webCommand(ctx, undefined);
|
|
416
|
+
},
|
|
402
417
|
});
|
|
403
418
|
}
|
package/src/pi/events.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { cancelAllRuns } from "../execution/agent-runner.ts";
|
|
|
8
8
|
import { describeTask } from "../workflow/workflow.ts";
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
10
|
import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
|
|
11
|
+
import { startsOn } from "./switch.ts";
|
|
11
12
|
import { isSubagentProcess, webToolsFor } from "./quiet.ts";
|
|
12
13
|
import { registerQuietTools } from "./tool-renderers.ts";
|
|
13
14
|
import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
|
|
@@ -70,7 +71,8 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
|
70
71
|
// Master-only: subagents keep their own --tools allowlist (see quiet.ts).
|
|
71
72
|
if (!isSubagentProcess()) registerQuietTools(pi);
|
|
72
73
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
73
|
-
|
|
74
|
+
// A new session starts with bot-lobby off unless settings or its own task say otherwise (switch.ts).
|
|
75
|
+
setMinimized(!isSubagentProcess() && !startsOn(ctx, configDir));
|
|
74
76
|
applyStatus(ctx, root, configDir);
|
|
75
77
|
});
|
|
76
78
|
|
package/src/pi/switch.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot-lobby on or off for this pi session. A session starts off, so pi is
|
|
3
|
+
* plain pi: no oracle prompt, no web lobby, no browser opening. It starts on
|
|
4
|
+
* only when `lobby.startOn` says so, or when the session already drives a task
|
|
5
|
+
* that is still under way (a resumed session picks its task back up).
|
|
6
|
+
* Ctrl+Shift+M and `/bot-lobby on|off` switch it; starting a task, opening the
|
|
7
|
+
* web lobby or answering a task's proposal turns it on. Off stops the web
|
|
8
|
+
* lobby too, and anything it runs (a quick fix, a review, a planning round)
|
|
9
|
+
* stops with it, so the user is asked first when something is running.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
13
|
+
import { currentLobbyService, lobbyWorkRunning, servesPage, startLobbyService, stopLobbyService } from "../lobby/runtime.ts";
|
|
14
|
+
import { activeTask } from "../state/persistence.ts";
|
|
15
|
+
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
16
|
+
import { stopWebServer, webSessionStarted } from "../webui/command.ts";
|
|
17
|
+
import { isSubagentProcess } from "./quiet.ts";
|
|
18
|
+
import { applyStatus, isMinimized, setMinimized } from "./ui.ts";
|
|
19
|
+
|
|
20
|
+
export const SWITCH_KEY = "ctrl+shift+m";
|
|
21
|
+
|
|
22
|
+
export function isOn(): boolean {
|
|
23
|
+
return !isMinimized();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a new session starts on: when settings say so, or it owns a task still under way. */
|
|
27
|
+
export function startsOn(ctx: ExtensionContext, configDir: string): boolean {
|
|
28
|
+
if (loadConfig().lobby.startOn) return true;
|
|
29
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
30
|
+
return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Turn bot-lobby on: the oracle's prompt comes back and the web lobby starts (opening the browser if settings say so). */
|
|
34
|
+
export function turnOn(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, quiet = false): void {
|
|
35
|
+
const was = isOn();
|
|
36
|
+
setMinimized(false);
|
|
37
|
+
if (!isSubagentProcess() && servesPage(ctx) && !currentLobbyService()) {
|
|
38
|
+
startLobbyService(pi, ctx, configDir);
|
|
39
|
+
webSessionStarted(ctx);
|
|
40
|
+
}
|
|
41
|
+
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
|
|
42
|
+
if (!was && !quiet) ctx.ui.notify(`bot-lobby on — ${SWITCH_KEY} or /bot-lobby off turns it off`, "info");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Turn bot-lobby off: pi is plain pi until it is turned on again. Returns false when the user kept it on. */
|
|
46
|
+
export async function turnOff(ctx: ExtensionContext, configDir: string): Promise<boolean> {
|
|
47
|
+
const running = lobbyWorkRunning();
|
|
48
|
+
if (running && ctx.hasUI && !(await ctx.ui.confirm("Turn bot-lobby off?", `${running} is running in the lobby and stops with it.`))) return false;
|
|
49
|
+
setMinimized(true);
|
|
50
|
+
stopLobbyService();
|
|
51
|
+
await stopWebServer(ctx);
|
|
52
|
+
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
|
|
53
|
+
ctx.ui.notify(`bot-lobby off — pi is plain pi; ${SWITCH_KEY} or /bot-lobby on turns it on`, "info");
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function toggleBotLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Promise<void> {
|
|
58
|
+
if (isOn()) await turnOff(ctx, configDir);
|
|
59
|
+
else turnOn(pi, ctx, configDir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ctrl+Shift+M switches bot-lobby on and off for this session. */
|
|
63
|
+
export function registerSwitch(pi: ExtensionAPI, configDir: string): void {
|
|
64
|
+
if (isSubagentProcess()) return;
|
|
65
|
+
pi.registerShortcut(Key.ctrlShift("m"), {
|
|
66
|
+
description: "bot-lobby: turn bot-lobby on or off for this session",
|
|
67
|
+
handler: (ctx) => toggleBotLobby(pi, ctx, configDir),
|
|
68
|
+
});
|
|
69
|
+
}
|
package/src/pi/tools.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { unescapeBreaks } from "../lobby/blocks.ts";
|
|
|
15
15
|
import { isQuiet } from "./quiet.ts";
|
|
16
16
|
import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
17
17
|
import { agentName, describeRun } from "./run-summary.ts";
|
|
18
|
+
import { lintFeed } from "../workflow/lint.ts";
|
|
19
|
+
import { lobbyFeed } from "../lobby/feed.ts";
|
|
18
20
|
import {
|
|
19
21
|
ORCHESTRATE_ACTIONS,
|
|
20
22
|
runWorkflowAction,
|
|
@@ -133,6 +135,11 @@ export function workflowDeps(
|
|
|
133
135
|
notify: (message, level = "info") => {
|
|
134
136
|
if (hasUI) ctx.ui.notify(message, level);
|
|
135
137
|
},
|
|
138
|
+
// Each new lint result on a task's touched files is a line in the lobby's activity log.
|
|
139
|
+
onLint: (taskId, report) => {
|
|
140
|
+
const line = lintFeed(taskId, report);
|
|
141
|
+
lobbyFeed.log("LINT", line.text, line.kind);
|
|
142
|
+
},
|
|
136
143
|
};
|
|
137
144
|
}
|
|
138
145
|
|
package/src/pi/ui.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Key } from "@earendil-works/pi-tui";
|
|
3
2
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
4
3
|
import type { Task } from "../schemas/task.ts";
|
|
5
4
|
import { activeTask } from "../state/persistence.ts";
|
|
@@ -17,7 +16,7 @@ export function summarizeRun(run: AgentRun): string {
|
|
|
17
16
|
/** One-line footer text, always carrying the quiet-mode hint. */
|
|
18
17
|
export function statusText(task: Task | undefined, minimized = false): string {
|
|
19
18
|
const mode = isQuiet() ? "tools hidden (alt+t)" : "tools shown";
|
|
20
|
-
if (minimized) return `bot-lobby
|
|
19
|
+
if (minimized) return `bot-lobby off (ctrl+shift+m) · ${mode}`;
|
|
21
20
|
if (!task) return `bot-lobby · ${mode}`;
|
|
22
21
|
return `bot-lobby ${task.id} · ${task.paused ? `${task.state} (paused)` : task.state} · ${mode}`;
|
|
23
22
|
}
|
|
@@ -73,7 +72,7 @@ function setZenState(task: Task | undefined, live: AgentRun[]): void {
|
|
|
73
72
|
zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
/**
|
|
75
|
+
/** Bot-lobby off for this session (plain pi): the Master prompt is hidden but ownership stays (see switch.ts). */
|
|
77
76
|
let minimized = false;
|
|
78
77
|
|
|
79
78
|
export function isMinimized(): boolean {
|
|
@@ -84,13 +83,6 @@ export function setMinimized(value: boolean): void {
|
|
|
84
83
|
minimized = value;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
/** Flip minimize/restore and refresh the footer; the session is unchanged. */
|
|
88
|
-
export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
|
|
89
|
-
setMinimized(!minimized);
|
|
90
|
-
applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
|
|
91
|
-
ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
86
|
/**
|
|
95
87
|
* Refresh the footer to match the task on disk. Runs are merged into the
|
|
96
88
|
* retained set for the same task because each `orchestrate` call reports only its
|
|
@@ -144,10 +136,6 @@ export function registerRevealShortcut(pi: ExtensionAPI, configDir: string): voi
|
|
|
144
136
|
description: "bot-lobby: reveal or hide built-in tool rows",
|
|
145
137
|
handler: (ctx) => revealTools(ctx, configDir),
|
|
146
138
|
});
|
|
147
|
-
pi.registerShortcut(Key.ctrlShift("m"), {
|
|
148
|
-
description: "bot-lobby: minimize or restore bot-lobby for this session",
|
|
149
|
-
handler: (ctx) => toggleMinimized(ctx, configDir),
|
|
150
|
-
});
|
|
151
139
|
}
|
|
152
140
|
|
|
153
141
|
function revealTools(ctx: ExtensionContext, configDir: string): void {
|
|
@@ -92,6 +92,29 @@ export interface WorkflowConfig {
|
|
|
92
92
|
gitIsolation: GitIsolation;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The lint gate: off; advise (the oracle and QA see what the project's own
|
|
97
|
+
* linter finds in the files the task's agents touched); or block (errors on
|
|
98
|
+
* the lines the task changed also hold completion until they are fixed or
|
|
99
|
+
* the user accepts the work as it is).
|
|
100
|
+
*/
|
|
101
|
+
export const LINT_MODES = ["off", "advise", "block"] as const;
|
|
102
|
+
export type LintMode = (typeof LINT_MODES)[number];
|
|
103
|
+
|
|
104
|
+
export function isLintMode(value: unknown): value is LintMode {
|
|
105
|
+
return typeof value === "string" && (LINT_MODES as readonly string[]).includes(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface LintConfig {
|
|
109
|
+
mode: LintMode;
|
|
110
|
+
/** A command of your own, run without a shell from the repository's top; `{files}` stands for the touched files (they are appended when it is absent). Empty: the project's own linters are found (ESLint, Biome, Oxlint, Ruff). */
|
|
111
|
+
command: string;
|
|
112
|
+
/** Which touched files the command is given, by extension (`.ts`, `.py`); empty gives it every one. */
|
|
113
|
+
extensions: string[];
|
|
114
|
+
/** Time limit for one linter run. */
|
|
115
|
+
timeoutMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
export interface KnowledgeConfig {
|
|
96
119
|
compactionThreshold: number;
|
|
97
120
|
backupCount: number;
|
|
@@ -128,6 +151,8 @@ export interface LobbyConfig {
|
|
|
128
151
|
planningPanel: PanelMember[];
|
|
129
152
|
/** Show the GitHub Issues tab (off for now). */
|
|
130
153
|
issues: boolean;
|
|
154
|
+
/** Bot-lobby is on when pi starts; off (the default), pi starts as plain pi until ctrl+shift+m or /bot-lobby on. */
|
|
155
|
+
startOn: boolean;
|
|
131
156
|
/** Which panes the Lobby tab shows. */
|
|
132
157
|
panels: Record<LobbyPanel, boolean>;
|
|
133
158
|
/** Key overrides by action name, e.g. `{ "search": "alt+f" }`. */
|
|
@@ -208,6 +233,8 @@ export interface BotLobbyConfig {
|
|
|
208
233
|
/** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
|
|
209
234
|
planner: AgentModelConfig;
|
|
210
235
|
workflow: WorkflowConfig;
|
|
236
|
+
/** The engine lints the files a task's agents touched, after each step, before QA and at completion. */
|
|
237
|
+
lint: LintConfig;
|
|
211
238
|
knowledge: KnowledgeConfig;
|
|
212
239
|
lobby: LobbyConfig;
|
|
213
240
|
classifier: ClassifierConfig;
|
|
@@ -243,6 +270,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
243
270
|
routeQuickFixes: true,
|
|
244
271
|
gitIsolation: "off",
|
|
245
272
|
},
|
|
273
|
+
lint: { mode: "advise", command: "", extensions: [], timeoutMs: 2 * 60 * 1000 },
|
|
246
274
|
knowledge: {
|
|
247
275
|
compactionThreshold: 20000,
|
|
248
276
|
backupCount: 1,
|
|
@@ -252,6 +280,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
252
280
|
lobby: {
|
|
253
281
|
planningPanel: [...PANEL_MEMBERS],
|
|
254
282
|
issues: false,
|
|
283
|
+
startOn: false,
|
|
255
284
|
panels: { conversation: true, activity: true, thinking: true },
|
|
256
285
|
keys: {},
|
|
257
286
|
maxPlanningRounds: 5,
|
|
@@ -315,7 +344,7 @@ function flag(value: unknown, fallback: boolean): boolean {
|
|
|
315
344
|
}
|
|
316
345
|
|
|
317
346
|
function normalizeLobby(value: unknown): LobbyConfig {
|
|
318
|
-
const source = value as { planningPanel?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
|
|
347
|
+
const source = value as { planningPanel?: unknown; issues?: unknown; startOn?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
|
|
319
348
|
const defaults = DEFAULT_CONFIG.lobby;
|
|
320
349
|
const panel = Array.isArray(source?.planningPanel)
|
|
321
350
|
? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
|
|
@@ -325,6 +354,7 @@ function normalizeLobby(value: unknown): LobbyConfig {
|
|
|
325
354
|
return {
|
|
326
355
|
planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
|
|
327
356
|
issues: flag(source?.issues, defaults.issues),
|
|
357
|
+
startOn: flag(source?.startOn, defaults.startOn),
|
|
328
358
|
panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
|
|
329
359
|
keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
|
|
330
360
|
maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
|
|
@@ -362,6 +392,25 @@ function text(value: unknown, fallback: string): string {
|
|
|
362
392
|
return typeof value === "string" ? value.trim() : fallback;
|
|
363
393
|
}
|
|
364
394
|
|
|
395
|
+
/** `.ts`, `TSX` and `*.py` all read as `.ts`, `.tsx`, `.py`. */
|
|
396
|
+
function extension(value: string): string {
|
|
397
|
+
const bare = value.trim().replace(/^\*/, "").replace(/^\./, "").toLowerCase();
|
|
398
|
+
return bare ? `.${bare}` : "";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizeLint(value: unknown): LintConfig {
|
|
402
|
+
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
403
|
+
const defaults = DEFAULT_CONFIG.lint;
|
|
404
|
+
const extensions = Array.isArray(source.extensions) ? source.extensions.filter((entry): entry is string => typeof entry === "string").map(extension).filter(Boolean) : [...defaults.extensions];
|
|
405
|
+
const timeout = positive(source.timeoutMs);
|
|
406
|
+
return {
|
|
407
|
+
mode: isLintMode(source.mode) ? source.mode : defaults.mode,
|
|
408
|
+
command: text(source.command, defaults.command),
|
|
409
|
+
extensions: [...new Set(extensions)],
|
|
410
|
+
timeoutMs: timeout ? Math.min(30 * 60 * 1000, Math.round(timeout)) : defaults.timeoutMs,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
365
414
|
function normalizeClassifier(value: unknown): ClassifierConfig {
|
|
366
415
|
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
367
416
|
const defaults = DEFAULT_CONFIG.classifier;
|
|
@@ -412,6 +461,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
|
412
461
|
quickFix: normalizeAgent(DEFAULT_CONFIG.quickFix, src.quickFix as Partial<AgentModelConfig> | undefined),
|
|
413
462
|
planner: normalizeAgent(DEFAULT_CONFIG.planner, src.planner as Partial<AgentModelConfig> | undefined),
|
|
414
463
|
workflow,
|
|
464
|
+
lint: normalizeLint(src.lint),
|
|
415
465
|
knowledge,
|
|
416
466
|
lobby: normalizeLobby(src.lobby),
|
|
417
467
|
classifier: normalizeClassifier(src.classifier),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the lint gate found: per linter run, and for the task as a whole. The
|
|
3
|
+
* engine records the last report on the task, so completion, QA and the
|
|
4
|
+
* lobby read the same result, and an unchanged tree is not linted twice.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LintState = "passing" | "failing" | "unavailable" | "skipped";
|
|
8
|
+
|
|
9
|
+
export interface LintFinding {
|
|
10
|
+
/** Repository-relative, with forward slashes. */
|
|
11
|
+
file: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
column?: number;
|
|
14
|
+
rule?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
severity: "error" | "warning";
|
|
17
|
+
/** On a line the task changed (or in a file it created); false for what was already there. */
|
|
18
|
+
fresh?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LintRun {
|
|
22
|
+
/** ESLint, Biome, Oxlint, Ruff, or the command from Settings. */
|
|
23
|
+
tool: string;
|
|
24
|
+
/** The folder it ran in, repository-relative ("" at the top). */
|
|
25
|
+
folder: string;
|
|
26
|
+
files: string[];
|
|
27
|
+
state: "passing" | "failing" | "unavailable";
|
|
28
|
+
errors: number;
|
|
29
|
+
warnings: number;
|
|
30
|
+
/** At most `MAX_FINDINGS` of them, those on changed lines first; the counts stay whole. */
|
|
31
|
+
findings: LintFinding[];
|
|
32
|
+
/** The tail of what it printed when that could not be read as findings, or why it could not run. */
|
|
33
|
+
output?: string;
|
|
34
|
+
ms: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LintReport {
|
|
38
|
+
at: string;
|
|
39
|
+
/** failing: errors on lines the task changed; passing: none (what was already there never fails it). */
|
|
40
|
+
state: LintState;
|
|
41
|
+
/** The touched files it covered, repository-relative. */
|
|
42
|
+
files: string[];
|
|
43
|
+
runs: LintRun[];
|
|
44
|
+
errors: number;
|
|
45
|
+
warnings: number;
|
|
46
|
+
/** Of those, on lines the task changed or in files it created. */
|
|
47
|
+
freshErrors: number;
|
|
48
|
+
freshWarnings: number;
|
|
49
|
+
/** Lint suppressions and lint-config changes the task added, one line each, for QA to judge. */
|
|
50
|
+
suppressions: string[];
|
|
51
|
+
/** Why nothing ran, when nothing did. */
|
|
52
|
+
note?: string;
|
|
53
|
+
/** The touched files as they stood and the settings: an equal key means an equal result. */
|
|
54
|
+
fingerprint: string;
|
|
55
|
+
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LintReport } from "./lint.ts";
|
|
1
2
|
import type { Domain, Role } from "./agent.ts";
|
|
2
3
|
import type { Delivery } from "../delivery/types.ts";
|
|
3
4
|
|
|
@@ -224,6 +225,10 @@ export interface Task {
|
|
|
224
225
|
qaWaiver?: QaWaiver;
|
|
225
226
|
/** The git branch (or worktree) made for this task, when isolation was on as it started. */
|
|
226
227
|
git?: TaskGit;
|
|
228
|
+
/** The lint gate's last report on the files this task's agents touched. */
|
|
229
|
+
lint?: LintReport;
|
|
230
|
+
/** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
|
|
231
|
+
lintWaiver?: { at: string };
|
|
227
232
|
}
|
|
228
233
|
|
|
229
234
|
export function createTask(
|
package/src/state/changes.ts
CHANGED
|
@@ -142,6 +142,8 @@ export interface FileProvenance {
|
|
|
142
142
|
notes: string[];
|
|
143
143
|
/** The quick fixes that edited it. */
|
|
144
144
|
quickFixes: string[];
|
|
145
|
+
/** This task's domains whose workers edited it. */
|
|
146
|
+
domains?: Domain[];
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
const DOMAIN_LABELS: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
|
@@ -180,6 +182,7 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
|
|
|
180
182
|
const kinds: Provenance[] = [];
|
|
181
183
|
const notes: string[] = [];
|
|
182
184
|
const quickFixes: string[] = [];
|
|
185
|
+
const domains: Domain[] = [];
|
|
183
186
|
const add = (kind: Provenance, note: string) => {
|
|
184
187
|
if (!kinds.includes(kind)) kinds.push(kind);
|
|
185
188
|
if (!notes.includes(note)) notes.push(note);
|
|
@@ -188,12 +191,15 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
|
|
|
188
191
|
if (record.source === "quickfix") {
|
|
189
192
|
add("quickfix", `quick fix ${record.id}${clock(record.finishedAt)}, asked by the user: "${clip(record.what)}"`);
|
|
190
193
|
if (!quickFixes.includes(record.id)) quickFixes.push(record.id);
|
|
191
|
-
} else if (record.taskId === task.id)
|
|
194
|
+
} else if (record.taskId === task.id) {
|
|
195
|
+
add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
|
|
196
|
+
if (record.domain && !domains.includes(record.domain)) domains.push(record.domain);
|
|
197
|
+
}
|
|
192
198
|
else add("other-task", `another task's worker (${record.taskId ?? "unknown task"}): ${clip(record.what)}`);
|
|
193
199
|
}
|
|
194
200
|
if (before.has(path)) add("pre-existing", "pre-existing: it already had uncommitted changes before this task's agents started");
|
|
195
201
|
if (kinds.length === 0) add("unattributed", "unattributed: no bot-lobby agent recorded editing it");
|
|
196
|
-
return { path, kinds, notes, quickFixes };
|
|
202
|
+
return { path, kinds, notes, quickFixes, ...(domains.length ? { domains } : {}) };
|
|
197
203
|
});
|
|
198
204
|
}
|
|
199
205
|
|