@a-t-h-i/bot-lobby 0.1.0 → 0.3.0

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/src/pi/zen.ts CHANGED
@@ -67,9 +67,12 @@ const PREFIX_CHARS = 32;
67
67
  const MIN_PREFIX = 8;
68
68
  const HEADER_LINE = /^\s*(?:#{1,6}\s+\S.*|\*\*[^*]+\*\*)\s*$/;
69
69
  const STEP_SECTION = /sequence|steps|order/i;
70
- const NUMBERED_STEP_LINE = /^\s*\d+[.)]\s+(.*\S)\s*$/;
71
- const INDENTED_BULLET = /^\s*[*-]\s+(.*\S)\s*$/;
72
- const TOP_LEVEL_BULLET = /^[*-]\s+(.*\S)\s*$/;
70
+ const NUMBERED_STEP_LINE = /^(\s*)(\d+[.)])\s+(.*\S)\s*$/;
71
+ const BULLET_LINE = /^(\s*)([*-])\s+(.*\S)\s*$/;
72
+ const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
73
+ /** `### Step 2: Wire the API`, `**Step 2 — Wire the API**`, `Phase 3) Tests`: one plan step per heading. */
74
+ const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
75
+ const MIN_STEP_HEADINGS = 2;
73
76
 
74
77
  export type PlanStepStatus = "done" | "current" | "pending";
75
78
 
@@ -78,20 +81,54 @@ export interface PlanStep {
78
81
  status: PlanStepStatus;
79
82
  }
80
83
 
81
- /** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
82
- function stepText(line: string): string | undefined {
83
- return NUMBERED_STEP_LINE.exec(line)?.[1] ?? INDENTED_BULLET.exec(line)?.[1];
84
+ interface ListItem {
85
+ indent: number;
86
+ /** Column where the item's text starts; deeper items are nested under it. */
87
+ content: number;
88
+ text: string;
89
+ }
90
+
91
+ type ItemMatcher = (line: string) => ListItem | undefined;
92
+
93
+ function indentOf(line: string): number {
94
+ return /^\s*/.exec(line)![0].replace(/\t/g, " ").length;
95
+ }
96
+
97
+ function itemMatcher(pattern: RegExp): ItemMatcher {
98
+ return (line) => {
99
+ const match = pattern.exec(line);
100
+ if (!match) return undefined;
101
+ const indent = indentOf(match[1]!);
102
+ return { indent, content: indent + match[2]!.length + 1, text: match[3]! };
103
+ };
84
104
  }
85
105
 
