@a-t-h-i/bot-lobby 0.6.26 → 0.6.28
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 +36 -11
- package/package.json +1 -1
- package/prompts/master.md +24 -3
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/qa-risk.ts +537 -0
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +145 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +10 -6
- package/src/pi/start-task.ts +31 -4
- package/src/pi/switch.ts +7 -2
- package/src/pi/tools.ts +3 -0
- package/src/pi/ui.ts +9 -2
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +36 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +59 -0
- package/src/webui/server.ts +10 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +51 -35
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
- package/webui/dist/assets/index-C4Ww36TW.js +0 -105
package/src/pi/commands.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { hasScoutThinking, SCOUT_THINKING } from "../schemas/configuration.ts";
|
|
|
7
7
|
import {
|
|
8
8
|
activeTask,
|
|
9
9
|
claimTask,
|
|
10
|
+
loadTask,
|
|
10
11
|
ownedTask,
|
|
11
12
|
ownerlessTask,
|
|
12
13
|
saveTask,
|
|
@@ -33,6 +34,8 @@ import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startCloc
|
|
|
33
34
|
import { qaStillDue } from "../workflow/track.ts";
|
|
34
35
|
import type { GitIsolation } from "../schemas/configuration.ts";
|
|
35
36
|
import { START_FLAG } from "./start-flags.ts";
|
|
37
|
+
import { livePresence } from "../state/presence.ts";
|
|
38
|
+
import { carryOnMessage } from "../lobby/resume.ts";
|
|
36
39
|
|
|
37
40
|
const HELP = [
|
|
38
41
|
"/bot-lobby Open the web lobby in your browser: tasks, plan, quick fix, metrics",
|
|
@@ -54,6 +57,7 @@ const HELP = [
|
|
|
54
57
|
"/bot-lobby config Show effective configuration",
|
|
55
58
|
"/bot-lobby on|off Turn bot-lobby on or off for this session (ctrl+shift+m); a new session starts off",
|
|
56
59
|
"/bot-lobby claim <taskId> Take ownership of an orphaned task",
|
|
60
|
+
"/bot-lobby carry-on <taskId> Take over a task no running session drives, unpause it and have the oracle carry it on (the Tasks screen's Resume)",
|
|
57
61
|
"/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
|
|
58
62
|
"/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
|
|
59
63
|
"/bot-lobby switch <session.jsonl> Run a saved session in this window (the web lobby's session browser uses it)",
|
|
@@ -62,10 +66,10 @@ const HELP = [
|
|
|
62
66
|
].join("\n");
|
|
63
67
|
|
|
64
68
|
/** Subcommands only win when no free-form text follows (so tasks still start). */
|
|
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"]);
|
|
69
|
+
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", "carry-on", "auto", "start-plan", "switch", "web"]);
|
|
66
70
|
|
|
67
71
|
/** 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"]);
|
|
72
|
+
const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
|
|
69
73
|
|
|
70
74
|
/** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
|
|
71
75
|
function isTaskId(value: string | undefined): boolean {
|
|
@@ -209,6 +213,36 @@ function claimTaskCommand(ctx: ExtensionCommandContext, configDir: string, taskI
|
|
|
209
213
|
applyStatus(ctx, root, configDir);
|
|
210
214
|
ctx.ui.notify(`bot-lobby now owns ${claimed.id}.`, "info");
|
|
211
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* `/bot-lobby carry-on <taskId>`: take over a task no running session drives
|
|
218
|
+
* (or this session's own, when it is started again from its file), unpause it
|
|
219
|
+
* and have the oracle carry it on. A background session resumed from the
|
|
220
|
+
* Tasks screen runs it as its first message.
|
|
221
|
+
*/
|
|
222
|
+
function carryOnCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
|
|
223
|
+
if (!isTaskId(taskId)) return ctx.ui.notify("Usage: /bot-lobby carry-on <taskId>", "warning");
|
|
224
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
225
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
226
|
+
const current = ownedTask(root, configDir, sessionId);
|
|
227
|
+
if (current && current.id !== taskId && !TERMINAL_STATES.includes(current.state)) {
|
|
228
|
+
return ctx.ui.notify(`bot-lobby ${current.id} is already active in this session; cancel it before carrying ${taskId} on.`, "warning");
|
|
229
|
+
}
|
|
230
|
+
const task = loadTask(root, configDir, taskId!);
|
|
231
|
+
if (!task) return ctx.ui.notify(`No task ${taskId}.`, "warning");
|
|
232
|
+
if (TERMINAL_STATES.includes(task.state)) return ctx.ui.notify(`${task.id} is already ${task.state}.`, "warning");
|
|
233
|
+
const owner = task.ownerSessionId;
|
|
234
|
+
if (owner && owner !== sessionId && livePresence(root, configDir).some((presence) => presence.sessionId === owner)) {
|
|
235
|
+
return ctx.ui.notify(`${task.id} is driven by a session that is still running; carry it on there.`, "warning");
|
|
236
|
+
}
|
|
237
|
+
task.ownerSessionId = sessionId;
|
|
238
|
+
task.paused = false;
|
|
239
|
+
task.updatedAt = new Date().toISOString();
|
|
240
|
+
saveTask(root, configDir, task);
|
|
241
|
+
applyStatus(ctx, root, configDir);
|
|
242
|
+
ctx.ui.notify(`bot-lobby carries ${task.id} on in this session.`, "info");
|
|
243
|
+
pi.sendUserMessage(carryOnMessage(task, "stopped"), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
244
|
+
}
|
|
245
|
+
|
|
212
246
|
function answerProposal(
|
|
213
247
|
ctx: ExtensionCommandContext,
|
|
214
248
|
configDir: string,
|
|
@@ -391,6 +425,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
|
391
425
|
return turnOn(pi, ctx, configDir);
|
|
392
426
|
case "claim":
|
|
393
427
|
return claimTaskCommand(ctx, configDir, rest[0]);
|
|
428
|
+
case "carry-on":
|
|
429
|
+
return carryOnCommand(pi, ctx, configDir, rest[0]);
|
|
394
430
|
case "auto":
|
|
395
431
|
return autoCommand(ctx, configDir, rest[0]);
|
|
396
432
|
case "switch":
|
package/src/pi/events.ts
CHANGED
|
@@ -13,9 +13,10 @@ import { isSubagentProcess, webToolsFor } from "./quiet.ts";
|
|
|
13
13
|
import { registerQuietTools } from "./tool-renderers.ts";
|
|
14
14
|
import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
|
|
15
15
|
import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
|
|
16
|
+
import { requestBesidePlan } from "../state/backlog.ts";
|
|
16
17
|
import { isAutoMode } from "../state/auto.ts";
|
|
17
18
|
import { triageContext } from "../classifier/triage.ts";
|
|
18
|
-
import { qaStillDue } from "../workflow/track.ts";
|
|
19
|
+
import { qaStillDue, rosterWords } from "../workflow/track.ts";
|
|
19
20
|
import { previousTaskNote } from "./fresh-context.ts";
|
|
20
21
|
import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
|
|
21
22
|
|
|
@@ -47,12 +48,12 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
|
|
|
47
48
|
const open = pendingComments(comments);
|
|
48
49
|
return [
|
|
49
50
|
`Task ${task.id}: ${task.title}`,
|
|
50
|
-
`Request: ${truncate(taskRequest(task), 2000)}`,
|
|
51
|
+
`Request: ${truncate(task.approvedPlan && task.plan ? requestBesidePlan(taskRequest(task)) : taskRequest(task), 2000)}`,
|
|
51
52
|
task.proposal ? `Current proposal:\n${truncate(task.proposal, 2000)}` : "",
|
|
52
53
|
task.plan ? `Approved plan:\n${truncate(task.plan, 3000)}` : "",
|
|
53
54
|
task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
|
|
54
55
|
open.length > 0 ? `Open plan comments (from the lobby):\n${open.map((comment) => `- ${truncate(comment.text, 600)}`).join("\n")}` : "",
|
|
55
|
-
task.approvedPlan ? `The user agreed this task's plan in the planning panel (${task.approvedPlan})
|
|
56
|
+
task.approvedPlan ? `The user agreed this task's plan in the planning panel (${task.approvedPlan}), and it is the task's plan: delegate its steps without clarifying, scouting or proposing, and answer open questions from the plan.${task.track?.satOut?.length ? ` ${rosterWords(task.track.satOut)} sat out the panel: do not give ${task.track.satOut.length === 1 ? "it" : "them"} work${task.track.satOut.includes("qa") ? ", and there is no QA gate (complete once every step is done)" : ""}.` : ""}` : "",
|
|
56
57
|
auto ? "AUTO MODE is on: drive this task to completion without the user. Do not ask them anything (clarify and ask_user_question are not answered); decide yourself, record each decision, and keep calling the orchestrate tool until the task is complete or truly blocked." : "",
|
|
57
58
|
]
|
|
58
59
|
.filter((line) => line.length > 0)
|
|
@@ -67,12 +68,15 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
|
|
|
67
68
|
export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
68
69
|
// Web tools the oracle put away for the task in hand, given back when it ends.
|
|
69
70
|
let hiddenWeb: string[] = [];
|
|
70
|
-
pi.on("session_start", (
|
|
71
|
+
pi.on("session_start", (event, ctx) => {
|
|
71
72
|
// Master-only: subagents keep their own --tools allowlist (see quiet.ts).
|
|
72
73
|
if (!isSubagentProcess()) registerQuietTools(pi);
|
|
73
74
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
74
|
-
//
|
|
75
|
-
|
|
75
|
+
// pi starting decides afresh: off unless settings or the session's own task say otherwise (switch.ts).
|
|
76
|
+
// A session replaced in this window (switched to, new, forked, reloaded) keeps bot-lobby as it was,
|
|
77
|
+
// so switching sessions from the lobby never leaves the page behind; it turns on for a task under way.
|
|
78
|
+
const fresh = !event?.reason || event.reason === "startup";
|
|
79
|
+
setMinimized(!isSubagentProcess() && (fresh || isMinimized()) && !startsOn(ctx, configDir));
|
|
76
80
|
applyStatus(ctx, root, configDir);
|
|
77
81
|
});
|
|
78
82
|
|
package/src/pi/start-task.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { existsSync } from "node:fs";
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { createTask, taskRequest, type Task, type TaskTrack, type TaskTriage, type TrackPath } from "../schemas/task.ts";
|
|
9
9
|
import type { Domain } from "../schemas/agent.ts";
|
|
10
|
-
import { chooseTrack, trackLine, trackSummary } from "../workflow/track.ts";
|
|
10
|
+
import { chooseTrack, qaRequired, rosterWords, trackLine, trackSummary } from "../workflow/track.ts";
|
|
11
|
+
import { startFromAgreedPlan } from "../workflow/workflow.ts";
|
|
11
12
|
import { lobbyFeed } from "../lobby/feed.ts";
|
|
12
13
|
import { createTaskDir, ensureProjectStructure, loadTask, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
|
|
13
14
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
@@ -43,7 +44,7 @@ function fastSteps(track: TaskTrack): string[] {
|
|
|
43
44
|
const steps = [
|
|
44
45
|
...(track.roster.includes("researcher") ? ["First summon the researcher for the outside facts it needs: orchestrate action=research with a domain and the question."] : []),
|
|
45
46
|
`Delegate now with orchestrate action=implement: ${builders}; open each task with "Step 1:" (the engine keeps the plan).`,
|
|
46
|
-
...(track.roster.includes("qa") && building.length > 0 ? ["QA takes part
|
|
47
|
+
...(track.roster.includes("qa") && building.length > 0 ? ["QA takes part: once the change is in, run orchestrate action=qa. Jev reads the change and sizes QA to its risk; one with nothing that runs passes on the engine's checks."] : []),
|
|
47
48
|
"Check `git diff --stat` and the report, then orchestrate action=complete with a one-line summary.",
|
|
48
49
|
];
|
|
49
50
|
return [
|
|
@@ -54,6 +55,25 @@ function fastSteps(track: TaskTrack): string[] {
|
|
|
54
55
|
].filter(Boolean);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** A task from an agreed plan: straight to building its steps; QA only when it took part in the panel. */
|
|
59
|
+
function plannedSteps(task: Task): string[] {
|
|
60
|
+
const qa = qaRequired(task);
|
|
61
|
+
const steps = [
|
|
62
|
+
'Delegate the plan\'s steps now with orchestrate action=implement, opening each task with "Step N:" and briefing it in full from the plan. Give one domain its consecutive steps in one call, and run independent domains together with assignments.',
|
|
63
|
+
"After each report, check `git diff --stat` and the report, then delegate the next step.",
|
|
64
|
+
qa
|
|
65
|
+
? "Once every step is done, run orchestrate action=qa (Jev sizes it to the change), then action=complete with a one-line summary."
|
|
66
|
+
: "Once every step is done, call orchestrate action=complete with a one-line summary. QA sat out the planning panel, so there is no QA gate.",
|
|
67
|
+
];
|
|
68
|
+
const out = task.track?.satOut ?? [];
|
|
69
|
+
return [
|
|
70
|
+
`Planned task: the user agreed this plan in the planning panel (${task.approvedPlan}), and it is already the task's plan. The panel asked the questions and read the code, so there is no clarifying, scouting, proposal or plan to write: start building.`,
|
|
71
|
+
out.length > 0 ? `Only the members who sat on the panel work on it (${rosterWords(task.track?.roster ?? [])}). ${rosterWords(out)} sat out, so give ${out.length === 1 ? "it" : "them"} nothing; if a step truly needs one, ask the user with orchestrate action=track (the roster with it, and why).` : "",
|
|
72
|
+
...steps.map((step, index) => `${index + 1}. ${step}`),
|
|
73
|
+
"If the code contradicts the plan, amend it with action=plan (the plan with the change made) and tell the user in one line, rather than starting over.",
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: boolean; routed?: boolean } = {}): string {
|
|
58
78
|
const track = task.track;
|
|
59
79
|
const head = [
|
|
@@ -68,6 +88,7 @@ export function kickoff(task: Task, budgetMinutes = 0, options: { fastTrack?: bo
|
|
|
68
88
|
"",
|
|
69
89
|
];
|
|
70
90
|
if (track?.path === "fast") return [...head, ...fastSteps(track)].join("\n");
|
|
91
|
+
if (task.approvedPlan && task.plan && task.state === "planning") return [...head, ...plannedSteps(task)].join("\n");
|
|
71
92
|
const fastAllowed = Boolean(track) && options.fastTrack !== false && track?.userChoice !== "full" && track?.source !== "plan";
|
|
72
93
|
return [
|
|
73
94
|
...head,
|
|
@@ -105,6 +126,10 @@ async function giveWorkspace(ctx: ExtensionContext, root: string, configDir: str
|
|
|
105
126
|
export interface StartOptions {
|
|
106
127
|
/** The planned task (PLAN-…) this task starts from: the user agreed its plan, so its proposal needs no approval. */
|
|
107
128
|
approvedPlan?: string;
|
|
129
|
+
/** That plan: it becomes the task's plan, and the task starts at delegating its steps. */
|
|
130
|
+
plan?: string;
|
|
131
|
+
/** The panel seats that took part in that plan; one that sat it out (QA) is not part of the task. */
|
|
132
|
+
panel?: readonly string[];
|
|
108
133
|
/** Start in auto mode: the oracle drives it to completion without asking. */
|
|
109
134
|
auto?: boolean;
|
|
110
135
|
/** The task's short title (a planned task's own title); derived from the request otherwise. */
|
|
@@ -140,8 +165,10 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
|
|
|
140
165
|
if (triage) task.triage = triage;
|
|
141
166
|
// How serious the request reads: who takes part, and whether it takes the fast track or the full workflow.
|
|
142
167
|
const config = loadConfig();
|
|
143
|
-
task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}) });
|
|
168
|
+
task.track = chooseTrack(request, triage, { fastTrack: config.workflow.fastTrack, ...(options.track ? { forced: options.track } : {}), ...(options.approvedPlan ? { approvedPlan: true } : {}), ...(options.panel ? { planPanel: options.panel } : {}) });
|
|
144
169
|
lobbyFeed.log("LOBBY", trackLine(task.track), "info");
|
|
170
|
+
// An agreed plan is the task's plan: it goes straight to delegating its steps.
|
|
171
|
+
if (options.approvedPlan && options.plan) startFromAgreedPlan(task, options.plan, taskDirFor(root, configDir, task.id));
|
|
145
172
|
await giveWorkspace(ctx, root, configDir, task, options.isolation ?? config.workflow.gitIsolation);
|
|
146
173
|
saveTask(root, configDir, task);
|
|
147
174
|
const budgetMinutes = options.budget ?? config.workflow.taskBudgetMinutes;
|
|
@@ -192,7 +219,7 @@ export async function startPlannedTask(pi: ExtensionAPI, ctx: ExtensionContext,
|
|
|
192
219
|
if (!plan) return `no planned task ${planId}`;
|
|
193
220
|
if (plan.status !== "pending") return `${planId} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
|
|
194
221
|
const waiting = unfinishedBefore(root, configDir, plan);
|
|
195
|
-
const task = await startTask(pi, ctx, configDir, plannedTaskRequest(plan), { approvedPlan: plan.id, title: plan.title, ...(options.auto ? { auto: true } : {}) });
|
|
222
|
+
const task = await startTask(pi, ctx, configDir, plannedTaskRequest(plan), { approvedPlan: plan.id, plan: plan.brief, ...(plan.panel ? { panel: plan.panel } : {}), title: plan.title, ...(options.auto ? { auto: true } : {}) });
|
|
196
223
|
if (!task) return `this session already drives a task; finish or cancel it first`;
|
|
197
224
|
markPlannedTaskStarted(root, configDir, plan.id, task.id);
|
|
198
225
|
if (waiting.length > 0) {
|
package/src/pi/switch.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
12
12
|
import { Key } from "@earendil-works/pi-tui";
|
|
13
13
|
import { currentLobbyService, lobbyWorkRunning, servesPage, startLobbyService, stopLobbyService } from "../lobby/runtime.ts";
|
|
14
14
|
import { activeTask } from "../state/persistence.ts";
|
|
15
|
+
import { workToCarryOn } from "../lobby/recovery.ts";
|
|
15
16
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
16
17
|
import { stopWebServer, webSessionStarted } from "../webui/command.ts";
|
|
17
18
|
import { isSubagentProcess } from "./quiet.ts";
|
|
@@ -23,11 +24,15 @@ export function isOn(): boolean {
|
|
|
23
24
|
return !isMinimized();
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Whether a new session starts on: when settings say so, when it owns a task
|
|
29
|
+
* still under way, or when a window in the project stopped unexpectedly and
|
|
30
|
+
* its work is waiting to carry on.
|
|
31
|
+
*/
|
|
27
32
|
export function startsOn(ctx: ExtensionContext, configDir: string): boolean {
|
|
28
33
|
if (loadConfig().lobby.startOn) return true;
|
|
29
34
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
30
|
-
return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId()));
|
|
35
|
+
return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId())) || (servesPage(ctx) && workToCarryOn(root, configDir));
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/** Turn bot-lobby on: the oracle's prompt comes back and the web lobby starts (opening the browser if settings say so). */
|
package/src/pi/tools.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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
18
|
import { lintFeed } from "../workflow/lint.ts";
|
|
19
|
+
import { qaRiskLine } from "../classifier/qa-risk.ts";
|
|
19
20
|
import { lobbyFeed } from "../lobby/feed.ts";
|
|
20
21
|
import {
|
|
21
22
|
ORCHESTRATE_ACTIONS,
|
|
@@ -140,6 +141,8 @@ export function workflowDeps(
|
|
|
140
141
|
const line = lintFeed(taskId, report);
|
|
141
142
|
lobbyFeed.log("LINT", line.text, line.kind);
|
|
142
143
|
},
|
|
144
|
+
// Jev's QA risk read of each task's change, as the QA gate starts.
|
|
145
|
+
onQaRisk: (taskId, assessment) => lobbyFeed.log("CLASSIFIER", `${taskId} ${qaRiskLine(assessment)}`, "info"),
|
|
143
146
|
};
|
|
144
147
|
}
|
|
145
148
|
|
package/src/pi/ui.ts
CHANGED
|
@@ -72,8 +72,14 @@ function setZenState(task: Task | undefined, live: AgentRun[]): void {
|
|
|
72
72
|
zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Bot-lobby off for this session (plain pi): the Master prompt is hidden but
|
|
77
|
+
* ownership stays (see switch.ts). Kept on the process, not the module: a
|
|
78
|
+
* reload imports the extension afresh, and on or off must outlive it.
|
|
79
|
+
*/
|
|
80
|
+
const SWITCH = Symbol.for("bot-lobby.minimized");
|
|
81
|
+
const processState = globalThis as { [SWITCH]?: boolean };
|
|
82
|
+
let minimized = processState[SWITCH] ?? false;
|
|
77
83
|
|
|
78
84
|
export function isMinimized(): boolean {
|
|
79
85
|
return minimized;
|
|
@@ -81,6 +87,7 @@ export function isMinimized(): boolean {
|
|
|
81
87
|
|
|
82
88
|
export function setMinimized(value: boolean): void {
|
|
83
89
|
minimized = value;
|
|
90
|
+
processState[SWITCH] = value;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
@@ -170,7 +170,7 @@ export interface LobbyConfig {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/** Decisions the classifier can make, each switched on or off on its own. */
|
|
173
|
-
export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge"] as const;
|
|
173
|
+
export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge", "qa"] as const;
|
|
174
174
|
export type ClassifierFeature = (typeof CLASSIFIER_FEATURES)[number];
|
|
175
175
|
|
|
176
176
|
/** Hosts that serve Jev behind the same System One API; `auto` takes OpenCode's free Jev when pi holds an OpenCode key, else TypeSafe. */
|
|
@@ -291,7 +291,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
291
291
|
model: "",
|
|
292
292
|
baseUrl: "",
|
|
293
293
|
timeoutMs: 4000,
|
|
294
|
-
features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true },
|
|
294
|
+
features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true, qa: true },
|
|
295
295
|
thresholds: {
|
|
296
296
|
seatAt: 0.35,
|
|
297
297
|
reseatReadyAt: 0.6,
|
package/src/schemas/task.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface ReviewRecord {
|
|
|
55
55
|
findings: { severity: Severity; text: string }[];
|
|
56
56
|
requiredChanges: string[];
|
|
57
57
|
createdAt: string;
|
|
58
|
+
/** The engine's deterministic checks decided the round: no QA agent ran (a change with nothing that runs). */
|
|
59
|
+
checksOnly?: boolean;
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
60
62
|
* One finished worker delegation, kept on the task so the plan checklist can be
|
|
@@ -118,6 +120,32 @@ export interface TaskTriage {
|
|
|
118
120
|
at: string;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
/** How much verification a finished change warrants, lowest first. */
|
|
124
|
+
export const QA_RISKS = ["none", "low", "medium", "high"] as const;
|
|
125
|
+
export type QaRisk = (typeof QA_RISKS)[number];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the change a task built warrants of QA, read from its diff once the
|
|
129
|
+
* implementation is done: the QA gate's verification budget. The test budget
|
|
130
|
+
* is a ceiling, never a quota.
|
|
131
|
+
*/
|
|
132
|
+
export interface QaRiskAssessment {
|
|
133
|
+
risk: QaRisk;
|
|
134
|
+
/** Whether an LLM QA pass is needed at all: without one, the engine's deterministic checks decide. */
|
|
135
|
+
qaRequired: boolean;
|
|
136
|
+
/** New behavioural tests that may be worth writing, at most `max`. */
|
|
137
|
+
testBudget: { min: number; max: number };
|
|
138
|
+
/** What QA should verify first, as words. */
|
|
139
|
+
focusAreas: string[];
|
|
140
|
+
/** The evidence behind the level: what the diff touches, with paths. */
|
|
141
|
+
riskFactors: string[];
|
|
142
|
+
existingTestsLikelySufficient: boolean;
|
|
143
|
+
reasoning: string;
|
|
144
|
+
/** Jev's read of the diff, or the engine's rules alone when Jev is off, failed or had nothing to read. */
|
|
145
|
+
source: "classifier" | "rules";
|
|
146
|
+
at: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
/** How much process a task gets: the fast track for a small, clear, low-risk change, the full workflow for the rest. */
|
|
122
150
|
export type TrackPath = "fast" | "full";
|
|
123
151
|
|
|
@@ -142,6 +170,8 @@ export interface TaskTrack {
|
|
|
142
170
|
userChoice?: TrackPath;
|
|
143
171
|
/** The engine keeps the plan (fast track): each delegation adds its step. */
|
|
144
172
|
autoPlan?: boolean;
|
|
173
|
+
/** Members who sat out the planning panel that agreed the plan: they do not work on the task (no QA gate without QA) unless the user lets one join. */
|
|
174
|
+
satOut?: TrackMember[];
|
|
145
175
|
at: string;
|
|
146
176
|
}
|
|
147
177
|
|
|
@@ -227,6 +257,8 @@ export interface Task {
|
|
|
227
257
|
git?: TaskGit;
|
|
228
258
|
/** The lint gate's last report on the files this task's agents touched. */
|
|
229
259
|
lint?: LintReport;
|
|
260
|
+
/** The last read of what the built change warrants of QA, taken as the QA gate starts. */
|
|
261
|
+
qaRisk?: QaRiskAssessment;
|
|
230
262
|
/** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
|
|
231
263
|
lintWaiver?: { at: string };
|
|
232
264
|
}
|
package/src/state/backlog.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { writeFileEnsured } from "../knowledge/store.ts";
|
|
|
11
11
|
import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
|
|
12
12
|
import { dataRoot } from "./project.ts";
|
|
13
13
|
import { taskSlug } from "./persistence.ts";
|
|
14
|
+
import type { PanelMember } from "../schemas/configuration.ts";
|
|
14
15
|
|
|
15
16
|
export interface IssueRef {
|
|
16
17
|
number: number;
|
|
@@ -44,6 +45,8 @@ export interface PlannedTask {
|
|
|
44
45
|
startedTaskId?: string;
|
|
45
46
|
/** Set when the plan was split into several tasks and this is one of them. */
|
|
46
47
|
split?: SplitInfo;
|
|
48
|
+
/** The panel seats that took part in planning it (sat a round and were not unseated); absent on plans saved before this was kept. */
|
|
49
|
+
panel?: PanelMember[];
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function backlogDir(root: string, configDir: string): string {
|
|
@@ -63,7 +66,7 @@ function isPlannedTask(value: unknown): value is PlannedTask {
|
|
|
63
66
|
export function savePlannedTask(
|
|
64
67
|
root: string,
|
|
65
68
|
configDir: string,
|
|
66
|
-
input: { title: string; brief: string; issue?: IssueRef; split?: SplitInfo },
|
|
69
|
+
input: { title: string; brief: string; issue?: IssueRef; split?: SplitInfo; panel?: readonly PanelMember[] },
|
|
67
70
|
now = new Date(),
|
|
68
71
|
): PlannedTask {
|
|
69
72
|
const title = input.title.trim() || "planned task";
|
|
@@ -73,7 +76,7 @@ export function savePlannedTask(
|
|
|
73
76
|
let id = base;
|
|
74
77
|
for (let n = 2; existsSync(entryPath(root, configDir, id)); n++) id = `${base}-${n}`;
|
|
75
78
|
const at = now.toISOString();
|
|
76
|
-
const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}), ...(input.split ? { split: input.split } : {}) };
|
|
79
|
+
const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}), ...(input.split ? { split: input.split } : {}), ...(input.panel ? { panel: [...input.panel] } : {}) };
|
|
77
80
|
writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(entry, null, 2));
|
|
78
81
|
return entry;
|
|
79
82
|
}
|
|
@@ -131,9 +134,22 @@ export function splitLine(split: SplitInfo): string {
|
|
|
131
134
|
return `This is part ${split.part} of ${split.of} of one plan that was split into separate tasks: ${others}.${before} Do only this part; the others are their own tasks.`;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/** Where a planned task's request moves on from what it is to the plan itself. */
|
|
138
|
+
const AGREED_PLAN = "\n\nAgreed plan (from the planning session):\n";
|
|
139
|
+
|
|
134
140
|
/** The request a started task carries: the agreed plan, plus the issue it came from, plus where it sits in a split plan. */
|
|
135
141
|
export function plannedTaskRequest(entry: PlannedTask): string {
|
|
136
142
|
const source = entry.issue ? `\n\nFrom GitHub issue #${entry.issue.number}: ${entry.issue.title}${entry.issue.url ? ` (${entry.issue.url})` : ""}` : "";
|
|
137
143
|
const part = entry.split ? `\n\n${splitLine(entry.split)}` : "";
|
|
138
|
-
return `${entry.title}${part}
|
|
144
|
+
return `${entry.title}${part}${AGREED_PLAN}${entry.brief}${source}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A planned task's request without the plan in it (its title, and where it
|
|
149
|
+
* sits in a split plan), for prompts that carry the plan on its own: the plan
|
|
150
|
+
* is not sent twice. Any other request is returned whole.
|
|
151
|
+
*/
|
|
152
|
+
export function requestBesidePlan(request: string): string {
|
|
153
|
+
const at = request.indexOf(AGREED_PLAN);
|
|
154
|
+
return at < 0 ? request : `${request.slice(0, at)}\n\n(The agreed plan is the task's plan.)`;
|
|
139
155
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every planning session in a project, kept as it goes (one file each under
|
|
3
|
+
* `planning/`): the conversation, the draft and the panel, so a plan the user
|
|
4
|
+
* walked away from (a new plan, a closed window) can be read again, carried
|
|
5
|
+
* on, archived or deleted. A session saved as a pending task is flagged with
|
|
6
|
+
* the tasks it became and leaves the previous plans; its record stays.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { dataRoot } from "./project.ts";
|
|
11
|
+
import type { PlanningSnapshot } from "../lobby/planner.ts";
|
|
12
|
+
|
|
13
|
+
export interface PlanRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
/** Put aside by the user: listed only with the archived plans. */
|
|
19
|
+
archivedAt?: string;
|
|
20
|
+
/** The pending tasks it was saved as: such a plan is no longer a previous plan. */
|
|
21
|
+
savedAs?: string[];
|
|
22
|
+
snapshot: PlanningSnapshot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a list of previous plans shows of each. */
|
|
26
|
+
export interface PlanSummary {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
archivedAt?: string;
|
|
32
|
+
/** Messages in its conversation. */
|
|
33
|
+
messages: number;
|
|
34
|
+
rounds: number;
|
|
35
|
+
hasDraft: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PLAN_ID = /^PS-[a-z0-9]{4,32}$/;
|
|
39
|
+
|
|
40
|
+
export function isPlanId(id: string): boolean {
|
|
41
|
+
return PLAN_ID.test(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function planningDir(root: string, configDir: string): string {
|
|
45
|
+
return join(dataRoot(root, configDir), "planning");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordPath(root: string, configDir: string, id: string): string {
|
|
49
|
+
if (!isPlanId(id)) throw new Error(`no plan ${id}`);
|
|
50
|
+
return join(planningDir(root, configDir), `${id}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A title for a session: the oracle's, else the start of the idea. */
|
|
54
|
+
export function planTitle(snapshot: PlanningSnapshot): string {
|
|
55
|
+
const words = snapshot.reply?.title ?? snapshot.seed?.issue.title ?? snapshot.messages.find((message) => message.role === "you")?.text ?? "";
|
|
56
|
+
const line = words.split("\n")[0]!.replace(/\s+/g, " ").trim();
|
|
57
|
+
return (line.length > 80 ? `${line.slice(0, 79)}…` : line) || "Untitled plan";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readPlan(root: string, configDir: string, id: string): PlanRecord | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const record = JSON.parse(readFileSync(recordPath(root, configDir, id), "utf8")) as PlanRecord;
|
|
63
|
+
return record && record.id === id && record.snapshot && Array.isArray(record.snapshot.messages) ? record : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function write(root: string, configDir: string, record: PlanRecord): void {
|
|
70
|
+
const path = recordPath(root, configDir, record.id);
|
|
71
|
+
mkdirSync(planningDir(root, configDir), { recursive: true });
|
|
72
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
73
|
+
writeFileSync(tmp, JSON.stringify(record), "utf8");
|
|
74
|
+
renameSync(tmp, path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keep a session as it stands. One that has not started (no idea, no issue)
|
|
79
|
+
* is not kept; saving it as a task flags it, for good.
|
|
80
|
+
*/
|
|
81
|
+
export function keepPlan(root: string, configDir: string, id: string, createdAt: number, snapshot: PlanningSnapshot, now = new Date()): void {
|
|
82
|
+
if (!isPlanId(id) || (snapshot.messages.length === 0 && !snapshot.seed)) return;
|
|
83
|
+
const previous = readPlan(root, configDir, id);
|
|
84
|
+
const saved = [...new Set([...(previous?.savedAs ?? []), ...(snapshot.saved ? [snapshot.saved.id] : []), ...snapshot.savedParts.map((part) => part.id)])];
|
|
85
|
+
write(root, configDir, {
|
|
86
|
+
id,
|
|
87
|
+
title: planTitle(snapshot),
|
|
88
|
+
createdAt: previous?.createdAt ?? new Date(createdAt).toISOString(),
|
|
89
|
+
updatedAt: now.toISOString(),
|
|
90
|
+
...(previous?.archivedAt ? { archivedAt: previous.archivedAt } : {}),
|
|
91
|
+
...(saved.length > 0 ? { savedAs: saved } : {}),
|
|
92
|
+
snapshot: { ...snapshot, running: false },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function summary(record: PlanRecord): PlanSummary {
|
|
97
|
+
return {
|
|
98
|
+
id: record.id,
|
|
99
|
+
title: record.title,
|
|
100
|
+
createdAt: record.createdAt,
|
|
101
|
+
updatedAt: record.updatedAt,
|
|
102
|
+
...(record.archivedAt ? { archivedAt: record.archivedAt } : {}),
|
|
103
|
+
messages: record.snapshot.messages.length,
|
|
104
|
+
rounds: record.snapshot.turns,
|
|
105
|
+
hasDraft: Boolean(record.snapshot.reply?.plan),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The previous plans: never saved as a task, newest first; the archived ones
|
|
111
|
+
* only when asked for, and the session on screen (`except`) never.
|
|
112
|
+
*/
|
|
113
|
+
export function previousPlans(root: string, configDir: string, options: { archived?: boolean; except?: string } = {}): PlanSummary[] {
|
|
114
|
+
const dir = planningDir(root, configDir);
|
|
115
|
+
if (!existsSync(dir)) return [];
|
|
116
|
+
const found: PlanSummary[] = [];
|
|
117
|
+
for (const file of readdirSync(dir)) {
|
|
118
|
+
const id = file.replace(/\.json$/, "");
|
|
119
|
+
if (!file.endsWith(".json") || !isPlanId(id) || id === options.except) continue;
|
|
120
|
+
const record = readPlan(root, configDir, id);
|
|
121
|
+
if (!record || record.savedAs?.length) continue;
|
|
122
|
+
if (Boolean(record.archivedAt) !== Boolean(options.archived)) continue;
|
|
123
|
+
found.push(summary(record));
|
|
124
|
+
}
|
|
125
|
+
return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Put a previous plan aside, or bring it back. */
|
|
129
|
+
export function archivePlan(root: string, configDir: string, id: string, archived: boolean, now = new Date()): PlanRecord {
|
|
130
|
+
const record = readPlan(root, configDir, id);
|
|
131
|
+
if (!record) throw new Error(`no plan ${id}`);
|
|
132
|
+
if (record.savedAs?.length) throw new Error(`${id} was saved as a task`);
|
|
133
|
+
const { archivedAt: _was, ...rest } = record;
|
|
134
|
+
const next: PlanRecord = archived ? { ...rest, archivedAt: now.toISOString() } : rest;
|
|
135
|
+
write(root, configDir, next);
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function deletePlan(root: string, configDir: string, id: string): void {
|
|
140
|
+
if (!readPlan(root, configDir, id)) throw new Error(`no plan ${id}`);
|
|
141
|
+
rmSync(recordPath(root, configDir, id), { force: true });
|
|
142
|
+
}
|