@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.
Files changed (54) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -5
  3. package/prompts/master.md +36 -10
  4. package/prompts/planner.md +5 -2
  5. package/prompts/qa.md +139 -77
  6. package/prompts/reviewer.md +4 -0
  7. package/src/classifier/hosts.ts +0 -11
  8. package/src/classifier/instance.ts +0 -5
  9. package/src/classifier/qa-risk.ts +537 -0
  10. package/src/execution/agent-runner.ts +0 -18
  11. package/src/knowledge/compactor.ts +0 -70
  12. package/src/lobby/ask.ts +0 -15
  13. package/src/lobby/host.ts +2 -0
  14. package/src/lobby/issues.ts +0 -8
  15. package/src/lobby/planner.ts +14 -0
  16. package/src/lobby/pulls.ts +1 -44
  17. package/src/lobby/runtime.ts +1 -1
  18. package/src/lobby/service.ts +3 -1
  19. package/src/lobby/session-files.ts +1 -45
  20. package/src/lobby/split.ts +33 -1
  21. package/src/lobby/task-rows.ts +2 -0
  22. package/src/master/decisions.ts +5 -2
  23. package/src/master/master.ts +6 -0
  24. package/src/master/research.ts +0 -16
  25. package/src/pi/events.ts +13 -6
  26. package/src/pi/model-settings.ts +0 -1
  27. package/src/pi/plan-checklist.ts +98 -8
  28. package/src/pi/route.ts +0 -1
  29. package/src/pi/run-summary.ts +0 -57
  30. package/src/pi/start-task.ts +31 -4
  31. package/src/pi/tools.ts +3 -0
  32. package/src/schemas/agent.ts +0 -4
  33. package/src/schemas/configuration.ts +2 -4
  34. package/src/schemas/task.ts +32 -0
  35. package/src/state/backlog.ts +19 -3
  36. package/src/state/budget.ts +49 -22
  37. package/src/state/metrics.ts +0 -1
  38. package/src/state/persistence.ts +0 -5
  39. package/src/state/work-time.ts +123 -0
  40. package/src/webui/api/index.ts +0 -5
  41. package/src/webui/api/lobby.ts +13 -6
  42. package/src/webui/api/plan-facts.ts +36 -6
  43. package/src/webui/api/tasks.ts +28 -6
  44. package/src/webui/dev/fake-service.ts +6 -0
  45. package/src/webui/dev/fixtures.ts +2 -0
  46. package/src/webui/protocol.ts +23 -3
  47. package/src/workflow/track.ts +46 -7
  48. package/src/workflow/workflow.ts +231 -42
  49. package/webui/dist/assets/index-BUzOUrih.css +2 -0
  50. package/webui/dist/assets/index-myC4UiEg.js +108 -0
  51. package/webui/dist/build.json +37 -41
  52. package/webui/dist/index.html +2 -2
  53. package/webui/dist/assets/index-BRVbs5In.js +0 -105
  54. package/webui/dist/assets/index-BeRXVi_r.css +0 -2
@@ -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[] = [];
@@ -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?.();
@@ -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 { execCommand, gh, type Exec } from "./issues.ts";
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[] = [];
@@ -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 { jobTitle, QuickFixQueue } from "./quickfix.ts";
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";
@@ -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 DialogAnswer, type SessionLauncher } from "./sessions.ts";
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 { chatFromEntries, chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
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 {
@@ -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. */
@@ -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`. */
@@ -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 (give qa the tests as the last step, or run the QA gate)");
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)`);
@@ -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)}` : "",
@@ -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}): follow it, do not ask them to approve a proposal (propose is approved automatically), and answer open questions from the plan.` : "",
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;
@@ -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 {
@@ -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. `Step N` headings win; then a
104
- * `sequence|steps|order` section; then numbered lines; when none exists,
105
- * top-level bullets are the last resort, so unrelated bullet lists under other
106
- * headers never leak into the checklist. Only the shallowest items count.
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 -1;
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 -1;
249
- const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
250
- return last >= 1 && last <= count ? last - 1 : -1;
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";
@@ -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
- }