86
- function topBulletText(line: string): string | undefined {
87
- return TOP_LEVEL_BULLET.exec(line)?.[1];
106
+ const numberedItem = itemMatcher(NUMBERED_STEP_LINE);
107
+ const bulletItem = itemMatcher(BULLET_LINE);
108
+ const topBulletItem = itemMatcher(TOP_LEVEL_BULLET);
109
+
110
+ /** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
111
+ function sectionItem(line: string): ListItem | undefined {
112
+ return numberedItem(line) ?? bulletItem(line);
88
113
  }
89
114
 
90
- function collectSteps(lines: readonly string[], accept: (line: string) => string | undefined): string[] {
115
+ /**
116
+ * Top-level list items only. As in CommonMark, an item indented to its parent's
117
+ * text column is a sub-point of that step, so nested bullets or a nested `1.`
118
+ * list never inflate the checklist. Prose at a shallower indent ends the parent.
119
+ */
120
+ function collectSteps(lines: readonly string[], accept: ItemMatcher): string[] {
91
121
  const found: string[] = [];
122
+ let parent: number | undefined;
92
123
  for (const line of lines) {
93
- const text = accept(line);
94
- if (text !== undefined) found.push(text);
124
+ const item = accept(line);
125
+ if (!item) {
126
+ if (parent !== undefined && line.trim() && indentOf(line) < parent) parent = undefined;
127
+ continue;
128
+ }
129
+ if (parent !== undefined && item.indent >= parent) continue;
130
+ found.push(item.text);
131
+ parent = item.content;
95
132
  }
96
133
  return found;
97
134
  }
@@ -108,37 +145,143 @@ function stepSection(lines: readonly string[]): string[] | undefined {
108
145
  return body;
109
146
  }
110
147
 
148
+ /** `Step N` headings, when the plan is structured as one heading per step. */
149
+ function headingSteps(lines: readonly string[]): string[] {
150
+ const found: string[] = [];
151
+ for (const line of lines) {
152
+ const match = STEP_HEADING.exec(line);
153
+ if (match) found.push(match[1] || line.replace(/[#*]/g, "").trim());
154
+ }
155
+ return found.length >= MIN_STEP_HEADINGS ? found : [];
156
+ }
157
+
111
158
  /**
112
- * Step texts from a free-form plan, capped. A `sequence|steps|order` section wins; otherwise
113
- * numbered lines win; when neither exists, top-level bullets are the last resort, so unrelated
114
- * bullet lists under other headers never leak into the checklist.
159
+ * Step texts from a free-form plan, capped. `Step N` headings win; then a
160
+ * `sequence|steps|order` section; then numbered lines; when none exists,
161
+ * top-level bullets are the last resort, so unrelated bullet lists under other
162
+ * headers never leak into the checklist. Only the shallowest items count.
115
163
  */
116
164
  export function planSteps(plan: string): string[] {
117
165
  const lines = plan.split("\n");
166
+ const headings = headingSteps(lines);
167
+ if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
118
168
  const section = stepSection(lines);
119
169
  if (section) {
120
- const sectioned = collectSteps(section, stepText);
170
+ const sectioned = collectSteps(section, sectionItem);
121
171
  if (sectioned.length > 0) return sectioned.slice(0, MAX_PLAN_STEPS);
122
172
  }
123
- const numbered = collectSteps(lines, (line) => NUMBERED_STEP_LINE.exec(line)?.[1]);
173
+ const numbered = collectSteps(lines, numberedItem);
124
174
  if (numbered.length > 0) return numbered.slice(0, MAX_PLAN_STEPS);
125
- return collectSteps(lines, topBulletText).slice(0, MAX_PLAN_STEPS);
175
+ return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
126
176
  }
127
177
 
178
+ /* -------------------------------------------------------------------------
179
+ * Step matching. A worker instruction names its step explicitly ("Step 3: ...")
180
+ * or is scored against every step by shared paths, a shared opening phrase and
181
+ * word overlap. Plans reuse file paths across steps, so near-ties go to the
182
+ * earliest step still open instead of the first step that ever mentioned the
183
+ * path -- otherwise every later instruction re-matches step 1 and the tracker
184
+ * never moves.
185
+ * ---------------------------------------------------------------------- */
186
+
187
+ const STEP_REFERENCE = /\bsteps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|through|thru|and|&)\s*#?\s*(\d+))?/gi;
188
+ /** Text allowed before a step reference that labels the instruction itself ("Now implement step 3:"). */
189
+ const LEADING_LABEL = /^[\s\W]*(?:(?:now|next|then|please|implement|do|complete|execute|start|begin|finish|work on|continue with|proceed with|plan)\s+)*(?:the\s+)?$/i;
190
+ const MIN_SCORE = 0.5;
191
+ const NEAR_TIE = 0.35;
192
+ const PATH_WEIGHT = 0.75;
193
+ const PREFIX_WEIGHT = 1;
194
+ const WORD = /[a-z0-9_][a-z0-9_./-]*[a-z0-9_]/g;
195
+ const MIN_WORD = 3;
196
+ const STOP_WORDS = new Set([
197
+ "the", "and", "for", "with", "that", "this", "from", "into", "then", "when", "each", "use", "make",
198
+ "sure", "new", "all", "any", "its", "are", "not", "but", "now", "via", "per", "our", "your", "you",
199
+ "has", "have", "will", "should", "must", "also", "only", "step", "steps", "plan", "approved",
200
+ "implement", "please", "add", "update", "change", "changes", "file", "files", "code",
201
+ ]);
202
+
128
203
  function normalize(text: string): string {
129
204
  return text.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
130
205
  }
131
206
 
132
- function matchesInstruction(step: string, instruction: string): boolean {
133
- const hay = normalize(instruction);
134
- const path = /`([^`]+)`/.exec(step)?.[1];
135
- if (path && hay.includes(normalize(path))) return true;
136
- const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.—–-]+/, ""));
207
+ function significantWords(text: string): Set<string> {
208
+ const words = new Set<string>();
209
+ for (const word of normalize(text).match(WORD) ?? []) {
210
+ if (word.length >= MIN_WORD && !STOP_WORDS.has(word)) words.add(word);
211
+ }
212
+ return words;
213
+ }
214
+
215
+ /** Scoring context for one instruction, built once per run and reused for every step. */
216
+ interface InstructionIndex {
217
+ text: string;
218
+ words: Set<string>;
219
+ }
220
+
221
+ function indexInstruction(instruction: string): InstructionIndex {
222
+ return { text: normalize(instruction), words: significantWords(instruction) };
223
+ }
224
+
225
+ function prefixMatches(step: string, hay: string): boolean {
226
+ const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.\u2014\u2013-]+/, ""));
137
227
  return tail.length >= MIN_PREFIX && hay.includes(tail.slice(0, PREFIX_CHARS));
138
228
  }
139
229
 
230
+ function pathShare(step: string, hay: string): number {
231
+ const paths = [...step.matchAll(/`([^`]+)`/g)].map((match) => normalize(match[1]!)).filter((path) => path.length > 0);
232
+ if (paths.length === 0) return 0;
233
+ return paths.filter((path) => hay.includes(path)).length / paths.length;
234
+ }
235
+
236
+ function wordShare(step: string, words: ReadonlySet<string>): number {
237
+ const own = significantWords(step);
238
+ if (own.size === 0) return 0;
239
+ let shared = 0;
240
+ for (const word of own) if (words.has(word)) shared += 1;
241
+ return shared / own.size;
242
+ }
243
+
244
+ /** How strongly an instruction targets one step; 0 when it shares nothing. */
245
+ function stepScore(step: string, instruction: InstructionIndex): number {
246
+ const prefix = prefixMatches(step, instruction.text) ? PREFIX_WEIGHT : 0;
247
+ return prefix + PATH_WEIGHT * pathShare(step, instruction.text) + wordShare(step, instruction.words);
248
+ }
249
+
250
+ /**
251
+ * Highest 0-based step an instruction labels itself with ("Step 3: ...", "steps 2-4"),
252
+ * or -1. A reference counts when it opens the instruction or is the only one
253
+ * named, so "building on step 1, now do step 3" does not jump back to step 1.
254
+ */
255
+ export function explicitStepIndex(instruction: string, count: number): number {
256
+ const refs = [...instruction.matchAll(STEP_REFERENCE)];
257
+ if (refs.length === 0) return -1;
258
+ const first = refs[0]!;
259
+ const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
260
+ const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
261
+ const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
262
+ if (!chosen) return -1;
263
+ const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
264
+ return last >= 1 && last <= count ? last - 1 : -1;
265
+ }
266
+
267
+ /**
268
+ * The step an instruction targets, given the steps already completed; -1 when
269
+ * nothing matches. Among near-tied candidates the earliest open step wins.
270
+ */
271
+ export function targetStep(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number> = new Set()): number {
272
+ if (!instruction || steps.length === 0) return -1;
273
+ const explicit = explicitStepIndex(instruction, steps.length);
274
+ if (explicit >= 0) return explicit;
275
+ const index = indexInstruction(instruction);
276
+ const scores = steps.map((step) => stepScore(step, index));
277
+ const best = Math.max(...scores);
278
+ if (best < MIN_SCORE) return -1;
279
+ const near = scores.findIndex((score, at) => score >= best - NEAR_TIE && score >= MIN_SCORE && !completed.has(at));
280
+ return near >= 0 ? near : scores.indexOf(best);
281
+ }
282
+
140
283
  /** The latest worker run carrying an instruction; its step is the current one. */
141
- export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
284
+ export function latestWorkerRun(runs: readonly AgentRun[]): AgentRun | undefined {
142
285
  let latest: AgentRun | undefined;
143
286
  for (const run of runs) {
144
287
  if (run.role !== "worker" || !run.instruction) continue;
@@ -147,11 +290,6 @@ export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
147
290
  return latest;
148
291
  }
149
292
 
150
- /** Index of the plan step an instruction targets, -1 when nothing matches. */
151
- export function currentStepIndex(steps: readonly string[], instruction: string | undefined): number {
152
- return instruction ? steps.findIndex((step) => matchesInstruction(step, instruction)) : -1;
153
- }
154
-
155
293
  function stepStatus(index: number, current: number): PlanStepStatus {
156
294
  if (current < 0) return index === 0 ? "current" : "pending";
157
295
  if (index < current) return "done";
@@ -168,37 +306,58 @@ function markThrough(completed: Set<number>, index: number): void {
168
306
  for (let step = 0; step <= index; step += 1) completed.add(step);
169
307
  }
170
308
 
309
+ function byStart(runs: readonly AgentRun[]): AgentRun[] {
310
+ const time = (run: AgentRun) => {
311
+ const at = Date.parse(run.startedAt);
312
+ return Number.isFinite(at) ? at : 0;
313
+ };
314
+ return runs
315
+ .map((run, order) => ({ run, order }))
316
+ .sort((a, b) => time(a.run) - time(b.run) || a.order - b.order)
317
+ .map((entry) => entry.run);
318
+ }
319
+
171
320
  /**
172
- * Steps completed by successful worker runs, in start order. A run whose instruction matches
173
- * a step completes everything up to that step; a run that matches nothing -- the common case
174
- * for a reworded instruction -- completes the next still-open step, so the count only grows
175
- * and a failure can never tick one off.
321
+ * Replays worker runs in start order. A successful run completes everything up
322
+ * to its target step; a run that matches nothing -- the common case for a
323
+ * reworded instruction -- completes the next still-open step, so the count only
324
+ * grows and a failure can never tick one off. The latest worker run's own target
325
+ * is reported separately so a running or failed step reads current.
176
326
  */
177
- function completedSteps(steps: readonly string[], runs: AgentRun[]): number {
327
+ function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number; latest: number } {
178
328
  const completed = new Set<number>();
179
- for (const run of runs) {
180
- if (run.role !== "worker" || run.status !== "success") continue;
181
- const matched = currentStepIndex(steps, run.instruction);
329
+ const latestRun = latestWorkerRun(runs);
330
+ let latest = -1;
331
+ for (const run of byStart(runs)) {
332
+ if (run.role !== "worker") continue;
333
+ const matched = targetStep(steps, run.instruction, completed);
334
+ if (run === latestRun) latest = matched >= 0 && run.status === "success" ? matched + 1 : matched;
335
+ if (run.status !== "success") continue;
182
336
  const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
183
337
  if (target >= 0) markThrough(completed, target);
184
338
  }
185
- return completed.size;
339
+ return { completed: completed.size, latest };
186
340
  }
187
341
 
342
+ /** One-entry memo: the panel asks for the same checklist several times per frame. */
343
+ let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
344
+
188
345
  /**
189
- * Done/current/pending per plan step, matched from the latest worker instruction.
190
- * A succeeded run completes its step, so the next step becomes current (and the
191
- * last step reads done); running, failed, cancelled and timeout runs keep it current.
192
- * Progress is monotonic: steps already completed by successful worker runs stay done,
193
- * and a later call that names an earlier step can never tick it back.
346
+ * Done/current/pending per plan step. A succeeded run completes its step, so the
347
+ * next step becomes current (and the last step reads done); running, failed,
348
+ * cancelled and timeout runs keep it current. Progress is monotonic: steps
349
+ * already completed by successful worker runs stay done, and a later call that
350
+ * names an earlier step can never tick it back. Memoized on the exact plan text
351
+ * and runs array, so callers must not mutate either.
194
352
  */
195
- export function planChecklist(plan: string, runs: AgentRun[]): PlanStep[] {
196
- const steps = planSteps(plan);
197
- const latest = latestWorkerRun(runs);
198
- const matched = currentStepIndex(steps, latest?.instruction);
199
- const latestCurrent = matched >= 0 ? (latest?.status === "success" ? matched + 1 : matched) : -1;
200
- const current = Math.max(completedSteps(steps, runs), latestCurrent);
201
- return steps.map((text, index) => ({ text, status: stepStatus(index, current) }));
353
+ export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
354
+ if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
355
+ const texts = planSteps(plan);
356
+ const replay = replaySteps(texts, runs);
357
+ const current = Math.max(replay.completed, replay.latest);
358
+ const steps = texts.map((text, index) => ({ text, status: stepStatus(index, current) }));
359
+ checklistMemo = { plan, runs, steps };
360
+ return steps;
202
361
  }
203
362
 
204
363
  /** Up to `count` consecutive step indexes centered on the current step, hard-capped at `max`. */
@@ -211,7 +370,7 @@ export function checklistWindow(steps: readonly PlanStep[], count: number, max =
211
370
  return Array.from({ length: size }, (_value, offset) => start + offset);
212
371
  }
213
372
 
214
- function stepLine(step: PlanStep, ordinal: number): string {
373
+ function checklistRow(step: PlanStep, ordinal: number): string {
215
374
  const icon = step.status === "done" ? "✓" : step.status === "current" ? "◐" : "○";
216
375
  return ` ${icon} ${ordinal}. ${step.text}`;
217
376
  }
@@ -329,7 +488,7 @@ function tailLines(task: Task, runs: AgentRun[], now: number, tick: number, step
329
488
 
330
489
  function checklistLines(steps: PlanStep[], count: number): string[] {
331
490
  if (steps.length === 0 || count <= 0) return [];
332
- return checklistWindow(steps, count).map((index) => stepLine(steps[index]!, index + 1));
491
+ return checklistWindow(steps, count).map((index) => checklistRow(steps[index]!, index + 1));
333
492
  }
334
493
 
335
494
  function compactPanel(
@@ -378,9 +537,9 @@ function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames): Large
378
537
  }));
379
538
  }
380
539
 
381
- function oracleSlot(task: Task, expressions: ExpressionFrames): LargeSceneInput["oracle"] {
540
+ function oracleSlot(task: Task, expressions: ExpressionFrames, motion: OracleMotion): LargeSceneInput["oracle"] {
382
541
  const pose = oraclePose(task);
383
- return { pose, frame: expressions.oracle ?? REST_FRAME };
542
+ return { pose, frame: expressions.oracle ?? REST_FRAME, ...motion };
384
543
  }
385
544
 
386
545
  function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
@@ -397,8 +556,7 @@ function sceneInput(
397
556
  quiet: boolean,
398
557
  tick: number,
399
558
  steps: PlanStep[],
400
- expressions: ExpressionFrames,
401
- oracleActivity: string | undefined,
559
+ opts: PanelOptions,
402
560
  ): LargeSceneInput {
403
561
  const metrics = sceneMetrics(task, runs, now);
404
562
  const alert = taskAlert(task);
@@ -411,10 +569,11 @@ function sceneInput(
411
569
  tick,
412
570
  done: metrics.done,
413
571
  total: metrics.total,
414
- slots: sceneSlots(metrics, expressions),
572
+ slots: sceneSlots(metrics, opts.expressions ?? {}),
415
573
  tasks: sceneTasks(steps),
416
- oracle: oracleSlot(task, expressions),
417
- oracleActivity,
574
+ oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
575
+ oracleActivity: opts.oracleActivity,
576
+ caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
418
577
  alert: alert?.text,
419
578
  alertKind: alert?.kind,
420
579
  };
@@ -429,8 +588,16 @@ export interface PanelOptions {
429
588
  theme?: PanelTheme;
430
589
  /** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
431
590
  expressions?: ExpressionFrames;
432
- /** Live master activity word for the oracle spinner; absent reads "working". */
591
+ /** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
433
592
  oracleActivity?: string;
593
+ /** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
594
+ oracleMotion?: OracleMotion;
595
+ }
596
+
597
+ /** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
598
+ export interface OracleMotion {
599
+ phase?: number;
600
+ talk?: number;
434
601
  }
435
602
 
436
603
  /**
@@ -453,7 +620,7 @@ export function panelLines(
453
620
  const steps = planChecklist(task.plan ?? "", runs);
454
621
  const budget = largeLineBudget(opts.rows ?? DEFAULT_ROWS);
455
622
  if (width >= LARGE_MIN_WIDTH && budget >= MIN_LARGE_LINES) {
456
- const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts.expressions ?? {}, opts.oracleActivity), width, budget, opts.theme);
623
+ const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme);
457
624
  return scene.map((line) => truncateToWidth(line, width));
458
625
  }
459
626
  return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
@@ -54,6 +54,22 @@ export interface ReviewRecord {
54
54
  requiredChanges: string[];
55
55
  createdAt: string;
56
56
  }
57
+ /**
58
+ * One finished worker delegation, kept on the task so the plan checklist can be
59
+ * replayed after a reload instead of resetting to the first step.
60
+ */
61
+ export interface WorkerRunRecord {
62
+ runId: string;
63
+ domain: Domain;
64
+ instruction: string;
65
+ status: "running" | "success" | "failed" | "cancelled" | "timeout";
66
+ startedAt: string;
67
+ finishedAt?: string;
68
+ }
69
+
70
+ /** Upper bound on persisted worker records; plans cap at 50 steps. */
71
+ export const MAX_WORKER_RECORDS = 64;
72
+
57
73
  export interface Task {
58
74
  id: string;
59
75
  title: string;
@@ -71,6 +87,8 @@ export interface Task {
71
87
  blockers: Blocker[];
72
88
  decisions: Decision[];
73
89
  approvals: Approval[];
90
+ /** Worker delegations in start order; absent on tasks created before tracking. */
91
+ workerRuns?: WorkerRunRecord[];
74
92
  createdAt: string;
75
93
  updatedAt: string;
76
94
  /** The pi session (ctx.sessionManager id) that owns this task; absent on legacy tasks. */
@@ -1,7 +1,17 @@
1
1
  import { join } from "node:path";
2
2
  import type { BotLobbyConfig } from "../schemas/configuration.ts";
3
3
  import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
4
- import { TASK_STATES, TERMINAL_STATES, taskRequest, type Approval, type ApprovalKind, type Task, type TaskState } from "../schemas/task.ts";
4
+ import {
5
+ MAX_WORKER_RECORDS,
6
+ TASK_STATES,
7
+ TERMINAL_STATES,
8
+ taskRequest,
9
+ type Approval,
10
+ type ApprovalKind,
11
+ type Task,
12
+ type TaskState,
13
+ type WorkerRunRecord,
14
+ } from "../schemas/task.ts";
5
15
  import { isDomain, type Domain } from "../schemas/agent.ts";
6
16
  import { transition } from "../state/task-state.ts";
7
17
  import { ownerlessTask, readTaskArtifact, removeTaskScratchpads, saveTask, selectTask, taskDirFor, taskReadDirs } from "../state/persistence.ts";
@@ -495,6 +505,19 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
495
505
  };
496
506
  }
497
507
 
508
+ /** Remember a worker delegation so the checklist replays it after a reload. */
509
+ function recordWorkerRun(task: Task, run: AgentRun): void {
510
+ const record: WorkerRunRecord = {
511
+ runId: run.runId,
512
+ domain: run.domain,
513
+ instruction: run.instruction ?? "",
514
+ status: run.status,
515
+ startedAt: run.startedAt,
516
+ ...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
517
+ };
518
+ task.workerRuns = [...(task.workerRuns ?? []), record].slice(-MAX_WORKER_RECORDS);
519
+ }
520
+
498
521
  async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
499
522
  requireState(task, ["planning", "implementing", "reviewing"]);
500
523
  const domain = parseDomain(params.domain, "implement");
@@ -504,6 +527,7 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
504
527
  if (!task.domains.includes(domain)) task.domains.push(domain);
505
528
  if (task.state !== "implementing") transition(task, "implementing");
506
529
  const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
530
+ recordWorkerRun(task, outcome.run);
507
531
  const approvals = recordWorkerApprovals(task, outcome, deps.config);
508
532
  const pushback = recordPushback(task, outcome);
509
533
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];