@a-t-h-i/bot-lobby 0.6.25 → 0.6.27
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 +10 -2
- package/package.json +1 -5
- package/prompts/master.md +36 -10
- package/prompts/planner.md +5 -2
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/classifier/qa-risk.ts +537 -0
- package/src/execution/agent-runner.ts +0 -18
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +0 -15
- package/src/lobby/host.ts +2 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +14 -0
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/master/research.ts +0 -16
- package/src/pi/events.ts +13 -6
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/start-task.ts +31 -4
- package/src/pi/tools.ts +3 -0
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +2 -4
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/budget.ts +49 -22
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +0 -5
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/tasks.ts +28 -6
- package/src/webui/dev/fake-service.ts +6 -0
- package/src/webui/dev/fixtures.ts +2 -0
- package/src/webui/protocol.ts +23 -3
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +231 -42
- package/webui/dist/assets/index-BUzOUrih.css +2 -0
- package/webui/dist/assets/index-myC4UiEg.js +108 -0
- package/webui/dist/build.json +37 -41
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BRVbs5In.js +0 -105
- package/webui/dist/assets/index-BeRXVi_r.css +0 -2
package/src/lobby/issues.ts
CHANGED
|
@@ -154,14 +154,6 @@ export async function createIssue(exec: Exec, cwd: string, title: string, body:
|
|
|
154
154
|
return { url, ...(Number.isFinite(number) ? { number } : {}) };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/** The issue as text for the planner: body plus comments. */
|
|
158
|
-
export function issueText(issue: IssueDetail): string {
|
|
159
|
-
const comments = issue.comments
|
|
160
|
-
.filter((comment) => comment.body.trim())
|
|
161
|
-
.map((comment) => `**${comment.author ?? "someone"}** commented:\n${comment.body.trim()}`);
|
|
162
|
-
return [issue.body.trim() || "(no description)", ...comments].join("\n\n");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
157
|
/** Lobby state for the Issues tab: the list, the open issue, and what is loading. */
|
|
166
158
|
export class IssuesState {
|
|
167
159
|
issues: IssueSummary[] = [];
|
package/src/lobby/planner.ts
CHANGED
|
@@ -563,6 +563,8 @@ export class PlanningSession {
|
|
|
563
563
|
satOut = new Map<PanelMember, number>();
|
|
564
564
|
/** How each seat left the last round it sat. */
|
|
565
565
|
private readonly lastStatus = new Map<PanelMember, "ready" | "open">();
|
|
566
|
+
/** Seats that answered in at least one round: who took part in the plan. */
|
|
567
|
+
private readonly sat = new Set<PanelMember>();
|
|
566
568
|
/** The last round was started by the classifier settling every question; the next one waits for the user. */
|
|
567
569
|
private autoContinued = false;
|
|
568
570
|
private controller?: AbortController;
|
|
@@ -707,6 +709,15 @@ export class PlanningSession {
|
|
|
707
709
|
this.controller?.abort();
|
|
708
710
|
}
|
|
709
711
|
|
|
712
|
+
/**
|
|
713
|
+
* The seats that took part in the plan: each answered in a round and is
|
|
714
|
+
* still on the panel. One the classifier always left out, or the user
|
|
715
|
+
* unseated, sat the plan out, and the task it becomes runs without it.
|
|
716
|
+
*/
|
|
717
|
+
get panel(): PanelMember[] {
|
|
718
|
+
return PANEL_MEMBERS.filter((member) => this.sat.has(member) && this.seats.has(member));
|
|
719
|
+
}
|
|
720
|
+
|
|
710
721
|
/** Save the latest draft as a pending task. */
|
|
711
722
|
save(now = new Date()): PlannedTask {
|
|
712
723
|
const plan = this.reply?.plan;
|
|
@@ -715,6 +726,7 @@ export class PlanningSession {
|
|
|
715
726
|
title: this.title ?? this.messages[0]?.text.split("\n")[0] ?? "planned task",
|
|
716
727
|
brief: plan,
|
|
717
728
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
729
|
+
panel: this.panel,
|
|
718
730
|
}, now);
|
|
719
731
|
this.deps.feed?.log(ORACLE_LABEL, `saved ${this.saved.id} to pending tasks`, "success");
|
|
720
732
|
this.deps.onChange?.();
|
|
@@ -733,6 +745,7 @@ export class PlanningSession {
|
|
|
733
745
|
brief: partBrief({ plan, steps, proposal, index }),
|
|
734
746
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
735
747
|
split: partInfo(proposal, index, group),
|
|
748
|
+
panel: this.panel,
|
|
736
749
|
}, new Date(now.getTime() + (proposal.tasks.length - index))));
|
|
737
750
|
this.savedParts = parts;
|
|
738
751
|
this.saved = parts[0];
|
|
@@ -966,6 +979,7 @@ export class PlanningSession {
|
|
|
966
979
|
const transcript = [plannerTranscript(this.messages, this.seed, this.reply?.plan, ""), likely].filter(Boolean).join("\n\n");
|
|
967
980
|
const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal, effort)));
|
|
968
981
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
982
|
+
for (const outcome of outcomes) if (outcome.reply) this.sat.add(outcome.member);
|
|
969
983
|
this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
|
|
970
984
|
this.step = "writing the plan";
|
|
971
985
|
this.deps.onChange?.();
|
package/src/lobby/pulls.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Like issues, `gh` owns authentication and repository detection; every
|
|
5
5
|
* failure becomes one readable line.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { gh, type Exec } from "./issues.ts";
|
|
8
8
|
|
|
9
9
|
export type ChecksState = "passing" | "failing" | "pending";
|
|
10
10
|
|
|
@@ -193,49 +193,6 @@ export async function pullDiff(exec: Exec, cwd: string, number: number, limit =
|
|
|
193
193
|
return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/** A pull request opened for a completed task's branch. */
|
|
197
|
-
export interface CreatedPull {
|
|
198
|
-
number: number;
|
|
199
|
-
url: string;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const PUSH_TIMEOUT_MS = 60_000;
|
|
203
|
-
|
|
204
|
-
async function run(exec: Exec, cwd: string, command: string, args: string[]): Promise<string> {
|
|
205
|
-
let result: Awaited<ReturnType<Exec>>;
|
|
206
|
-
try {
|
|
207
|
-
result = await exec(command, args, { cwd, timeout: PUSH_TIMEOUT_MS });
|
|
208
|
-
} catch (error) {
|
|
209
|
-
throw new Error(`${command} could not run — ${(error as Error).message}`);
|
|
210
|
-
}
|
|
211
|
-
if (result.code !== 0) throw new Error(result.stderr.trim() || `${command} exited with code ${result.code}`);
|
|
212
|
-
return result.stdout;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** `gh pr create` ends with the URL on its own last line; the number is its tail. */
|
|
216
|
-
function parseCreated(stdout: string): CreatedPull {
|
|
217
|
-
const url = stdout.trim().split("\n").map((line) => line.trim()).filter((line) => /^https?:\/\/\S+$/.test(line)).pop();
|
|
218
|
-
if (!url) throw new Error("gh pr create printed no pull request URL");
|
|
219
|
-
const match = /\/pull\/([1-9]\d*)$/.exec(url);
|
|
220
|
-
const number = Number(match?.[1]);
|
|
221
|
-
if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`gh pr create printed an unreadable URL: ${url}`);
|
|
222
|
-
return { number, url };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Push a finished task's branch and open its pull request, so the work lands
|
|
227
|
-
* in review without the oracle having to remember a second step.
|
|
228
|
-
*/
|
|
229
|
-
export async function createPullRequest(options: { exec?: Exec; cwd: string; branch: string; base: string; title: string; body: string }): Promise<CreatedPull> {
|
|
230
|
-
for (const ref of [options.branch, options.base]) {
|
|
231
|
-
if (!ref || ref.startsWith("-") || /[\s~^:?*\[\\]|\.\.|@\{|\/\//.test(ref) || /[/.]$/.test(ref)) throw new Error("invalid pull request branch");
|
|
232
|
-
}
|
|
233
|
-
const exec = options.exec ?? execCommand;
|
|
234
|
-
await run(exec, options.cwd, "git", ["push", "-u", "origin", options.branch]);
|
|
235
|
-
const stdout = await gh(exec, options.cwd, ["pr", "create", "--base", options.base, "--head", options.branch, "--title", options.title, "--body", options.body.slice(0, 60_000)]);
|
|
236
|
-
return parseCreated(stdout);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
196
|
/** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
|
|
240
197
|
export class PullsState {
|
|
241
198
|
pulls: PullSummary[] = [];
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { Domain } from "../schemas/agent.ts";
|
|
|
20
20
|
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
21
21
|
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
22
22
|
import { checkThinking } from "../pi/model-support.ts";
|
|
23
|
-
import {
|
|
23
|
+
import { QuickFixQueue } from "./quickfix.ts";
|
|
24
24
|
import type { Asker } from "../ask/types.ts";
|
|
25
25
|
import type { PlanningSession } from "./planner.ts";
|
|
26
26
|
import { execCommand, IssuesState } from "./issues.ts";
|
package/src/lobby/service.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import { realpathSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
|
|
13
|
+
import { taskWork } from "../state/work-time.ts";
|
|
13
14
|
import { refreshReview, deferReview } from "../delivery/review.ts";
|
|
14
15
|
import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
|
|
15
16
|
import type { LockIdentity } from "../delivery/lock.ts";
|
|
@@ -34,7 +35,7 @@ import { pendingRequest, startRequest } from "../pi/route.ts";
|
|
|
34
35
|
import { deliverComments, setAuto } from "../pi/owner.ts";
|
|
35
36
|
import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
|
|
36
37
|
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
37
|
-
import { launchPi, SessionRegistry, type BackgroundSession, type
|
|
38
|
+
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
38
39
|
import { SessionChats } from "./session-files.ts";
|
|
39
40
|
import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
|
|
40
41
|
import { askUser } from "../ask/web.ts";
|
|
@@ -572,6 +573,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
572
573
|
feed: lobbyFeed,
|
|
573
574
|
masterBusy: () => !state.ctx.isIdle(),
|
|
574
575
|
tasks: () => peekTasks(state.root, state.configDir),
|
|
576
|
+
work: (taskId) => taskWork(state.root, state.configDir, taskId),
|
|
575
577
|
deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
|
|
576
578
|
deliveryReview: async (taskId) => {
|
|
577
579
|
const task = deliveryTask(state, taskId);
|
|
@@ -7,51 +7,7 @@
|
|
|
7
7
|
* parent, and the text of user and assistant messages (never tool output).
|
|
8
8
|
*/
|
|
9
9
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
interface EntryLike {
|
|
13
|
-
type?: string;
|
|
14
|
-
id?: string;
|
|
15
|
-
parentId?: string | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** The entries on the branch that ends at the newest entry, oldest first (a session file is a tree). */
|
|
19
|
-
export function currentBranch(entries: readonly unknown[]): unknown[] {
|
|
20
|
-
const byId = new Map<string, EntryLike>();
|
|
21
|
-
let leaf: EntryLike | undefined;
|
|
22
|
-
for (const entry of entries) {
|
|
23
|
-
const record = entry as EntryLike;
|
|
24
|
-
if (!record || typeof record.id !== "string") continue;
|
|
25
|
-
byId.set(record.id, record);
|
|
26
|
-
leaf = record;
|
|
27
|
-
}
|
|
28
|
-
const branch: unknown[] = [];
|
|
29
|
-
const seen = new Set<string>();
|
|
30
|
-
for (let entry = leaf; entry?.id && !seen.has(entry.id); entry = entry.parentId ? byId.get(entry.parentId) : undefined) {
|
|
31
|
-
seen.add(entry.id);
|
|
32
|
-
branch.push(entry);
|
|
33
|
-
}
|
|
34
|
-
return branch.reverse();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Parse a session file's JSON lines, skipping torn ones. */
|
|
38
|
-
export function parseEntries(text: string): unknown[] {
|
|
39
|
-
const entries: unknown[] = [];
|
|
40
|
-
for (const line of text.split("\n")) {
|
|
41
|
-
if (!line.trim()) continue;
|
|
42
|
-
try {
|
|
43
|
-
entries.push(JSON.parse(line));
|
|
44
|
-
} catch {
|
|
45
|
-
// A line being written right now; the next read has it.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return entries;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The conversation a session file holds, as the lobby's chat entries. */
|
|
52
|
-
export function chatFromFile(text: string, max = MAX_CHAT): ChatEntry[] {
|
|
53
|
-
return chatFromEntries(currentBranch(parseEntries(text)), max).map((line, index) => ({ id: index + 1, at: line.at ?? 0, role: line.role, text: line.text }));
|
|
54
|
-
}
|
|
10
|
+
import { chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
|
|
55
11
|
|
|
56
12
|
/** What a session log keeps of one entry: its parent, and its conversation lines if it is a message. */
|
|
57
13
|
interface LogNode {
|
package/src/lobby/split.ts
CHANGED
|
@@ -202,7 +202,39 @@ function planWithSteps(plan: string, covers: readonly number[], stepCount: numbe
|
|
|
202
202
|
const lines = plan.split("\n");
|
|
203
203
|
const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
|
|
204
204
|
const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
|
|
205
|
-
return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
|
|
205
|
+
return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...stepDetails(lines.slice(found.to), covers)].join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `### Step 3: …` (or `**Step 3 — …**`): the heading of one step's detail. */
|
|
209
|
+
const DETAIL_HEADING = /^(\s*(?:#{1,6}\s+)?(?:\*\*)?\s*step\s+#?)(\d+)(?!\d)/i;
|
|
210
|
+
const ANY_HEADING = /^\s*(?:(#{1,6})\s+\S.*|\*\*[^*]+\*\*.*)$/;
|
|
211
|
+
|
|
212
|
+
/** A heading's depth: `#` is 1, a bold line deeper than any `#`. */
|
|
213
|
+
const depth = (line: string): number => ANY_HEADING.exec(line)?.[1]?.length ?? 7;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The lines after a plan's steps list with only the detail of the steps in `covers`, renumbered as
|
|
217
|
+
* the list was: a step's detail runs from its `Step N` heading to the next heading as deep or deeper.
|
|
218
|
+
*/
|
|
219
|
+
function stepDetails(lines: readonly string[], covers: readonly number[]): string[] {
|
|
220
|
+
const out: string[] = [];
|
|
221
|
+
let skipping: number | undefined;
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
const heading = ANY_HEADING.test(line) ? depth(line) : undefined;
|
|
224
|
+
if (skipping !== undefined && heading !== undefined && heading <= skipping) skipping = undefined;
|
|
225
|
+
const detail = heading !== undefined ? DETAIL_HEADING.exec(line) : null;
|
|
226
|
+
if (detail) {
|
|
227
|
+
const at = covers.indexOf(Number(detail[2]));
|
|
228
|
+
if (at < 0) {
|
|
229
|
+
skipping = heading;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
out.push(line.replace(DETAIL_HEADING, (_all, lead: string) => `${lead}${at + 1}`));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (skipping === undefined) out.push(line);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
/** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
|
package/src/lobby/task-rows.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface TaskRow {
|
|
|
22
22
|
check: CheckState;
|
|
23
23
|
/** Plan steps done, once the task has a plan. */
|
|
24
24
|
progress?: { done: number; total: number };
|
|
25
|
+
/** How long the task's agents have worked on it, idle time left out (ms, as of the reply). */
|
|
26
|
+
work?: { workedMs: number; running: boolean };
|
|
25
27
|
/** Who drives it, for tasks other sessions own. */
|
|
26
28
|
owner?: string;
|
|
27
29
|
/** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
|
package/src/master/decisions.ts
CHANGED
|
@@ -7,14 +7,17 @@ import { builtSomething, onFastTrack, qaRequired, qaTookPart } from "../workflow
|
|
|
7
7
|
/**
|
|
8
8
|
* §19: everything the engine requires before completion may be declared. The
|
|
9
9
|
* fast track asks for a finished worker step instead of a QA pass, and for
|
|
10
|
-
* QA's part only when the change needs tests
|
|
10
|
+
* QA's part only when the change needs tests; so does a planned task whose
|
|
11
|
+
* QA sat out the planning panel.
|
|
11
12
|
*/
|
|
12
13
|
export function completionBlockers(task: Task, pendingCount: number): string[] {
|
|
13
14
|
const blockers: string[] = [];
|
|
14
15
|
if (!task.plan) blockers.push("no approved plan is recorded");
|
|
15
16
|
if (onFastTrack(task)) {
|
|
16
17
|
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
17
|
-
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (
|
|
18
|
+
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (run the QA gate: action=qa)");
|
|
19
|
+
} else if (!qaRequired(task)) {
|
|
20
|
+
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
18
21
|
} else if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
|
|
19
22
|
if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
|
|
20
23
|
if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
|
package/src/master/master.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface WorkerRequest {
|
|
|
238
238
|
time?: AgentTime;
|
|
239
239
|
/** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
|
|
240
240
|
ask?: { onAsk: RelayAsk; previews: string };
|
|
241
|
+
/** A QA step's verification budget: Jev's QA risk read of the change so far. */
|
|
242
|
+
risk?: string;
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
/** What a worker that may ask the user is told about it. */
|
|
@@ -264,6 +266,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
|
|
|
264
266
|
? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
|
|
265
267
|
: "No scout findings were collected for your domain; verify the repository yourself.",
|
|
266
268
|
likely,
|
|
269
|
+
request.risk ?? "",
|
|
267
270
|
request.time?.note ?? "",
|
|
268
271
|
request.ask ? askGuidance(request.ask.previews) : "",
|
|
269
272
|
].filter(Boolean).join("\n\n");
|
|
@@ -344,6 +347,8 @@ export interface ReviewerRequest {
|
|
|
344
347
|
previousRound?: string;
|
|
345
348
|
/** The engine's lint result on the touched files and the suppressions the task added. */
|
|
346
349
|
lint?: string;
|
|
350
|
+
/** Jev's QA risk read of the change: the gate's depth, test budget and focus. */
|
|
351
|
+
risk?: string;
|
|
347
352
|
/** Under a task time budget: the gate's time. */
|
|
348
353
|
time?: AgentTime;
|
|
349
354
|
instruction?: string;
|
|
@@ -365,6 +370,7 @@ function reviewerContext(request: ReviewerRequest): string {
|
|
|
365
370
|
return [
|
|
366
371
|
`Task state: reviewing. Domain: ${request.domain}.`,
|
|
367
372
|
"You may not modify implementation. Report required changes instead.",
|
|
373
|
+
request.risk ?? "",
|
|
368
374
|
request.previousRound ?? "",
|
|
369
375
|
request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
|
|
370
376
|
owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
|
package/src/master/research.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import type { Domain } from "../schemas/agent.ts";
|
|
4
3
|
import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
|
|
@@ -39,21 +38,6 @@ export function saveResearchResult(taskDir: string, outcome: ResearchOutcome): v
|
|
|
39
38
|
writeFileEnsured(researchResultPath(taskDir, outcome.result.domain), JSON.stringify(outcome, null, 2));
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
/** Re-read persisted research reports for the given domains. */
|
|
43
|
-
export function loadResearchResults(taskDir: string, domains: Domain[]): ResearchOutcome[] {
|
|
44
|
-
const outcomes: ResearchOutcome[] = [];
|
|
45
|
-
for (const domain of domains) {
|
|
46
|
-
const path = researchResultPath(taskDir, domain);
|
|
47
|
-
if (!existsSync(path)) continue;
|
|
48
|
-
try {
|
|
49
|
-
outcomes.push(JSON.parse(readFileSync(path, "utf8")) as ResearchOutcome);
|
|
50
|
-
} catch {
|
|
51
|
-
// Corrupted research artifact: skip it rather than failing the workflow.
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return outcomes;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
41
|
function researchContext(request: ResearchRequest): AgentRequest["context"] {
|
|
58
42
|
return {
|
|
59
43
|
task: request.instruction,
|
package/src/pi/events.ts
CHANGED
|
@@ -13,11 +13,12 @@ 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
|
-
import { budgetLine, budgetState, pauseClocks, readBudget, resumeClocks, startClock, stopClocks } from "../state/budget.ts";
|
|
21
|
+
import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
|
|
21
22
|
|
|
22
23
|
/** Tools that wait on the user: the task's clock waits with them. */
|
|
23
24
|
const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
|
|
@@ -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)
|
|
@@ -77,10 +78,11 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
|
77
78
|
});
|
|
78
79
|
|
|
79
80
|
|
|
80
|
-
// A task's time budget counts while the oracle works on it, not while it waits on the user.
|
|
81
|
+
// A task's work time (and its time budget) counts while the oracle works on it, not while it waits on the user.
|
|
81
82
|
const asking = new Set<string>();
|
|
82
83
|
pi.on("agent_start", (_event, ctx) => {
|
|
83
84
|
if (isSubagentProcess()) return;
|
|
85
|
+
turnStarted();
|
|
84
86
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
85
87
|
const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
|
|
86
88
|
if (task) startClock(root, configDir, task.id);
|
|
@@ -92,9 +94,14 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
|
92
94
|
pauseClocks();
|
|
93
95
|
}
|
|
94
96
|
});
|
|
95
|
-
pi.on("tool_execution_end", (event) => {
|
|
97
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
96
98
|
if (isSubagentProcess()) return;
|
|
97
99
|
if (asking.delete(event.toolCallId)) resumeClocks();
|
|
100
|
+
// A task started, finished or taken over within the turn: the clock follows the task the session drives now.
|
|
101
|
+
if (event.toolName === "orchestrate") {
|
|
102
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
103
|
+
clockTask(root, configDir, activeTask(root, configDir, ctx.sessionManager.getSessionId())?.id);
|
|
104
|
+
}
|
|
98
105
|
});
|
|
99
106
|
pi.on("agent_end", () => {
|
|
100
107
|
if (isSubagentProcess()) return;
|
package/src/pi/model-settings.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { describeKey, jevEndpoint, type StatusSource } from "../classifier/hosts
|
|
|
18
18
|
|
|
19
19
|
/** One settings entry: the master or a subagent profile. */
|
|
20
20
|
export type SettingsKind = "master" | SubagentKind;
|
|
21
|
-
export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
|
|
22
21
|
|
|
23
22
|
/** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
|
|
24
23
|
export interface EntryView {
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -89,6 +89,19 @@ function stepSection(lines: readonly string[]): string[] | undefined {
|
|
|
89
89
|
return body;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The concise list a plan opens its steps with: a `Steps` section that comes
|
|
94
|
+
* before any `Step N` heading. The plan's detail then follows, often one
|
|
95
|
+
* `### Step N: …` section per step, and the list stays the checklist.
|
|
96
|
+
*/
|
|
97
|
+
function leadingStepList(lines: readonly string[]): string[] {
|
|
98
|
+
const section = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
|
|
99
|
+
if (section < 0) return [];
|
|
100
|
+
const heading = lines.findIndex((line) => STEP_HEADING.test(line));
|
|
101
|
+
if (heading >= 0 && heading < section) return [];
|
|
102
|
+
return collectSteps(stepSection(lines) ?? [], sectionItem);
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
/** `Step N` headings, when the plan is structured as one heading per step. */
|
|
93
106
|
function headingSteps(lines: readonly string[]): string[] {
|
|
94
107
|
const found: string[] = [];
|
|
@@ -100,13 +113,17 @@ function headingSteps(lines: readonly string[]): string[] {
|
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
/**
|
|
103
|
-
* Step texts from a free-form plan, capped.
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
116
|
+
* Step texts from a free-form plan, capped. A steps list the plan opens with
|
|
117
|
+
* wins (its detail may follow under `Step N` headings); then `Step N`
|
|
118
|
+
* headings; then a `sequence|steps|order` section; then numbered lines; when
|
|
119
|
+
* none exists, top-level bullets are the last resort, so unrelated bullet
|
|
120
|
+
* lists under other headers never leak into the checklist. Only the
|
|
121
|
+
* shallowest items count.
|
|
107
122
|
*/
|
|
108
123
|
export function planSteps(plan: string): string[] {
|
|
109
124
|
const lines = plan.split("\n");
|
|
125
|
+
const leading = leadingStepList(lines);
|
|
126
|
+
if (leading.length > 0) return leading.slice(0, MAX_PLAN_STEPS);
|
|
110
127
|
const headings = headingSteps(lines);
|
|
111
128
|
if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
|
|
112
129
|
const section = stepSection(lines);
|
|
@@ -119,6 +136,26 @@ export function planSteps(plan: string): string[] {
|
|
|
119
136
|
return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
|
|
120
137
|
}
|
|
121
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The plan without the steps list it opens with, for reading after the
|
|
141
|
+
* checklist (which already shows those steps); undefined when the checklist
|
|
142
|
+
* does not come from such a list, so the plan is read whole.
|
|
143
|
+
*/
|
|
144
|
+
export function planDetails(plan: string): string | undefined {
|
|
145
|
+
const lines = plan.split("\n");
|
|
146
|
+
if (leadingStepList(lines).length === 0) return undefined;
|
|
147
|
+
const heading = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
|
|
148
|
+
let to = lines.length;
|
|
149
|
+
for (let index = heading + 1; index < lines.length; index += 1) {
|
|
150
|
+
if (HEADER_LINE.test(lines[index]!)) {
|
|
151
|
+
to = index;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const rest = [...lines.slice(0, heading), ...lines.slice(to)].join("\n").trim();
|
|
156
|
+
return rest || undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
122
159
|
/** Where a plan's steps section sits, and each of its top-level steps whole (its nested lines included). */
|
|
123
160
|
export interface StepBlocks {
|
|
124
161
|
/** The section's body: from this line up to (not including) `to`. */
|
|
@@ -239,15 +276,23 @@ function stepScore(step: string, instruction: InstructionIndex): number {
|
|
|
239
276
|
* named, so "building on step 1, now do step 3" does not jump back to step 1.
|
|
240
277
|
*/
|
|
241
278
|
export function explicitStepIndex(instruction: string, count: number): number {
|
|
279
|
+
return explicitStepRange(instruction, count)?.[1] ?? -1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The 0-based steps an instruction labels itself with, first and last ("Steps 2-4: ..." is 1 to 3); undefined when none. */
|
|
283
|
+
export function explicitStepRange(instruction: string, count: number): [number, number] | undefined {
|
|
242
284
|
const refs = [...instruction.matchAll(STEP_REFERENCE)];
|
|
243
|
-
if (refs.length === 0) return
|
|
285
|
+
if (refs.length === 0) return undefined;
|
|
244
286
|
const first = refs[0]!;
|
|
245
287
|
const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
|
|
246
288
|
const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
|
|
247
289
|
const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
|
|
248
|
-
if (!chosen) return
|
|
249
|
-
const
|
|
250
|
-
|
|
290
|
+
if (!chosen) return undefined;
|
|
291
|
+
const a = Number(chosen[1]);
|
|
292
|
+
const b = Number(chosen[2] ?? chosen[1]);
|
|
293
|
+
const last = Math.max(a, b);
|
|
294
|
+
if (last < 1 || last > count) return undefined;
|
|
295
|
+
return [Math.max(1, Math.min(a, b)) - 1, last - 1];
|
|
251
296
|
}
|
|
252
297
|
|
|
253
298
|
/**
|
|
@@ -345,3 +390,48 @@ export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep
|
|
|
345
390
|
checklistMemo = { plan, runs, steps };
|
|
346
391
|
return steps;
|
|
347
392
|
}
|
|
393
|
+
|
|
394
|
+
/* -------------------------------------------------------------------------
|
|
395
|
+
* Step work: how long the workers have worked on each step, and which steps
|
|
396
|
+
* they are on right now, for the checklist's `active · 1m 12s` marks.
|
|
397
|
+
* ---------------------------------------------------------------------- */
|
|
398
|
+
|
|
399
|
+
export interface StepWork {
|
|
400
|
+
/** Worker time on the step so far, in ms (the running runs up to `now`). */
|
|
401
|
+
ms: number;
|
|
402
|
+
/** A worker is on it right now. */
|
|
403
|
+
active: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The steps a run works on: every step of a range it names, else the step it targets, else the next one still open. */
|
|
407
|
+
function runSteps(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number>): number[] {
|
|
408
|
+
const range = instruction ? explicitStepRange(instruction, steps.length) : undefined;
|
|
409
|
+
if (range) return Array.from({ length: range[1] - range[0] + 1 }, (_value, offset) => range[0] + offset);
|
|
410
|
+
const target = targetStep(steps, instruction, completed);
|
|
411
|
+
const step = target >= 0 ? target : nextOpenStep(steps, completed);
|
|
412
|
+
return step >= 0 ? [step] : [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Each step's worker time and whether a worker is on it now, replaying the
|
|
417
|
+
* runs in start order as the checklist does. A run counts in full toward each
|
|
418
|
+
* step it works on (a `Steps 2-4` run toward all three), and its success
|
|
419
|
+
* completes them, so a later run that names no step goes to the next one.
|
|
420
|
+
*/
|
|
421
|
+
export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
|
|
422
|
+
const work = steps.map(() => ({ ms: 0, active: false }));
|
|
423
|
+
const completed = new Set<number>();
|
|
424
|
+
for (const run of byStart(runs)) {
|
|
425
|
+
if (run.role !== "worker") continue;
|
|
426
|
+
const covered = runSteps(steps, run.instruction, completed);
|
|
427
|
+
const start = Date.parse(run.startedAt);
|
|
428
|
+
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
429
|
+
const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
|
|
430
|
+
for (const index of covered) {
|
|
431
|
+
work[index]!.ms += ms;
|
|
432
|
+
if (run.status === "running") work[index]!.active = true;
|
|
433
|
+
}
|
|
434
|
+
if (run.status === "success" && covered.length > 0) markThrough(completed, covered[covered.length - 1]!);
|
|
435
|
+
}
|
|
436
|
+
return work;
|
|
437
|
+
}
|
package/src/pi/route.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
14
14
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { Domain } from "../schemas/agent.ts";
|
|
17
|
-
import type { TaskTriage } from "../schemas/task.ts";
|
|
18
17
|
import { chooseRoute, type RequestRoute } from "../workflow/track.ts";
|
|
19
18
|
import { triageFor } from "../classifier/instance.ts";
|
|
20
19
|
import { ownedTask } from "../state/persistence.ts";
|
package/src/pi/run-summary.ts
CHANGED
|
@@ -124,60 +124,3 @@ export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
|
|
|
124
124
|
...(entry.error ? { error: entry.error } : {}),
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
/** Silence after which a working agent reads as "quiet" in the scene. */
|
|
129
|
-
export const QUIET_MS = 45_000;
|
|
130
|
-
|
|
131
|
-
/** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
|
|
132
|
-
export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
|
|
133
|
-
if (run.status !== "running") return 0;
|
|
134
|
-
const last = run.lastEventAt ?? Date.parse(run.startedAt);
|
|
135
|
-
const quiet = now - last;
|
|
136
|
-
return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface FeedLine {
|
|
140
|
-
text: string;
|
|
141
|
-
kind: "info" | "warning";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
|
|
145
|
-
export const FEED_ROTATE_TICKS = 12;
|
|
146
|
-
|
|
147
|
-
/** What one working agent is doing right now, with a warning when something needs attention. */
|
|
148
|
-
function liveFeed(run: AgentRun, now: number): FeedLine {
|
|
149
|
-
const name = agentName(run);
|
|
150
|
-
if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
|
|
151
|
-
const quiet = quietFor(run, now);
|
|
152
|
-
if (quiet >= QUIET_MS) {
|
|
153
|
-
const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
|
|
154
|
-
return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
|
|
155
|
-
}
|
|
156
|
-
if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
|
|
157
|
-
const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
|
|
158
|
-
const turns = run.turns ?? run.usage?.turns;
|
|
159
|
-
if (turns) parts.push(`turn ${turns}`);
|
|
160
|
-
if (run.tools) parts.push(`${run.tools} tools`);
|
|
161
|
-
if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
|
|
162
|
-
if (run.note) parts.push(run.note);
|
|
163
|
-
return { text: parts.join(" · "), kind: "info" };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The scene's feed row: a working agent that needs attention first, otherwise
|
|
168
|
-
* the working agents in rotation, otherwise the most recent finished run.
|
|
169
|
-
* Undefined before anything has run.
|
|
170
|
-
*/
|
|
171
|
-
export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
|
|
172
|
-
const running = runs.filter((run) => run.status === "running");
|
|
173
|
-
if (running.length > 0) {
|
|
174
|
-
const lines = running.map((run) => liveFeed(run, now));
|
|
175
|
-
const warning = lines.find((line) => line.kind === "warning");
|
|
176
|
-
if (warning) return warning;
|
|
177
|
-
const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
|
|
178
|
-
return lines[index];
|
|
179
|
-
}
|
|
180
|
-
const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
|
|
181
|
-
if (!last) return undefined;
|
|
182
|
-
return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
|
|
183
|
-
}
|