@dirwin517/bot-farm 0.1.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.
Files changed (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
@@ -0,0 +1,2145 @@
1
+ // Personas and pipelines: definitions from YAML, runs on top of tasks.
2
+ //
3
+ // A persona is a bot template (system prompt, model, tools, mesh policy).
4
+ // A pipeline is an ordered list of stages, each naming a persona and — this is
5
+ // the part that matters — naming which earlier handoffs it receives. Dropping
6
+ // the QA write-up from the reviewer's prompt is an edit to one line of YAML.
7
+ //
8
+ // Running a pipeline creates: one worktree, one session per distinct persona,
9
+ // one task per stage chained by dependsOn, and one group chat so the bots can
10
+ // talk without going through the operator.
11
+
12
+ import * as git from "./git.mjs"
13
+ import { normalizeQuestions, questionText, formatAnswers } from "./questions.mjs"
14
+ import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"
15
+ import { join, basename } from "node:path"
16
+ import * as YAML from "./yaml.mjs"
17
+ import { render, variables } from "./template.mjs"
18
+ import { difficulty, routeFor, trimText, trimHandoff, localBrief, stuckReason } from "./craft.mjs"
19
+ import { xpForCard, budgetBonus, perksAt } from "./farmhands.mjs"
20
+ import { overQuota } from "./quota.mjs"
21
+ import { mkdir as mkdirp, writeFile as writeF } from "node:fs/promises"
22
+ import { buildPacket } from "./packets.mjs"
23
+
24
+ const DEFAULT_HANDOFF_PROMPT = `
25
+ When you have finished, call botfarm_task_complete with:
26
+ summary what you did, in enough detail that the next bot needs nothing else
27
+ acceptance_criteria the criteria you believe now hold (or the ones you set)
28
+ artifacts files you created or changed, as paths
29
+ open_questions anything you could not resolve
30
+ Do not mark the task complete until the work is actually done.`
31
+
32
+ export class Pipelines {
33
+ constructor({ dir, tasks, supervisor }) {
34
+ this.dir = dir // ~/.botfarm
35
+ this.tasks = tasks
36
+ this.sup = supervisor
37
+ this.personas = new Map()
38
+ this.definitions = new Map()
39
+ this.problems = []
40
+ }
41
+
42
+ get db() {
43
+ return this.tasks.db
44
+ }
45
+
46
+ // --- definitions ---------------------------------------------------------
47
+
48
+ async load() {
49
+ this.personas.clear()
50
+ this.definitions.clear()
51
+ this.problems = []
52
+ await this.ensureDefaults()
53
+ for (const [sub, target, kind] of [
54
+ ["personas", this.personas, "persona"],
55
+ ["pipelines", this.definitions, "pipeline"],
56
+ ]) {
57
+ const folder = join(this.dir, sub)
58
+ let files = []
59
+ try {
60
+ files = (await readdir(folder)).filter((f) => /\.ya?ml$/.test(f))
61
+ } catch {}
62
+ for (const file of files) {
63
+ try {
64
+ const doc = YAML.parse(await readFile(join(folder, file), "utf8"))
65
+ const id = doc.id ?? basename(file).replace(/\.ya?ml$/, "")
66
+ const problems = kind === "persona" ? checkPersona(doc) : this.checkPipeline(doc)
67
+ if (problems.length) this.problems.push({ file, problems })
68
+ target.set(id, { ...doc, id, file: join(folder, file) })
69
+ } catch (err) {
70
+ this.problems.push({ file, problems: [err.message] })
71
+ }
72
+ }
73
+ }
74
+ return { personas: this.personas.size, pipelines: this.definitions.size, problems: this.problems }
75
+ }
76
+
77
+ checkPipeline(doc) {
78
+ return checkPipelineDoc(doc, this.personas)
79
+ }
80
+
81
+ /** Global definitions (~/.botfarm), or a workspace's own files. */
82
+ async lib(workspaceId) {
83
+ if (workspaceId && this.sup.workspaces?.get(workspaceId)) return this.sup.workspaces.library(workspaceId)
84
+ return { personas: this.personas, definitions: this.definitions, problems: this.problems }
85
+ }
86
+
87
+ /** The definition a run was started with, frozen at start. */
88
+ defFor(run) {
89
+ return run?.def ?? (run ? this.definitions.get(run.pipeline) : null)
90
+ }
91
+
92
+ personaFor(run, name) {
93
+ return run?.personaDefs?.[name] ?? this.personas.get(name)
94
+ }
95
+
96
+ async ensureDefaults() {
97
+ for (const [sub, files] of Object.entries(DEFAULTS)) {
98
+ const folder = join(this.dir, sub)
99
+ await mkdir(folder, { recursive: true })
100
+ for (const [name, body] of Object.entries(files)) {
101
+ const path = join(folder, name)
102
+ try {
103
+ await readFile(path)
104
+ } catch {
105
+ await writeFile(path, body)
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ list() {
112
+ return {
113
+ personas: [...this.personas.values()].map((p) => ({
114
+ id: p.id,
115
+ title: p.title ?? p.id,
116
+ role: p.role ?? p.id,
117
+ model: p.model ?? null,
118
+ tools: p.tools ?? null,
119
+ may_spawn: !!p.may_spawn,
120
+ file: p.file,
121
+ })),
122
+ pipelines: [...this.definitions.values()].map((p) => ({
123
+ id: p.id,
124
+ title: p.title ?? p.id,
125
+ description: p.description ?? "",
126
+ stages: (p.stages ?? []).map((s) => ({
127
+ id: s.id,
128
+ persona: s.persona,
129
+ receives: s.receives ?? [],
130
+ human: !!s.human,
131
+ title: s.title ?? null,
132
+ after: s.after ?? null,
133
+ split: s.split ?? null,
134
+ parallel: s.parallel ?? null,
135
+ limits: s.limits ?? null,
136
+ })),
137
+ file: p.file,
138
+ })),
139
+ problems: this.problems,
140
+ }
141
+ }
142
+
143
+ /** Raw definition, for editing. */
144
+ definition(kind, id) {
145
+ const map = kind === "personas" ? this.personas : this.definitions
146
+ const doc = map.get(id)
147
+ if (!doc) return null
148
+ const { file, ...rest } = doc
149
+ return { id, file, doc: rest, yaml: YAML.stringify(rest) }
150
+ }
151
+
152
+ /**
153
+ * Write a definition back to YAML. Structured edits and hand edits are the
154
+ * same file, so a pipeline changed in the dashboard is still a file you can
155
+ * diff, commit and send to someone.
156
+ */
157
+ async saveDefinition(kind, id, body) {
158
+ const folder = join(this.dir, kind)
159
+ await mkdir(folder, { recursive: true })
160
+ const doc = body.yaml ? YAML.parse(body.yaml) : body.doc
161
+ if (!doc || typeof doc !== "object") throw new Error("nothing to save")
162
+ doc.id = id
163
+ const problems = kind === "personas" ? checkPersona(doc) : this.checkPipeline(doc)
164
+ const path = join(folder, `${id}.yaml`)
165
+ await writeFile(path, YAML.stringify(doc) + "\n")
166
+ await this.load()
167
+ return { id, path, problems, doc }
168
+ }
169
+
170
+ async deleteDefinition(kind, id) {
171
+ const map = kind === "personas" ? this.personas : this.definitions
172
+ const doc = map.get(id)
173
+ if (doc?.file) await rm(doc.file).catch(() => {})
174
+ await this.load()
175
+ }
176
+
177
+ // --- runs ----------------------------------------------------------------
178
+
179
+ runs() {
180
+ return this.db.all("runs").sort((a, b) => b.created - a.created)
181
+ }
182
+
183
+ run(id) {
184
+ return this.db.get("runs", id)
185
+ }
186
+
187
+ /**
188
+ * Start a pipeline: worktree, one session per persona, one task per stage.
189
+ * Only the first stage is queued; the rest are blocked on their predecessor
190
+ * until a handoff lands.
191
+ */
192
+ async start({ pipeline, repo, branch, story, title, group, project, services, workspace = null, limits = null, parallel = null }) {
193
+ const ws = workspace ? this.sup.workspaces?.get(workspace) : null
194
+ if (workspace && !ws) throw new Error(`no workspace "${workspace}"`)
195
+ const lib = await this.lib(ws?.id)
196
+ const def = lib.definitions.get(pipeline)
197
+ if (!def) throw new Error(`no pipeline called "${pipeline}"`)
198
+ if (!story?.trim()) throw new Error("a pipeline needs a story or task to work on")
199
+ repo ??= ws?.path
200
+ services = ws ? this.sup.workspaces.serviceEntries(ws, services ?? ws.services ?? []) : services ?? []
201
+ const label = title?.trim() || topicFrom(story).replace(/…$/, "")
202
+
203
+ // Every run is its own workstream — its own branch, team, chat and board
204
+ // — unless you name an existing one to add to. Two DE-something stories
205
+ // through the same pipeline are two workstreams, not one crowded board.
206
+ let target = project || group ? this.sup.projects.find(project ?? group) : null
207
+ if (!target) {
208
+ if (ws && !branch) branch = `botfarm/${slugify(label)}`
209
+ const clash = branch && this.sup.projects.list().find((p) => p.repo === repo && p.branch === branch && p.status !== "archived")
210
+ if (clash) throw new Error(`workstream "${clash.name}" is already on branch ${branch} — pick another branch`)
211
+ target = this.sup.projects.create({ name: label, repo, branch, services, unique: true })
212
+ }
213
+ const projectId = target.id
214
+ const runId = "run_" + Date.now().toString(36)
215
+ const stages = def.stages ?? []
216
+ // Human stages have no bot behind them, so they need no session.
217
+ const personas = [...new Set(stages.filter((s) => !s.human && s.persona).map((s) => s.persona))]
218
+
219
+ const sessions = {}
220
+ const briefs = {}
221
+ const personaDefs = {}
222
+ // Every bot's opencode agent goes into the worktree config before the
223
+ // first session opens it, so all of them load together.
224
+ const team = Object.fromEntries(personas.map((n) => [n, teamEntry(lib.personas.get(n), n)]))
225
+ for (const name of personas) {
226
+ const persona = lib.personas.get(name)
227
+ if (!persona) throw new Error(`pipeline "${pipeline}" wants agent "${name}", which is not defined`)
228
+ personaDefs[name] = { ...persona, file: undefined }
229
+ const s = await this.sup.spawn({
230
+ repo,
231
+ services, // nested service repos get a worktree each, in the same layout
232
+ branch, // every persona shares one worktree: they are working on the same change
233
+ title: `${persona.title ?? name} · ${title ?? story.slice(0, 40)}`,
234
+ label: `${name}`,
235
+ group: projectId,
236
+ agent: persona.agent ?? null,
237
+ model: persona.model ?? null,
238
+ variant: persona.variant ?? null,
239
+ persona: name,
240
+ personaTitle: persona.title ?? name,
241
+ team,
242
+ role: persona.role ?? name,
243
+ adjectives: persona.adjectives ?? null,
244
+ tools: persona.tools ?? null,
245
+ policy: {
246
+ talk: persona.talk ?? "open",
247
+ rooms: persona.rooms ?? "member",
248
+ spawn: !!persona.may_spawn,
249
+ ...(persona.policy ?? {}),
250
+ },
251
+ })
252
+ sessions[name] = s.id
253
+ // The brief is held until this bot's first stage is dispatched, and then
254
+ // sent together with the task. Briefing every bot up front left the
255
+ // later stages awake with a persona and nothing to do, so they went
256
+ // looking for work: reading a truncated room topic and asking the
257
+ // product bot for criteria it had not written yet.
258
+ const brief = [persona.prompt, def.context, stageContextFor(def, name)].filter(Boolean).join("\n\n")
259
+ if (brief.trim()) briefs[name] = render(brief, { story, run: runId, project: projectId })
260
+ }
261
+
262
+ // The project's chat is the run's chat; there is only one conversation.
263
+ const room = this.sup.projects.room(projectId)
264
+ for (const id of Object.values(sessions)) {
265
+ const s = this.sup.store.get(id)
266
+ if (s) this.sup.rooms.join(room, s, { silent: true })
267
+ }
268
+ // The topic is a label, not the spec: every stage gets the whole story in
269
+ // its task. A topic cut mid-sentence reads like truncated requirements.
270
+ room.topic = title ?? topicFrom(story)
271
+
272
+ const taskIds = {}
273
+ let previous = null
274
+ for (const [i, stage] of stages.entries()) {
275
+ // A stage waits for the one before it, unless it says what it waits for
276
+ // ("after: [analyse]") — which is how dev and QA start together for TDD.
277
+ const deps = Array.isArray(stage.after)
278
+ ? stage.after.map((id) => taskIds[id]).filter(Boolean)
279
+ : previous ? [previous] : []
280
+ const task = this.tasks.create({
281
+ title: stage.title ?? `${stage.id} — ${title ?? story.slice(0, 40)}`,
282
+ brief: story,
283
+ runId,
284
+ projectId,
285
+ stage: stage.id,
286
+ persona: stage.persona,
287
+ // Addressed to the part, and to the session currently playing it.
288
+ role: lib.personas.get(stage.persona)?.role ?? stage.persona,
289
+ assignee: stage.human ? null : sessions[stage.persona],
290
+ owner: stage.human ? "human" : "session",
291
+ kind: stage.human ? "question" : "work",
292
+ dependsOn: deps,
293
+ createdBy: "pipeline",
294
+ order: i,
295
+ })
296
+ if (stage.split || stage.limits) {
297
+ // A split stage becomes one card per item of an earlier handoff's list,
298
+ // worked by up to `parallel` bots of its kind at once; the stages after
299
+ // it wait for every piece (see expandSplits and joinParent).
300
+ if (stage.split) task.split = { from: stage.split === true ? "tasks" : String(stage.split), parallel: clampParallel(parallel?.[stage.id] ?? stage.parallel ?? 3) }
301
+ if (stage.limits) task.limits = cleanLimits(stage.limits)
302
+ this.tasks.save(task)
303
+ }
304
+ taskIds[stage.id] = task.id
305
+ previous = task.id
306
+ }
307
+
308
+ const run = {
309
+ id: runId,
310
+ pipeline: def.id,
311
+ title: title ?? story.slice(0, 80),
312
+ story,
313
+ repo,
314
+ branch,
315
+ projectId,
316
+ roomId: room.id,
317
+ sessions,
318
+ taskIds,
319
+ briefs,
320
+ briefed: [],
321
+ workspaceId: ws?.id ?? null,
322
+ def: { ...def, file: undefined },
323
+ personaDefs,
324
+ limits: cleanLimits(limits ?? def.limits),
325
+ paused: null,
326
+ pausedMs: 0,
327
+ status: "running",
328
+ created: Date.now(),
329
+ }
330
+ this.db.put("runs", run)
331
+ // Re-read: creating the chat above saved a newer copy of the project.
332
+ target = this.sup.projects.get(projectId) ?? target
333
+ Object.assign(target, {
334
+ workspaceId: ws?.id ?? target.workspaceId ?? null,
335
+ pipeline: def.id,
336
+ pipelineTitle: def.title ?? def.id,
337
+ runId,
338
+ story,
339
+ status: "running",
340
+ repo: target.repo ?? repo,
341
+ branch: target.branch ?? branch,
342
+ worktree: this.sup.store.get(Object.values(sessions)[0])?.directory ?? target.worktree ?? null,
343
+ })
344
+ this.sup.projects.save(target)
345
+ this.sup.store.note(`pipeline ${def.id} started: ${stages.length} stages, ${personas.length} bots`, null, "info")
346
+
347
+ // The first stage goes out now. Nobody else has been told anything yet,
348
+ // so the later bots sit idle until their own stage is handed to them.
349
+ // Every stage with nothing to wait for starts now (usually just the first;
350
+ // with "after:" several can). Nobody else has been told anything yet.
351
+ const openers = stages.filter((st) => !st.human && !this.tasks.get(taskIds[st.id])?.dependsOn?.length)
352
+ await this.dispatch({ force: true, sessions: [...new Set(openers.map((st) => sessions[st.persona]))].map((id) => this.sup.store.get(id)).filter(Boolean) })
353
+ await this.sup.saveManifest(projectId)
354
+ return run
355
+ }
356
+
357
+ /**
358
+ * Run a workstream again from one stage. Everything before it keeps its
359
+ * handoff; that stage and everything after go back to waiting their turn.
360
+ * With `fresh`, every bot is replaced by a new opencode session in the same
361
+ * worktree — which is how a changed opencode.json, agent definition or MCP
362
+ * setup reaches them — and the old sessions leave the board (their
363
+ * transcripts stay in opencode).
364
+ */
365
+ async restart(projectId, { stage = null, fresh = true } = {}) {
366
+ const project = this.sup.projects.get(projectId)
367
+ const run = project?.runId ? this.run(project.runId) : null
368
+ if (!run) throw new Error("this workstream was not started from a pipeline")
369
+ const def = this.defFor(run)
370
+ const stages = def?.stages ?? []
371
+ const tasks = stages.map((s) => this.tasks.get(run.taskIds[s.id]))
372
+ const from = stage
373
+ ? stages.findIndex((s) => s.id === stage)
374
+ : Math.max(0, tasks.findIndex((t) => t && t.status !== "done"))
375
+ if (from < 0) throw new Error(`no stage "${stage}" in this pipeline`)
376
+
377
+ // Stop whoever is mid-turn so nothing lands on a card we are resetting.
378
+ const old = Object.entries(run.sessions).map(([persona, id]) => [persona, this.sup.store.get(id)]).filter(([, s]) => s)
379
+ for (const [, s] of old) if (s.status === "busy") await this.sup.abort(s.id).catch(() => {})
380
+
381
+ const swap = new Map()
382
+ if (fresh) {
383
+ // Bring the worktree's config up to date, then have opencode forget
384
+ // the location so the new sessions actually read it.
385
+ const dir = old[0]?.[1]?.directory
386
+ if (dir) {
387
+ await git.carryConfig(run.repo, dir).catch(() => {})
388
+ const reloaded = await this.sup.client.disposeInstance(dir)
389
+ if (!reloaded) this.sup.store.note("opencode would not reload this worktree's config; if MCP changes are missing, restart opencode serve", null, "warn")
390
+ }
391
+ const room = this.sup.rooms.rooms.get(run.roomId)
392
+ for (const [key, s] of old) {
393
+ const name = s.persona ?? key
394
+ const persona = run.personaDefs?.[name] ?? (await this.lib(run.workspaceId)).personas.get(name)
395
+ if (!persona) continue
396
+ const next = await this.sup.spawn({
397
+ repo: run.repo,
398
+ services: project.services ?? [],
399
+ branch: run.branch,
400
+ title: `${persona.title ?? name} · ${project.name}`,
401
+ label: name,
402
+ group: projectId,
403
+ agent: persona.agent ?? null,
404
+ model: persona.model ?? null,
405
+ variant: persona.variant ?? null,
406
+ persona: name,
407
+ personaTitle: persona.title ?? name,
408
+ team: Object.fromEntries(old.map(([k, x]) => [x.persona ?? k, teamEntry(run.personaDefs?.[x.persona ?? k], x.persona ?? k)]).filter(([, v]) => v)),
409
+ role: persona.role ?? name,
410
+ adjectives: persona.adjectives ?? null,
411
+ tools: persona.tools ?? null,
412
+ policy: { talk: persona.talk ?? "open", rooms: persona.rooms ?? "member", spawn: !!persona.may_spawn, ...(persona.policy ?? {}) },
413
+ })
414
+ swap.set(s.id, next.id)
415
+ run.sessions[key] = next.id
416
+ if (room && !room.members.has(next.id)) this.sup.rooms.join(room, this.sup.store.get(next.id), { silent: true })
417
+ }
418
+ run.briefed = []
419
+ for (const t of this.tasks.all({ projectId })) {
420
+ if (swap.has(t.assignee)) { t.assignee = swap.get(t.assignee); this.tasks.save(t) }
421
+ }
422
+ // What the old bots spent still counts against the workstream's budget.
423
+ run.retired ??= { tokens: 0, usd: 0 }
424
+ for (const [oldId] of swap) {
425
+ const o = this.sup.store.get(oldId)
426
+ if (o) { run.retired.tokens += tokensOf(o); run.retired.usd += this.costOf(o) }
427
+ }
428
+ for (const [oldId] of swap) await this.sup.release(oldId, { disableMesh: false }).catch(() => {})
429
+ }
430
+
431
+ for (const [i, st] of stages.entries()) {
432
+ const t = this.tasks.get(run.taskIds[st.id]) // re-read: the swap above reassigned them
433
+ if (!t || i < from) continue
434
+ // A split card goes back to being one card; its pieces are made again
435
+ // from the (possibly new) handoff when it comes round.
436
+ if (t.children) {
437
+ for (const id of t.children) this.tasks.remove(id)
438
+ delete t.children
439
+ t.owner = "session"
440
+ t.assignee = run.sessions[t.persona] ?? null
441
+ }
442
+ t.budget = null
443
+ t.pausedAt = null
444
+ if (t.baseDeps) t.dependsOn = t.baseDeps
445
+ for (const k of ["baseDeps", "rework", "recheck", "rounds", "round", "history", "lastSendBack", "extraRounds", "prefer", "preferUntil", "pendingSendBack"]) delete t[k]
446
+ Object.assign(t, {
447
+ status: i === from ? (t.owner === "human" ? "waiting" : "queued") : "blocked",
448
+ queuedAt: i === from ? Date.now() : null,
449
+ handoff: null, answer: null, answers: null, files: [],
450
+ startedAt: null, nudgedAt: null, escalated: false,
451
+ })
452
+ this.tasks.save(t)
453
+ }
454
+ run.status = "running"
455
+ run.paused = null
456
+ this.db.put("runs", run)
457
+ this.sup.projects.save({ ...this.sup.projects.get(projectId), status: "running" })
458
+ const room = this.sup.rooms.rooms.get(run.roomId)
459
+ if (room) await this.sup.rooms.post(room, null, `Restarting from ${stages[from].id}${fresh ? " with fresh bots" : ""}. Earlier handoffs are kept.`).catch(() => {})
460
+ this.sup.store.note(`${project.name}: restarted from ${stages[from].id}${fresh ? ` with ${swap.size} fresh bot(s)` : ""}`)
461
+ this.tasks.unblock()
462
+ await this.dispatch({ force: true, sessions: this.sup.projects.sessions(projectId) })
463
+ await this.sup.saveManifest(projectId)
464
+ return { from: stages[from].id, fresh: swap.size }
465
+ }
466
+
467
+ /** Bring one more agent into a workstream's team, in the same worktree. */
468
+ async addBot(projectId, agentId) {
469
+ const project = this.sup.projects.get(projectId)
470
+ if (!project) throw new Error("unknown workstream")
471
+ const lib = await this.lib(project.workspaceId ?? this.sup.workspaces?.forPath(project.repo)?.id)
472
+ const persona = lib.personas.get(agentId)
473
+ if (!persona) throw new Error(`no agent called "${agentId}"`)
474
+ const s = await this.sup.spawn({
475
+ repo: project.repo,
476
+ services: project.services ?? [],
477
+ branch: project.branch,
478
+ title: `${persona.title ?? agentId} · ${project.name}`,
479
+ label: agentId,
480
+ group: projectId,
481
+ agent: persona.agent ?? null,
482
+ model: persona.model ?? null,
483
+ variant: persona.variant ?? null,
484
+ persona: agentId,
485
+ personaTitle: persona.title ?? agentId,
486
+ role: persona.role ?? agentId,
487
+ adjectives: persona.adjectives ?? null,
488
+ tools: persona.tools ?? null,
489
+ policy: { talk: persona.talk ?? "open", rooms: persona.rooms ?? "member", spawn: !!persona.may_spawn, ...(persona.policy ?? {}) },
490
+ })
491
+ const run = project.runId ? this.run(project.runId) : null
492
+ if (run) {
493
+ run.sessions[`${agentId}${run.sessions[agentId] ? "-" + s.handle : ""}`] = s.id
494
+ run.briefs ??= {}
495
+ run.briefs[agentId] ??= render(persona.prompt ?? "", { story: run.story, run: run.id, project: projectId })
496
+ run.personaDefs ??= {}
497
+ run.personaDefs[agentId] ??= { ...persona, file: undefined }
498
+ this.db.put("runs", run)
499
+ }
500
+ const room = this.sup.projects.room(projectId)
501
+ if (room && !room.members.has(s.id)) this.sup.rooms.join(room, this.sup.store.get(s.id), { silent: true })
502
+ await this.sup.saveManifest(projectId)
503
+ return s
504
+ }
505
+
506
+ // --- handoffs ------------------------------------------------------------
507
+
508
+ /**
509
+ * An agent finishing a stage. The handoff is structured on purpose: prose
510
+ * alone gives the next bot nothing to template against, and "what did you
511
+ * actually change" is the question every downstream stage asks first.
512
+ */
513
+ async complete(task, { summary, acceptance_criteria = [], artifacts = [], open_questions = [], tasks = [] }) {
514
+ if (!summary?.trim()) throw new Error("a handoff needs a summary — the next bot cannot see your conversation")
515
+ // Questions can come as plain strings or with choices; keep both the
516
+ // structured form (for the operator's answer form) and plain lines.
517
+ const questions = normalizeQuestions(open_questions)
518
+ open_questions = questionText(questions)
519
+ task.handoff = {
520
+ summary: summary.trim(),
521
+ acceptance_criteria,
522
+ artifacts,
523
+ open_questions,
524
+ questions,
525
+ tasks: normalizeItems(tasks),
526
+ at: Date.now(),
527
+ by: task.persona,
528
+ }
529
+ // The next bot reads a brief, not the whole write-up: squeezed by rules,
530
+ // or by a local model when one is configured. The full text stays here.
531
+ const hcfg = this.sup.config?.handoffs ?? {}
532
+ const maxChars = Number(hcfg.maxChars) > 200 ? Number(hcfg.maxChars) : 1800
533
+ task.handoff.brief = hcfg.local?.url && hcfg.local?.model
534
+ ? await localBrief(task.handoff.summary, { ...hcfg.local, maxChars })
535
+ : trimText(task.handoff.summary, maxChars)
536
+ const worker = this.sup.store.get(task.assignee)
537
+ if (worker?.route?.taskId === task.id) worker.route = null
538
+ this.awardXp(task, worker)
539
+ const reworked = task.rework ? task.round : null
540
+ task.rework = null
541
+ task.recheck = null
542
+ const run = task.runId ? this.run(task.runId) : null
543
+ // Open questions travel with the handoff to the next stage, which can
544
+ // settle them or ask. Stopping the whole line for them is opt-in per
545
+ // stage ("hold_on_questions: true"): a product bot always has some.
546
+ const stage = this.defFor(run)?.stages?.find((s) => s.id === task.stage)
547
+ // One piece of a split stage never holds the line on its own: its questions
548
+ // travel with the joined handoff.
549
+ const hold = !task.parentId && open_questions.length && (stage ? !!stage.hold_on_questions : !task.runId)
550
+ task.status = hold ? "review" : "done"
551
+ task.files = artifacts
552
+ this.tasks.save(task)
553
+
554
+ // The card's report: time, tokens, cost, who, the files it changed (with
555
+ // their diffs as they stood when it finished) and any screenshots.
556
+ const report = await this.cardReport(task).catch((e) => { this.sup.store.note(`card report: ${e.message}`, null, "warn"); return null })
557
+ if (run && this.sup.rooms.rooms.get(run.roomId)) {
558
+ const room = this.sup.rooms.rooms.get(run.roomId)
559
+ const author = this.sup.store.get(task.assignee)
560
+ await this.sup.rooms
561
+ .post(room, author, `Finished ${task.stage}${reworked ? ` (round ${reworked})` : ""}${task.item ? ` (${task.item.index}/${task.item.total}: ${task.item.title})` : ""}: ${summary.slice(0, 600)}${open_questions.length ? `\n\nOpen questions: ${open_questions.join("; ")}` : ""}`, { quiet: true, meta: report ? this.cardMeta(task, report) : null })
562
+ .catch(() => {})
563
+ }
564
+
565
+ if (task.parentId) await this.joinParent(task.parentId)
566
+ this.tasks.unblock()
567
+ await this.dispatch()
568
+ this.checkRunDone(task.runId)
569
+ return task
570
+ }
571
+
572
+ checkRunDone(runId) {
573
+ const run = runId ? this.run(runId) : null
574
+ if (!run || run.status === "done") return
575
+ const all = Object.values(run.taskIds).map((id) => this.tasks.get(id)).filter(Boolean)
576
+ if (!all.every((t) => t.status === "done")) return
577
+ run.status = "done"
578
+ run.finishedAt = Date.now()
579
+ this.db.put("runs", run)
580
+ const ws = this.sup.projects.get(run.projectId)
581
+ if (ws && ws.status === "running") this.sup.projects.save({ ...ws, status: "done" })
582
+ this.sup.saveManifest(run.projectId).catch(() => {})
583
+ this.sup.store.note(`pipeline ${run.pipeline} finished`, null, "info")
584
+ this.harvest(run).catch((e) => this.sup.store.note(`harvest: ${e.message}`, null, "warn"))
585
+ }
586
+
587
+ /**
588
+ * The operator lets a held stage go on, optionally answering its open
589
+ * questions. The answers ride along to every stage that receives it.
590
+ */
591
+ async release(task, note = "", answers = null) {
592
+ if (answers && task.handoff?.questions?.length) {
593
+ const said = formatAnswers(task.handoff.questions, answers)
594
+ note = [said, note?.trim()].filter(Boolean).join("\n\n")
595
+ task.handoff.answers = answers
596
+ }
597
+ if (note?.trim()) {
598
+ task.handoff ??= { summary: "", acceptance_criteria: [], artifacts: [], open_questions: [], at: Date.now(), by: task.persona }
599
+ task.handoff.operator_notes = note.trim()
600
+ }
601
+ task.status = "done"
602
+ this.tasks.save(task)
603
+ const run = task.runId ? this.run(task.runId) : null
604
+ const room = run ? this.sup.rooms.rooms.get(run.roomId) : null
605
+ if (room && note?.trim()) await this.sup.rooms.post(room, null, `On ${task.stage ?? task.title}: ${note.trim()}`).catch(() => {})
606
+ if (task.parentId) await this.joinParent(task.parentId)
607
+ this.tasks.unblock()
608
+ await this.dispatch()
609
+ this.checkRunDone(task.runId)
610
+ return task
611
+ }
612
+
613
+ /** Context handed to a stage: the story, the AC so far, and chosen handoffs. */
614
+ contextFor(task) {
615
+ const run = task.runId ? this.run(task.runId) : null
616
+ const def = this.defFor(run)
617
+ const stage = def?.stages?.find((s) => s.id === task.stage)
618
+ const handoffs = {}
619
+ let acceptance = []
620
+ for (const id of stage?.receives ?? []) {
621
+ const t = this.tasks.get(run?.taskIds?.[id])
622
+ if (t?.handoff) {
623
+ handoffs[id] = { ...trimHandoff(t.handoff, { maxChars: Number(this.sup.config?.handoffs?.maxChars) > 200 ? Number(this.sup.config.handoffs.maxChars) : 1800 }), stage: id, persona: t.persona }
624
+ if (t.handoff.acceptance_criteria?.length) acceptance = t.handoff.acceptance_criteria
625
+ }
626
+ }
627
+ return {
628
+ story: task.brief,
629
+ title: task.title,
630
+ task: { id: task.id, title: task.title, stage: task.stage },
631
+ acceptance_criteria: acceptance.length ? acceptance : task.acceptance ?? [],
632
+ handoffs,
633
+ received: Object.values(handoffs),
634
+ run: run ? { id: run.id, pipeline: run.pipeline, branch: run.branch, repo: run.repo } : null,
635
+ peers: run ? Object.entries(run.sessions).map(([persona, id]) => ({ persona, handle: this.sup.store.get(id)?.handle })) : [],
636
+ room: run ? this.sup.rooms.rooms.get(run.roomId)?.name : null,
637
+ item: task.item ?? null,
638
+ siblings: task.parentId ? (this.tasks.get(task.parentId)?.children ?? []).filter((id) => id !== task.id).map((id) => this.tasks.get(id)).filter(Boolean).map((t) => ({ title: t.title, handle: this.sup.store.get(t.assignee)?.handle ?? null })) : [],
639
+ }
640
+ }
641
+
642
+ promptFor(task) {
643
+ const run = task.runId ? this.run(task.runId) : null
644
+ const def = this.defFor(run)
645
+ const stage = def?.stages?.find((s) => s.id === task.stage)
646
+ const ctx = this.contextFor(task)
647
+ let body = stage?.prompt
648
+ ? render(stage.prompt, ctx)
649
+ : render(GENERIC_TASK_PROMPT, ctx)
650
+ // Every stage sees the whole story. A stage prompt that leaves it out is
651
+ // usually an oversight, and without it the bot goes asking for it in chat.
652
+ if (stage?.prompt && !variables(stage.prompt).includes("story") && ctx.story) {
653
+ body = `Story:\n\n${ctx.story}\n\n${body}`
654
+ }
655
+ // Questions an earlier stage left open, and anything the operator said
656
+ // about them, whether or not the stage prompt mentions them.
657
+ const open = ctx.received.filter((h) => h.open_questions?.length || h.operator_notes)
658
+ if (open.length) {
659
+ body += "\n\n" + open.map((h) => [
660
+ h.open_questions?.length ? `@${h.persona} (${h.stage}) left these open:\n${h.open_questions.map((q) => ` - ${q}`).join("\n")}` : "",
661
+ h.operator_notes ? `The operator's answer: ${h.operator_notes}` : h.open_questions?.length ? "Settle them from the code and the story where you can; ask in the chat or with botfarm_ask_human if one blocks you." : "",
662
+ ].filter(Boolean).join("\n")).join("\n\n")
663
+ }
664
+ if (task.item && !variables(stage?.prompt ?? "").includes("item")) {
665
+ body += `\n\nYour part — ${task.item.index} of ${task.item.total}: ${task.item.title}` + (task.item.detail ? `\n${task.item.detail}` : "")
666
+ }
667
+ if (task.item) {
668
+ body += "\n\n" + [
669
+ `This stage was split into ${task.item.total} pieces, worked at the same time by several bots in the same worktree.`,
670
+ ctx.siblings.length ? `The others: ${ctx.siblings.map((x) => `"${x.title}"${x.handle ? ` (@${x.handle})` : ""}`).join(", ")}.` : "",
671
+ "Do only your piece. Touch only the files it needs, never revert or reformat someone else's changes,",
672
+ "and if you must change a file another piece owns, say so in the chat first. Do not commit; the next stage sees the whole worktree.",
673
+ ].filter(Boolean).join("\n")
674
+ }
675
+ if (task.rework) {
676
+ const r = task.rework
677
+ const last = task.history?.at(-1)
678
+ body = [
679
+ r.early
680
+ ? `⚠ @${r.by} already checked this and found a problem — fix it as part of this card:`
681
+ : `↩ Round ${r.round}: @${r.by}${r.fromStage ? ` (${r.fromStage})` : ""} sent this card back. Fix what they found — only that, and anything it breaks:`,
682
+ this.reworkText(r),
683
+ last?.summary ? `\nWhat was handed off last time${last.by ? ` (by @${last.by})` : ""}:\n${trimText(last.summary, 900)}` : "",
684
+ r.early ? "" : "\nThe worktree already has the earlier work in it; change what is needed rather than starting over. Run the checks that failed yourself if you can. Then call botfarm_task_complete again, with a summary of what you changed this round and why it fixes each failure.",
685
+ "\n--- the original card ---\n",
686
+ ].filter(Boolean).join("\n") + "\n" + body
687
+ }
688
+ if (task.recheck) {
689
+ const rc = task.recheck
690
+ const fixed = rc.cards.map((id) => this.tasks.get(id)).filter((t) => t?.handoff)
691
+ body += `\n\n↻ Fix ${rc.round}: you sent ${rc.to} back (${rc.reason ? trimText(rc.reason, 200) : "see your last message"}). The fix is in:\n` +
692
+ (fixed.length ? fixed.map((t) => `@${this.sup.store.get(t.assignee)?.handle ?? t.persona}${t.item ? ` (piece ${t.item.index})` : ""}: ${trimText(t.handoff.summary, 700)}`).join("\n\n") : "(no handoff text)") +
693
+ `\n\nVerify it again from scratch — run the tests and builds, do not take the summary's word for it.`
694
+ }
695
+ const targets = this.sendBackTargets(task)
696
+ if (targets.length) {
697
+ const used = Object.values(task.rounds ?? {}).reduce((a, b) => Math.max(a, b), 0)
698
+ body += `\n\nIf what you received does not hold up — a failing test, a broken build, a missed criterion — do not fix it yourself: call botfarm_send_back with the stage and exactly what failed. ` +
699
+ `The bot that did it fixes it, and this card comes back to you to verify. You can send back to: ${targets.map((t) => `${t.stage} (${t.by.length ? t.by.map((h) => "@" + h).join(", ") : t.persona}${t.pieces ? `, ${t.pieces} pieces — name the pieces or files` : ""})`).join(", ")}. ` +
700
+ `Rounds so far: ${used} of ${this.maxRounds(task)}. When it passes, complete your card as usual.`
701
+ }
702
+ const project = task.projectId ? this.sup.projects.get(task.projectId) : null
703
+ const repos = project?.services?.length ? this.sup.reposText(project) : ""
704
+ if (repos) body += `\n\nRepos in this worktree (each on the workstream's branch; commit in the repo you changed):\n${repos}`
705
+ const shared = this.sharedContext(task)
706
+ if (shared) body += "\n\n" + shared
707
+ let handoff = render(stage?.handoff_prompt ?? DEFAULT_HANDOFF_PROMPT, ctx)
708
+ const feeds = this.splitFedBy(def, stage)
709
+ if (feeds && !task.item) {
710
+ handoff += `\n tasks REQUIRED: the work for the next stage (${feeds.id}) split into independent pieces,` +
711
+ `\n as [{ "title": "...", "detail": "..." }]. Each becomes its own card for a ${feeds.persona} bot;` +
712
+ `\n up to ${feeds.parallel ?? 3} run at the same time in the same worktree, so make them touch different` +
713
+ `\n files or areas. Usually 2–10 pieces; one piece if it cannot be split. Add` +
714
+ `\n "difficulty": "easy" | "normal" | "hard" to a piece when it is obvious — easy ones run on a cheaper model.`
715
+ }
716
+ return [
717
+ `[botfarm] task ${task.id} — ${task.title}`,
718
+ "",
719
+ body,
720
+ "",
721
+ handoff,
722
+ ].join("\n")
723
+ }
724
+
725
+ /** The split stage that takes its pieces from this stage's handoff, if any. */
726
+ splitFedBy(def, stage) {
727
+ if (!def || !stage) return null
728
+ const stages = def.stages ?? []
729
+ const i = stages.findIndex((x) => x.id === stage.id)
730
+ const field = (x) => String(x.split === true ? "tasks" : x.split)
731
+ return stages.find((x, j) => {
732
+ if (!x.split || j <= i) return false
733
+ const f = field(x)
734
+ if (f.includes(".")) return f.split(".")[0] === stage.id
735
+ const deps = Array.isArray(x.after) ? x.after : [stages[j - 1]?.id]
736
+ return f === "tasks" && (deps.includes(stage.id) || (x.receives ?? []).includes(stage.id))
737
+ }) ?? null
738
+ }
739
+
740
+ // --- fan-out / join ------------------------------------------------------
741
+
742
+ /** The list a split card fans out over: `tasks` (or `stage.field`) from the handoffs it waits on. */
743
+ splitItems(parent) {
744
+ const from = parent.split?.from ?? "tasks"
745
+ const [stageId, field] = from.includes(".") ? from.split(".") : [null, from]
746
+ const run = parent.runId ? this.run(parent.runId) : null
747
+ const sources = stageId
748
+ ? [this.tasks.get(run?.taskIds?.[stageId])]
749
+ : (parent.dependsOn ?? []).map((id) => this.tasks.get(id))
750
+ for (const t of sources) {
751
+ const items = normalizeItems(t?.handoff?.[field])
752
+ if (items.length) return items.slice(0, 50)
753
+ }
754
+ return []
755
+ }
756
+
757
+ /**
758
+ * Split cards whose inputs have landed become one card per item, addressed
759
+ * to the part (any bot of that kind in this workstream), and enough bots of
760
+ * that kind are brought in to work `parallel` of them at once.
761
+ */
762
+ async expandSplits() {
763
+ const spawned = []
764
+ for (const parent of this.tasks.all().filter((t) => t.split && !t.children && t.status === "queued" && this.tasks.ready(t))) {
765
+ const run = parent.runId ? this.run(parent.runId) : null
766
+ if (run?.paused) continue
767
+ const items = this.splitItems(parent)
768
+ if (!items.length) {
769
+ parent.split = null
770
+ this.tasks.save(parent)
771
+ this.sup.store.note(`"${parent.title}": the handoff before it had no ${parent.split?.from ?? "tasks"} list, so it runs as one card`, null, "warn")
772
+ continue
773
+ }
774
+ const persona = parent.persona
775
+ const role = run?.personaDefs?.[persona]?.role ?? persona
776
+ const children = items.map((it, i) => {
777
+ const c = this.tasks.create({
778
+ title: it.title,
779
+ brief: parent.brief,
780
+ runId: parent.runId,
781
+ projectId: parent.projectId,
782
+ stage: parent.stage,
783
+ persona,
784
+ role,
785
+ owner: "role",
786
+ kind: "work",
787
+ createdBy: "pipeline",
788
+ order: (parent.order ?? 0) + (i + 1) / 1000,
789
+ })
790
+ Object.assign(c, { parentId: parent.id, item: { ...it, index: i + 1, total: items.length }, limits: parent.limits ?? null })
791
+ return this.tasks.save(c)
792
+ })
793
+ Object.assign(parent, { children: children.map((c) => c.id), status: "active", assignee: null, owner: "group", startedAt: Date.now() })
794
+ this.tasks.save(parent)
795
+ const want = Math.min(parent.split.parallel ?? 1, items.length)
796
+ if (run) spawned.push(...(await this.ensureBots(run, persona, want)))
797
+ this.sup.store.note(`${parent.stage} split into ${items.length} cards for up to ${want} ${persona} bot${want > 1 ? "s" : ""}`, null, "info")
798
+ }
799
+ return spawned
800
+ }
801
+
802
+ /** Bots of one kind on a run, bringing in more (same worktree, same brief) up to `n`. */
803
+ async ensureBots(run, persona, n) {
804
+ const have = Object.values(run.sessions ?? {}).map((id) => this.sup.store.get(id)).filter((s) => s && (s.persona ?? null) === persona)
805
+ const out = []
806
+ const project = this.sup.projects.get(run.projectId)
807
+ const def = run.personaDefs?.[persona]
808
+ if (!def || !project) return out
809
+ for (let k = have.length; k < n; k++) {
810
+ const s = await this.sup.spawn({
811
+ repo: run.repo,
812
+ services: project.services ?? [],
813
+ branch: run.branch,
814
+ title: `${def.title ?? persona} ${k + 1} · ${project.name}`,
815
+ label: persona,
816
+ group: run.projectId,
817
+ agent: def.agent ?? null,
818
+ model: def.model ?? null,
819
+ variant: def.variant ?? null,
820
+ persona,
821
+ personaTitle: def.title ?? persona,
822
+ role: def.role ?? persona,
823
+ adjectives: def.adjectives ?? null,
824
+ tools: def.tools ?? null,
825
+ policy: { talk: def.talk ?? "open", rooms: def.rooms ?? "member", spawn: !!def.may_spawn, ...(def.policy ?? {}) },
826
+ }).catch((e) => { this.sup.store.note(`could not add a ${persona} bot: ${e.message}`, null, "warn"); return null })
827
+ if (!s) break
828
+ const fresh = this.run(run.id) ?? run
829
+ fresh.sessions[`${persona}-${k + 1}`] = s.id
830
+ this.db.put("runs", fresh)
831
+ run.sessions = fresh.sessions
832
+ const room = this.sup.rooms.rooms.get(run.roomId)
833
+ const live = this.sup.store.get(s.id)
834
+ if (room && live && !room.members.has(s.id)) this.sup.rooms.join(room, live, { silent: true })
835
+ if (live) out.push(live)
836
+ }
837
+ if (out.length) await this.sup.saveManifest(run.projectId).catch(() => {})
838
+ return out
839
+ }
840
+
841
+ /** When the last piece of a split card lands, the card lands with all their handoffs joined. */
842
+ async joinParent(parentId) {
843
+ const parent = this.tasks.get(parentId)
844
+ if (!parent?.children || parent.status === "done") return
845
+ const kids = parent.children.map((id) => this.tasks.get(id)).filter(Boolean)
846
+ if (!kids.every((k) => ["done", "cancelled"].includes(k.status))) return
847
+ const done = kids.filter((k) => k.handoff)
848
+ const uniq = (xs) => [...new Set(xs)]
849
+ parent.handoff = {
850
+ summary: done.map((k) => `[${k.item?.index}/${k.item?.total}] ${k.title} — @${this.sup.store.get(k.assignee)?.handle ?? k.persona}:\n${k.handoff.summary}`).join("\n\n")
851
+ + (kids.length > done.length ? `\n\n${kids.length - done.length} piece(s) were stopped before finishing.` : ""),
852
+ acceptance_criteria: uniq(done.flatMap((k) => k.handoff.acceptance_criteria ?? [])),
853
+ artifacts: uniq(done.flatMap((k) => k.handoff.artifacts ?? [])),
854
+ open_questions: uniq(done.flatMap((k) => k.handoff.open_questions ?? [])),
855
+ questions: done.flatMap((k) => k.handoff.questions ?? []),
856
+ parts: done.map((k) => ({ title: k.title, summary: k.handoff.summary, by: k.assignee })),
857
+ at: Date.now(),
858
+ by: parent.persona,
859
+ }
860
+ parent.files = parent.handoff.artifacts
861
+ parent.status = "done"
862
+ this.tasks.save(parent)
863
+ const run = parent.runId ? this.run(parent.runId) : null
864
+ const room = run ? this.sup.rooms.rooms.get(run.roomId) : null
865
+ const sum = (k) => kids.reduce((a, x) => a + (x.report?.[k] ?? 0), 0)
866
+ parent.report = { minutes: parent.startedAt ? (Date.now() - parent.startedAt) / 60000 : 0, tokens: sum("tokens"), usd: sum("usd"), files: parent.handoff.artifacts.slice(0, 60), diffs: [], images: kids.flatMap((k) => (k.report?.images ?? []).map((im, i) => ({ ...im, task: k.id, n: i }))).slice(0, 12), bots: [...new Set(kids.map((k) => this.sup.store.get(k.assignee)?.handle).filter(Boolean))], pieces: kids.length }
867
+ this.tasks.save(parent)
868
+ if (room) await this.sup.rooms.post(room, null, `Finished ${parent.stage}: all ${kids.length} pieces are in (${done.length} done${kids.length > done.length ? `, ${kids.length - done.length} stopped` : ""}).`, { quiet: true, meta: this.cardMeta(parent, parent.report) }).catch(() => {})
869
+ }
870
+
871
+ /**
872
+ * Hand queued tasks to sessions. Normally a busy session is left alone until
873
+ * it finishes, which is the whole point of queuing — but two cases must not
874
+ * wait for an idle transition:
875
+ *
876
+ * force: a session that was just created and handed its persona brief is
877
+ * "busy" by definition, so a fresh pipeline would otherwise sit
878
+ * there doing nothing until something else woke it.
879
+ * stalled: a queued task nobody has taken for a while. opencode queues
880
+ * prompts durably, so delivering to a busy session is safe; a
881
+ * missed idle event should not strand a run forever.
882
+ */
883
+ async dispatch({ force = false, sessions = null } = {}) {
884
+ const spawned = await this.expandSplits()
885
+ const candidates = sessions ? [...sessions, ...spawned] : this.sup.store.list()
886
+ const fresh = new Set(spawned.map((s) => s.id))
887
+ for (const session of candidates) {
888
+ const task = this.tasks.nextFor(session.id, { role: session.role ?? session.persona, projectId: session.project ?? null, alive: (id) => this.aliveOn(id, session.project) })
889
+ if (!task) continue
890
+ if (task.runId && this.run(task.runId)?.paused) continue
891
+ // Rework waits for the bot that did it first while it has its chance,
892
+ // rather than being pushed into its queue behind other work.
893
+ if (task.prefer === session.id && session.status !== "idle" && Date.now() <= (task.preferUntil ?? 0)) continue
894
+ const stalled = Date.now() - (task.queuedAt ?? task.updated) > 45_000
895
+ if (session.status !== "idle" && !force && !stalled && !fresh.has(session.id)) continue
896
+ if (stalled && session.status !== "idle") {
897
+ this.sup.store.note(`queueing "${task.title}" behind @${session.handle}'s current work`, session.id, "warn")
898
+ }
899
+ task.status = "active"
900
+ task.startedAt = Date.now()
901
+ task.nudgedAt = null
902
+ task.tokensAtStart = tokensOf(session)
903
+ task.usdAtStart = this.costOf(session)
904
+ await this.routeCard(task, session).catch(() => {})
905
+ // Where the worktree stood when this card began, so its report shows what
906
+ // this card changed — through any tool, MCP server or script.
907
+ task.baseline = session.directory ? await git.snapshot(session.directory).catch(() => null) : null
908
+ task.pausedMs = 0
909
+ task.noRun = false
910
+ // Role work becomes this session's work the moment it is picked up, so
911
+ // two bots playing the same part cannot both take it.
912
+ if (!task.assignee) task.assignee = session.id
913
+ if (task.prefer) {
914
+ if (task.prefer !== session.id) this.sup.store.note(`"${task.title}" went to @${session.handle} — @${this.sup.store.get(task.prefer)?.handle ?? "the bot that did it first"} was not free`, session.id, "info")
915
+ task.prefer = null
916
+ task.preferUntil = null
917
+ }
918
+ this.tasks.save(task)
919
+ const brief = this.takeBrief(task, session)
920
+ const text = this.promptFor(task)
921
+ if (brief) {
922
+ // First contact: the persona and the task arrive as one operator turn,
923
+ // so the bot starts with its instructions and its work together.
924
+ await this.sup.send(session.id, `${brief}\n\n---\n\n${text}`)
925
+ } else {
926
+ // Whatever was said in the room since this bot last looked rides along
927
+ // with the card, so nobody has to be woken separately to read it.
928
+ const room = task.runId ? this.sup.rooms.rooms.get(this.run(task.runId)?.roomId) : null
929
+ await this.sup.deliver(session, {
930
+ text: text + earlier(room, session, "since you last looked"),
931
+ description: `task ${task.stage ?? ""} ${task.title}`.trim(),
932
+ })
933
+ }
934
+ this.sup.store.note(`@${session.handle} picked up "${task.title}"`, session.id)
935
+ }
936
+ }
937
+
938
+ /**
939
+ * A bot on a pipeline run with no open cards: everything assigned to it is
940
+ * done or cancelled. Chatter between bots does not wake it (only the
941
+ * operator, a direct question, or a new card does). Sessions outside
942
+ * pipeline runs are never resting: they keep the plain chat behaviour.
943
+ */
944
+ resting(session) {
945
+ const onRun = this.runs().some((run) => Object.values(run.sessions ?? {}).includes(session.id))
946
+ if (!onRun) return false
947
+ return !this.tasks.all({ assignee: session.id }).some((t) => !["done", "cancelled"].includes(t.status))
948
+ }
949
+
950
+ /** A bot in a run whose first stage has not been handed to it yet. */
951
+ awaitingStage(session) {
952
+ for (const run of this.runs()) {
953
+ if (run.status !== "running" || !run.briefs) continue
954
+ if (Object.values(run.sessions ?? {}).includes(session.id)) return !(run.briefed ?? []).includes(session.id)
955
+ }
956
+ return false
957
+ }
958
+
959
+ /** The persona brief for this session's first task in a run, once. */
960
+ takeBrief(task, session) {
961
+ const run = task.runId ? this.run(task.runId) : null
962
+ if (!run?.briefs) return null
963
+ run.briefed ??= []
964
+ if (run.briefed.includes(session.id)) return null
965
+ run.briefed.push(session.id)
966
+ this.db.put("runs", run)
967
+ const persona = session.persona ?? task.persona
968
+ const body = run.briefs[persona]
969
+ const peers = Object.entries(run.sessions ?? {})
970
+ .filter(([, id]) => id !== session.id)
971
+ .map(([p, id]) => `@${this.sup.store.get(id)?.handle ?? p} (${p})`)
972
+ const room = this.sup.rooms.rooms.get(run.roomId)
973
+ return [
974
+ `[botfarm] You are @${session.handle ?? persona}, the ${persona} on this run.` +
975
+ (peers.length ? ` The others are ${peers.join(", ")}${room ? `, in ${room.name}` : ""}.` : ""),
976
+ "Your task is below and has everything you need. Other stages run before and after yours: do not do their work,",
977
+ "and do not wait for anyone unless your task says to.",
978
+ body ? `\n${body}` : "",
979
+ earlier(room, session),
980
+ ].join("\n")
981
+ }
982
+
983
+ // --- sending work back ----------------------------------------------------
984
+ //
985
+ // Plan → Code → Verify → Fail → Code → Verify → Pass. A checker (QA, the
986
+ // reviewer, the operator) that finds something wrong does not fix it: it
987
+ // sends the earlier card back with what failed. That card goes back to To
988
+ // do for the bot that did it first (another of its kind takes it if that
989
+ // bot is gone or stays busy), and the checker's card waits for the fix and
990
+ // then comes back to the same checker to verify again. Rounds are counted
991
+ // per checker card and target; past `max_rounds`, or when the same failure
992
+ // comes back twice, it stops and asks the operator.
993
+
994
+ /** A session that still works on this workstream. */
995
+ aliveOn(id, projectId) {
996
+ const s = id ? this.sup.store.get(id) : null
997
+ return !!s && (!projectId || !s.project || s.project === projectId) && s.status !== "error"
998
+ }
999
+
1000
+ /** The earlier stages a card may send back to, with who did them. */
1001
+ sendBackTargets(task) {
1002
+ const run = task?.runId ? this.run(task.runId) : null
1003
+ const def = this.defFor(run)
1004
+ const stages = def?.stages ?? []
1005
+ const stage = stages.find((s) => s.id === task.stage)
1006
+ if (!stage || stage.send_back === false || this.maxRounds(task) === 0) return []
1007
+ const i = stages.indexOf(stage)
1008
+ const allowed = Array.isArray(stage.send_back) ? stage.send_back : stages.slice(0, i).filter((s) => !s.human).map((s) => s.id)
1009
+ return allowed.map((id) => {
1010
+ const t = this.tasks.get(run.taskIds?.[id])
1011
+ const who = t?.children ? [...new Set(t.children.map((c) => this.sup.store.get(this.tasks.get(c)?.assignee)?.handle).filter(Boolean))] : [this.sup.store.get(t?.assignee)?.handle].filter(Boolean)
1012
+ return { stage: id, persona: stages.find((s) => s.id === id)?.persona ?? null, by: who, pieces: t?.children?.length ?? 0 }
1013
+ }).filter((x) => x.persona)
1014
+ }
1015
+
1016
+ maxRounds(task) {
1017
+ const run = task.runId ? this.run(task.runId) : null
1018
+ const def = this.defFor(run)
1019
+ const stage = def?.stages?.find((s) => s.id === task.stage)
1020
+ const base = stage?.max_rounds ?? def?.max_rounds ?? this.sup.config?.maxRounds ?? 3
1021
+ return base + (task.extraRounds ?? 0)
1022
+ }
1023
+
1024
+ /**
1025
+ * Send earlier work back. `from` is the checker's card (null when the
1026
+ * operator does it from the board). `to` is a stage id or a card id.
1027
+ * `pieces` (1-based numbers or card ids) or `files` pick pieces of a split
1028
+ * stage; with neither, a new "fix" piece is added.
1029
+ */
1030
+ async sendBack(from, { to, reason = "", failures = [], files = [], pieces = [], by = null } = {}) {
1031
+ reason = String(reason ?? "").trim()
1032
+ failures = (Array.isArray(failures) ? failures : [failures]).map((f) => String(typeof f === "object" && f ? JSON.stringify(f) : f ?? "").trim()).filter(Boolean).slice(0, 30)
1033
+ files = (Array.isArray(files) ? files : [files]).map(String).filter(Boolean).slice(0, 60)
1034
+ if (!reason && !failures.length) throw new Error("say what is wrong: a reason and/or the failures — the bot fixing it cannot see your conversation")
1035
+ const get = (id) => (typeof id === "string" && id ? this.tasks.get(id) : null)
1036
+ const runId = from?.runId ?? get(to)?.runId
1037
+ const run = runId ? this.run(runId) : null
1038
+ if (!run) throw new Error("sending back only works inside a pipeline workstream")
1039
+ let target = get(run.taskIds?.[to]) ?? get(to)
1040
+ if (target?.runId !== run.id) target = null
1041
+ if (!target) throw new Error(`no stage or card "${to}" on this workstream. Stages: ${Object.keys(run.taskIds).join(", ")}`)
1042
+ if (target.owner === "human" || target.kind === "question") throw new Error("that card is for the operator; ask them with botfarm_ask_human instead")
1043
+ const top = target.parentId ? this.tasks.get(target.parentId) : target
1044
+ const key = top.stage ?? top.id
1045
+ if (from) {
1046
+ if (from.id === target.id || from.id === top.id) throw new Error("that is your own card — fix it yourself or finish it")
1047
+ const ok = this.sendBackTargets(from).map((x) => x.stage)
1048
+ if (!ok.includes(key)) throw new Error(ok.length ? `${from.stage} can send back to: ${ok.join(", ")}` : `${from.stage} cannot send work back (its pipeline says so)`)
1049
+ }
1050
+ const by_ = by ?? (from ? this.sup.store.get(from.assignee)?.handle ?? from.persona : "operator")
1051
+ const room = this.sup.rooms.rooms.get(run.roomId)
1052
+
1053
+ // Rounds, and the same failure twice, are counted per checker card.
1054
+ let round = 1
1055
+ if (from) {
1056
+ from.rounds ??= {}
1057
+ round = (from.rounds[key] ?? 0) + 1
1058
+ const max = this.maxRounds(from)
1059
+ const sig = (failures.length ? failures.join("\n") : reason).toLowerCase().replace(/\d+(\.\d+)?\s*(ms|s)\b/g, "").replace(/\s+/g, " ").slice(0, 600)
1060
+ const same = round > 1 && sig.length >= 20 && from.lastSendBack?.[key]?.sig === sig
1061
+ if (round > max || same) {
1062
+ const why = same ? `the same failure came back after round ${round - 1}` : `${key} has been sent back ${round - 1} time${round > 2 ? "s" : ""} (max ${max})`
1063
+ from.status = "review"
1064
+ from.budget = { kind: "rounds", why, to: key, round: round - 1, max, at: Date.now() }
1065
+ from.pausedAt = Date.now()
1066
+ from.pendingSendBack = { to, reason, failures, files, pieces }
1067
+ this.tasks.save(from)
1068
+ this.sup.store.note(`⏸ "${from.title}": ${why} — waiting for you`, from.assignee, "warn")
1069
+ this.sup.replay?.note(from.projectId, `${from.stage}: ${why}`, "warn")
1070
+ if (room) await this.sup.rooms.post(room, this.sup.store.get(from.assignee) ?? null, `⏸ I wanted to send ${key} back again, but ${why}. Waiting for the operator.`, { quiet: true }).catch(() => {})
1071
+ this.sup.onChange()
1072
+ return { paused: true, why, round: round - 1, max }
1073
+ }
1074
+ from.lastSendBack = { ...(from.lastSendBack ?? {}), [key]: { sig, at: Date.now() } }
1075
+ } else {
1076
+ round = (top.round ?? 1) + 1
1077
+ }
1078
+
1079
+ // Which cards go back.
1080
+ let reopen = []
1081
+ let added = null
1082
+ if (top.children) {
1083
+ const kids = top.children.map((id) => this.tasks.get(id)).filter(Boolean)
1084
+ const want = (Array.isArray(pieces) ? pieces : [pieces]).filter((p) => p !== undefined && p !== null && p !== "")
1085
+ if (target !== top) reopen = [target]
1086
+ else if (want.length) reopen = kids.filter((k) => want.some((p) => String(p) === k.id || Number(p) === k.item?.index))
1087
+ else if (files.length) {
1088
+ const hit = (a, b) => a === b || a.endsWith("/" + b) || b.endsWith("/" + a)
1089
+ reopen = kids.filter((k) => [...(k.files ?? []), ...(k.handoff?.artifacts ?? []), ...(k.report?.files ?? []).map((f) => f.path ?? f)].some((f) => files.some((x) => hit(String(f), x))))
1090
+ }
1091
+ if (!reopen.length && kids.length === 1) reopen = kids
1092
+ if (!reopen.length) {
1093
+ // Nobody owns what failed: a new piece, for any bot of that kind.
1094
+ const total = kids.length + 1
1095
+ added = this.tasks.create({
1096
+ title: `Fix: ${(failures[0] ?? reason).split("\n")[0].slice(0, 80)}`,
1097
+ brief: top.brief, runId: top.runId, projectId: top.projectId, stage: top.stage, persona: top.persona,
1098
+ role: run.personaDefs?.[top.persona]?.role ?? top.persona, owner: "role", kind: "work", createdBy: by_,
1099
+ order: (top.order ?? 0) + total / 1000,
1100
+ })
1101
+ Object.assign(added, { parentId: top.id, item: { title: added.title, detail: reason, index: total, total }, limits: top.limits ?? null, round, rework: { round, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null } })
1102
+ this.tasks.save(added)
1103
+ top.children = [...top.children, added.id]
1104
+ }
1105
+ } else reopen = [target]
1106
+
1107
+ for (const t of reopen) {
1108
+ const prev = t.assignee
1109
+ if (["done", "cancelled", "review"].includes(t.status)) {
1110
+ t.history = [...(t.history ?? []), { round: t.round ?? 1, summary: String(t.handoff?.summary ?? "").slice(0, 2000), usd: t.report?.usd ?? null, tokens: t.report?.tokens ?? null, minutes: t.report?.minutes ?? null, by: this.sup.store.get(prev)?.handle ?? null, at: Date.now() }].slice(-12)
1111
+ t.round = (t.round ?? 1) + 1
1112
+ t.rework = { round: t.round, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null }
1113
+ t.status = "queued"
1114
+ t.queuedAt = Date.now()
1115
+ t.budget = null
1116
+ t.xp = null
1117
+ t.pausedAt = null
1118
+ // Addressed to the part, preferring the bot that did it.
1119
+ t.owner = "role"
1120
+ t.role = run.personaDefs?.[t.persona]?.role ?? t.persona
1121
+ t.assignee = null
1122
+ t.prefer = this.aliveOn(prev, run.projectId) ? prev : null
1123
+ t.preferUntil = t.prefer ? Date.now() + (this.sup.config?.preferMs ?? 90_000) : null
1124
+ this.tasks.save(t)
1125
+ } else {
1126
+ // Still being worked on (dev and QA side by side): tell its bot now.
1127
+ this.tasks.note(t.id, `${by_} found: ${[reason, ...failures].filter(Boolean).join(" · ").slice(0, 1500)}`, by_)
1128
+ t.rework = { round: t.round ?? 1, reason, failures, files, by: by_, fromTask: from?.id ?? null, fromStage: from?.stage ?? null, early: true }
1129
+ this.tasks.save(t)
1130
+ const s = t.status === "active" ? this.sup.store.get(t.assignee) : null
1131
+ if (s) await this.sup.deliver(s, { text: `[botfarm] @${by_} checked your work on "${t.title}" and found a problem:\n${this.reworkText(t.rework)}\nFix it as part of this card before you complete it.`, description: `rework ${t.title}` }).catch(() => {})
1132
+ }
1133
+ }
1134
+ if (top.children && (reopen.length || added)) { top.status = "active"; this.tasks.save(top) }
1135
+
1136
+ // Make sure someone of that kind is there to take it.
1137
+ const persona = top.persona
1138
+ if (!Object.values(run.sessions ?? {}).some((id) => this.aliveOn(id, run.projectId) && this.sup.store.get(id)?.persona === persona)) {
1139
+ await this.ensureBots(run, persona, 1).catch(() => {})
1140
+ }
1141
+
1142
+ // The checker's card (or, from the board, whatever was waiting on it) waits for the fix.
1143
+ const waitOn = top.children ? top.id : target.id
1144
+ const waiters = from ? [from] : this.tasks.all({ runId: run.id }).filter((t) => t.id !== top.id && !t.parentId && this.dependsOnTransitively(t, top.id) && ["active", "queued", "review"].includes(t.status) && t.owner !== "human")
1145
+ for (const w of waiters) {
1146
+ if (w.status === "active" && !from) {
1147
+ const s = this.sup.store.get(w.assignee)
1148
+ if (s && (s.status === "busy" || s.status === "waiting")) await this.sup.abort(s.id).catch(() => {})
1149
+ }
1150
+ w.baseDeps ??= [...(w.dependsOn ?? [])]
1151
+ w.dependsOn = [...new Set([...(w.dependsOn ?? []), waitOn])]
1152
+ w.status = "blocked"
1153
+ w.queuedAt = null
1154
+ w.recheck = { round, to: key, reason, cards: [...reopen.map((t) => t.id), ...(added ? [added.id] : [])], by: by_ }
1155
+ if (from && w === from) from.rounds[key] = round
1156
+ this.tasks.save(w)
1157
+ }
1158
+
1159
+ const names = [...reopen, ...(added ? [added] : [])]
1160
+ const whoFor = names.map((t) => t.prefer ? "@" + (this.sup.store.get(t.prefer)?.handle ?? t.persona) : `a ${t.persona} bot`)
1161
+ const max = from ? this.maxRounds(from) : null
1162
+ const text = `↩ ${by_ === "operator" ? "You" : "@" + by_} sent ${key}${names.length && top.children ? ` (${names.map((t) => t.item ? `piece ${t.item.index}` : t.title).join(", ")})` : ""} back to ${[...new Set(whoFor)].join(", ") || "its bot"}${from ? ` — fix ${round} of ${max}` : ""}: ${(reason || failures[0]).slice(0, 400)}${failures.length > (reason ? 0 : 1) ? `\n${failures.slice(reason ? 0 : 1, 6).map((f) => "• " + f.slice(0, 200)).join("\n")}` : ""}`
1163
+ if (room) await this.sup.rooms.post(room, from ? this.sup.store.get(from.assignee) : null, text, { quiet: true, meta: { kind: "sendback", to: key, round, max, cards: names.map((t) => t.id), from: from?.id ?? null } }).catch(() => {})
1164
+ this.sup.store.note(text.split("\n")[0], from?.assignee ?? null, "warn")
1165
+ this.sup.replay?.note(run.projectId, text.split("\n")[0])
1166
+ this.tasks.unblock()
1167
+ await this.dispatch()
1168
+ return { sent: names.map((t) => ({ id: t.id, title: t.title, to: t.prefer ? this.sup.store.get(t.prefer)?.handle ?? null : null })), round, max, waiting: waiters.map((w) => w.id) }
1169
+ }
1170
+
1171
+ dependsOnTransitively(task, id, seen = new Set()) {
1172
+ for (const d of task.dependsOn ?? []) {
1173
+ if (d === id) return true
1174
+ if (seen.has(d)) continue
1175
+ seen.add(d)
1176
+ const t = this.tasks.get(d)
1177
+ if (t && this.dependsOnTransitively(t, id, seen)) return true
1178
+ }
1179
+ return false
1180
+ }
1181
+
1182
+ reworkText(r) {
1183
+ return [
1184
+ r.reason ? ` ${r.reason}` : "",
1185
+ r.failures?.length ? `What failed:\n${r.failures.map((f) => ` - ${f}`).join("\n")}` : "",
1186
+ r.files?.length ? `Files: ${r.files.join(", ")}` : "",
1187
+ ].filter(Boolean).join("\n")
1188
+ }
1189
+
1190
+ /** The operator answers a card that stopped sending work back. */
1191
+ async roundsDecision(task, action) {
1192
+ const pending = task.pendingSendBack
1193
+ const was = task.budget
1194
+ task.pausedMs = (task.pausedMs ?? 0) + (task.pausedAt ? Date.now() - task.pausedAt : 0)
1195
+ task.pausedAt = null
1196
+ task.budget = null
1197
+ task.pendingSendBack = null
1198
+ const s = this.sup.store.get(task.assignee)
1199
+ if (action === "continue" && pending) {
1200
+ // One more round, and forget the "same failure" match so it can go.
1201
+ task.extraRounds = (task.extraRounds ?? 0) + Math.max(0, (was.round + 1) - this.maxRounds(task))
1202
+ if (task.lastSendBack?.[was.to]) task.lastSendBack[was.to].sig = null
1203
+ task.status = "active"
1204
+ this.tasks.save(task)
1205
+ return this.sendBack(task, pending)
1206
+ }
1207
+ // Accept: the checker finishes with what it has.
1208
+ task.status = "active"
1209
+ task.nudgedAt = null
1210
+ this.tasks.save(task)
1211
+ if (s) await this.sup.deliver(s, {
1212
+ text: `[botfarm] The operator read where ${was.to} stands and says: no more rounds. Finish task ${task.id} — "${task.title}" — now with botfarm_task_complete, and list whatever still fails in open_questions so it is not lost.`,
1213
+ description: `finish ${task.title}`,
1214
+ })
1215
+ return task
1216
+ }
1217
+
1218
+ // --- routing, shared notes, loops, XP -------------------------------------
1219
+
1220
+ /**
1221
+ * Pick the model for a card by how hard it looks: the agent's `tiers:` or
1222
+ * the workspace's botfarm/routing.botfarm.yml. The bot's own override (Model…)
1223
+ * always wins. Recorded on the card so the board can show it.
1224
+ */
1225
+ async routeCard(task, session) {
1226
+ const run = task.runId ? this.run(task.runId) : null
1227
+ const stage = this.defFor(run)?.stages?.find((s) => s.id === task.stage)
1228
+ const ctx = this.contextFor(task)
1229
+ const d = difficulty({
1230
+ explicit: task.item?.difficulty ?? stage?.difficulty ?? task.difficultyWanted ?? null,
1231
+ title: task.title,
1232
+ detail: task.item?.detail ?? "",
1233
+ story: task.item ? "" : task.brief ?? "",
1234
+ criteria: ctx.acceptance_criteria ?? [],
1235
+ })
1236
+ const wsId = run?.workspaceId ?? this.sup.workspaceIdOf(session)
1237
+ const routing = wsId ? await this.sup.workspaces.routing(wsId).catch(() => null) : null
1238
+ const persona = run?.personaDefs?.[task.persona ?? session.persona] ?? null
1239
+ const tier = routeFor(d.level, { agentTiers: persona?.tiers ?? null, routing })
1240
+ const route = tier ? this.applyQuota(tier, { wsId, projectId: task.projectId, persona: task.persona ?? session.persona }) : null
1241
+ task.difficulty = { ...d, route: route ? { model: route.model, variant: route.variant, fellBack: route.fellBack ?? null } : null }
1242
+ session.route = route ? { ...route, source: tier.source, persona: task.persona ?? session.persona, taskId: task.id, level: d.level, wsId, projectId: task.projectId, switchMode: routing?.switch === "next-turn" ? "next-turn" : "now" } : null
1243
+ if (route) this.sup.store.note(`"${task.title}" looks ${d.level} (${d.why}) → ${route.model ?? "same model"}${route.variant ? ` · ${route.variant}` : ""}${route.fellBack ? ` (${route.fellBack.why})` : ""}`, session.id, "info")
1244
+ }
1245
+
1246
+ /**
1247
+ * A tier with a quota on its model: once the workspace has spent it in the
1248
+ * period (a day, week, month or this workstream), use the fallback instead.
1249
+ */
1250
+ applyQuota(tier, { wsId, projectId, persona = null }) {
1251
+ if (!tier.quota || !tier.model || !this.sup.ledger) return tier
1252
+ // An agent's own quota counts only that agent's spend; a workspace quota counts everyone's.
1253
+ const key = tier.source === "agent" && persona ? `${tier.model}@${persona}` : tier.model
1254
+ const spent = this.sup.ledger.spent(wsId, key, tier.quota.per, projectId)
1255
+ const hit = overQuota(tier.quota, spent)
1256
+ if (!hit) return tier
1257
+ const { quota, fallback, ...rest } = tier
1258
+ const label = { usd: `$${Number(hit.limit).toFixed(2)}`, tokens: `${Math.round(hit.limit / 1000)}k tokens`, minutes: `${Math.round(hit.limit)} min` }[hit.kind]
1259
+ return {
1260
+ model: fallback?.model ?? undefined,
1261
+ variant: fallback?.variant ?? (fallback?.model ? undefined : rest.variant),
1262
+ fellBack: { from: tier.model, why: `${tier.model.split("/").pop()} quota of ${label} per ${tier.quota.per} used`, hit },
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Cards already running on a model whose quota just ran out move to the
1268
+ * fallback: at once (the turn is stopped and picked up again on the
1269
+ * fallback) or from the bot's next turn, per the routing file's `switch:`.
1270
+ */
1271
+ async checkQuotas() {
1272
+ for (const s of this.sup.store.list()) {
1273
+ const r = s.route
1274
+ if (!r?.quota || r.fellBack || s.override?.model) continue
1275
+ const next = this.applyQuota(r, { wsId: r.wsId, projectId: r.projectId, persona: r.persona })
1276
+ if (!next.fellBack) continue
1277
+ s.route = { ...next, source: r.source, persona: r.persona, taskId: r.taskId, level: r.level, wsId: r.wsId, projectId: r.projectId, switchMode: r.switchMode }
1278
+ const task = this.tasks.get(r.taskId)
1279
+ if (task?.difficulty) { task.difficulty = { ...task.difficulty, route: { model: next.model, variant: next.variant, fellBack: next.fellBack } }; this.tasks.save(task) }
1280
+ const to = next.model ? next.model.split("/").pop() : "its agent's own model"
1281
+ this.sup.store.note(`@${s.handle}: ${next.fellBack.why} — switching to ${to}${next.variant ? ` · ${next.variant}` : ""}`, s.id, "warn")
1282
+ this.sup.replay?.note(task?.projectId ?? s.project, `@${s.handle} moved to ${to}: ${next.fellBack.why}`, "warn")
1283
+ if (r.switchMode === "now" && s.status === "busy" && task?.status === "active") {
1284
+ await this.sup.abort(s.id).catch(() => {})
1285
+ await this.sup.deliver(s, {
1286
+ text: `[botfarm] ${next.fellBack.why}, so you are now running on ${to}. Carry on with task ${task.id} — "${task.title}" — from where you left off.`,
1287
+ description: `continue ${task.title} on ${to}`,
1288
+ }).catch(() => {})
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * What a finished card cost and changed. Diffs are taken now, per file the
1295
+ * card handed over (capped), so later stages' edits do not rewrite history.
1296
+ * Screenshots are image attachments from the bot's tool results since the
1297
+ * card started, and image files among its artifacts.
1298
+ */
1299
+ async cardReport(task) {
1300
+ const s = this.sup.store.get(task.assignee)
1301
+ const usage = this.taskUsage(task)
1302
+ const dir = s?.directory ?? (task.runId ? this.sup.projects.get(task.projectId)?.worktree : null)
1303
+ const listed = [...new Set((task.handoff?.artifacts ?? []).map(String))].slice(0, 60)
1304
+ let files = listed
1305
+ const diffs = []
1306
+ let budget = 160_000
1307
+ if (dir && task.baseline) {
1308
+ // What changed on disk during the card, however it was changed.
1309
+ const changes = await git.changesSince(dir, task.baseline).catch(() => [])
1310
+ const norm = (f) => String(f).replace(/^\.\//, "").replace(dir + "/", "")
1311
+ const listedSet = new Set(listed.map(norm))
1312
+ for (const c of changes) diffs.push({ ...c, unlisted: !listedSet.has(c.file) })
1313
+ files = [...new Set([...listed.map(norm), ...changes.map((c) => c.file)])].slice(0, 80)
1314
+ } else if (dir) {
1315
+ for (const f of files.slice(0, 20)) {
1316
+ if (/\.(png|jpe?g|gif|webp|svg)$/i.test(f)) continue
1317
+ let d = await git.diffFile(dir, f).catch(() => "")
1318
+ if (!d) continue
1319
+ // A new, untracked file comes back as its contents: show it as all added.
1320
+ if (!/^diff --git /m.test(d)) d = `--- /dev/null\n+++ b/${f}\n@@ new file @@\n` + d.replace(/\n$/, "").split("\n").map((l) => "+" + l).join("\n")
1321
+ const lines = d.split("\n")
1322
+ const added = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).length
1323
+ const removed = lines.filter((l) => l.startsWith("-") && !l.startsWith("---")).length
1324
+ const cap = Math.min(budget, 24_000)
1325
+ if (cap <= 0) { diffs.push({ file: f, added, removed, truncated: true, diff: "" }); continue }
1326
+ const truncated = d.length > cap
1327
+ if (truncated) d = d.slice(0, cap)
1328
+ budget -= d.length
1329
+ diffs.push({ file: f, added, removed, truncated, diff: d })
1330
+ }
1331
+ }
1332
+ const images = []
1333
+ for (const f of files.filter((x) => /\.(png|jpe?g|gif|webp)$/i.test(x) && !diffs.some((d) => d.file === x && d.deleted)).slice(0, 6)) images.push({ source: "worktree", path: f, name: f.split("/").pop() })
1334
+ if (s) {
1335
+ const msgs = await this.sup.client.messages(s.id, { limit: 60, order: "desc" }).catch(() => [])
1336
+ const since = task.startedAt ?? 0
1337
+ const shots = []
1338
+ for (const m of msgs) {
1339
+ const info = m.info ?? m
1340
+ const at = info.time?.created ?? info.time?.start ?? 0
1341
+ if (at && at < since) continue
1342
+ for (const p of m.parts ?? []) {
1343
+ const atts = [
1344
+ ...(p.type === "file" ? [p] : []),
1345
+ ...(Array.isArray(p.state?.attachments) ? p.state.attachments : []),
1346
+ ...(Array.isArray(p.attachments) ? p.attachments : []),
1347
+ ]
1348
+ for (const a of atts) {
1349
+ const mime = a.mime ?? a.mimeType ?? ""
1350
+ const url = a.url ?? (a.data ? `data:${mime};base64,${a.data}` : "")
1351
+ if (/^image\//.test(mime) && /^data:/.test(url)) shots.push({ mime, url, name: a.filename ?? a.name ?? `${p.tool ?? "screenshot"}` })
1352
+ }
1353
+ }
1354
+ }
1355
+ if (shots.length) {
1356
+ const out = join(this.sup.home, "shots")
1357
+ await mkdirp(out, { recursive: true })
1358
+ for (const [i, sh] of shots.slice(0, 8).entries()) {
1359
+ const ext = (sh.mime.split("/")[1] ?? "png").replace("jpeg", "jpg").replace(/[^a-z0-9]/g, "")
1360
+ const file = `${task.id}-${i}.${ext}`
1361
+ const b64 = sh.url.slice(sh.url.indexOf(",") + 1)
1362
+ await writeF(join(out, file), Buffer.from(b64, "base64")).catch(() => {})
1363
+ images.push({ source: "shot", file, name: sh.name })
1364
+ }
1365
+ }
1366
+ }
1367
+ const report = {
1368
+ minutes: task.startedAt ? Math.max(0, Date.now() - task.startedAt - (task.pausedMs ?? 0)) / 60000 : 0,
1369
+ tokens: usage.tokens,
1370
+ usd: usage.usd,
1371
+ model: s ? (this.sup.turnSettings(s).model ?? s.model ?? null) : null,
1372
+ handle: s?.handle ?? null,
1373
+ files,
1374
+ diffs,
1375
+ images,
1376
+ tracked: !!task.baseline,
1377
+ }
1378
+ delete task.baseline // done with it; keep the task record small
1379
+ task.report = report
1380
+ this.tasks.save(task)
1381
+ return report
1382
+ }
1383
+
1384
+ /** The small part of a report that rides on the chat message; the diffs are fetched when opened. */
1385
+ cardMeta(task, r) {
1386
+ return {
1387
+ kind: "card",
1388
+ taskId: task.id,
1389
+ title: task.title,
1390
+ stage: task.stage ?? null,
1391
+ piece: task.item ? `${task.item.index}/${task.item.total}` : null,
1392
+ minutes: r.minutes,
1393
+ tokens: r.tokens,
1394
+ usd: r.usd,
1395
+ model: r.model ?? null,
1396
+ xp: task.xp?.gained ?? null,
1397
+ files: (r.diffs?.length ? r.diffs.map((d) => ({ file: d.file, added: d.added, removed: d.removed, ...(d.unlisted ? { unlisted: true } : {}), ...(d.created ? { created: true } : {}), ...(d.deleted ? { deleted: true } : {}) })) : (r.files ?? []).map((f) => ({ file: f }))).slice(0, 40),
1398
+ images: (r.images ?? []).length,
1399
+ pieces: r.pieces ?? null,
1400
+ bots: r.bots ?? null,
1401
+ open: task.handoff?.open_questions?.length ?? 0,
1402
+ }
1403
+ }
1404
+
1405
+ /** Stop every bot on a workstream until you resume it. */
1406
+ async pauseByHand(projectId) {
1407
+ const project = this.sup.projects.get(projectId)
1408
+ const run = project?.runId ? this.run(project.runId) : null
1409
+ if (!run) throw new Error("this workstream was not started from a pipeline")
1410
+ if (run.paused) return run
1411
+ await this.pauseRun(run, { kind: "manual" })
1412
+ return run
1413
+ }
1414
+
1415
+ /** What the rest of the team already knows: saved notes, and files they have read. */
1416
+ sharedContext(task) {
1417
+ const run = task.runId ? this.run(task.runId) : null
1418
+ if (!run) return ""
1419
+ const notes = run.notes ?? []
1420
+ const me = task.assignee
1421
+ const read = new Map()
1422
+ for (const id of Object.values(run.sessions ?? {})) {
1423
+ if (id === me) continue
1424
+ const s = this.sup.store.get(id)
1425
+ for (const c of s?.toolLog ?? []) {
1426
+ if (!/(^|_)read$|^read/i.test(c.name) || !c.summary || c.status === "error") continue
1427
+ if (!read.has(c.summary)) read.set(c.summary, s.handle)
1428
+ }
1429
+ }
1430
+ const out = []
1431
+ if (notes.length) out.push(`Team notes (read the ones you need with botfarm_notes_read before exploring): ${notes.slice(-15).map((n) => `"${n.topic}" (@${n.by})`).join(", ")}.`)
1432
+ if (read.size) out.push(`Files teammates have already read — check the notes or ask before re-reading them all: ${[...read].slice(-20).map(([f, h]) => `${f} (@${h})`).join(", ")}.`)
1433
+ out.push("When you have mapped an area others will need (a module, a flow, where something lives), save 3–8 lines with botfarm_notes_write so nobody has to read it all again.")
1434
+ return out.join("\n")
1435
+ }
1436
+
1437
+ notes(runId) {
1438
+ return this.run(runId)?.notes ?? []
1439
+ }
1440
+
1441
+ addNote(runId, { topic, text, files = [], by }) {
1442
+ const run = this.run(runId)
1443
+ if (!run) throw new Error("no workstream for this bot")
1444
+ const t = String(topic ?? "").trim().slice(0, 80)
1445
+ if (!t || !String(text ?? "").trim()) throw new Error("a note needs a topic and some text")
1446
+ run.notes ??= []
1447
+ const at = run.notes.findIndex((n) => n.topic.toLowerCase() === t.toLowerCase())
1448
+ const note = { topic: t, text: trimText(String(text), 1500), files: (files ?? []).slice(0, 30).map(String), by, at: Date.now() }
1449
+ if (at >= 0) run.notes[at] = note
1450
+ else run.notes.push(note)
1451
+ run.notes = run.notes.slice(-60)
1452
+ this.db.put("runs", run)
1453
+ this.sup.replay?.note(run.projectId, `@${by} saved a note: ${t}`)
1454
+ return note
1455
+ }
1456
+
1457
+ /** Is this card's bot going round in circles? Only calls since the card started count. */
1458
+ async loopCheck(task, usage, now = Date.now()) {
1459
+ if (task.loopQuietUntil && now < task.loopQuietUntil) return null
1460
+ const s = this.sup.store.get(task.assignee)
1461
+ if (!s || s.status !== "busy") return null
1462
+ const calls = (s.toolLog ?? []).filter((c) => (c.at ?? 0) >= (task.startedAt ?? 0))
1463
+ const persona = task.runId ? this.run(task.runId)?.personaDefs?.[task.persona] : null
1464
+ const canEdit = !persona?.tools?.length || persona.tools.some((t) => /edit|write|patch|\*/.test(t))
1465
+ const ceiling = Number(this.sup.config?.loops?.tokensWithoutEdits) > 0 ? Number(this.sup.config.loops.tokensWithoutEdits) : 250_000
1466
+ // Only ask git once the token ceiling is in play, and at most once a minute per card.
1467
+ let edited
1468
+ if (canEdit && usage.tokens > ceiling && task.baseline && s.directory) {
1469
+ this.editedCache ??= new Map()
1470
+ const hit = this.editedCache.get(task.id)
1471
+ if (hit && now - hit.at < 60_000) edited = hit.edited
1472
+ else { edited = await git.changedSince(s.directory, task.baseline).catch(() => undefined); this.editedCache.set(task.id, { at: now, edited }) }
1473
+ }
1474
+ return stuckReason(calls, { tokens: usage.tokens, canEdit, tokenCeiling: ceiling, edited })
1475
+ }
1476
+
1477
+ levelFor(task) {
1478
+ const run = task.runId ? this.run(task.runId) : null
1479
+ const wsId = run?.workspaceId ?? null
1480
+ return this.sup.farmhands?.get(wsId, task.persona ?? "bot")?.level ?? 1
1481
+ }
1482
+
1483
+ /** A card's limits with the level perks (Seasoned +10%, Veteran +20%) applied. */
1484
+ effectiveLimits(task) {
1485
+ const bonus = budgetBonus(this.levelFor(task))
1486
+ if (!task.limits || !bonus) return task.limits
1487
+ return Object.fromEntries(Object.entries(task.limits).map(([k, v]) => [k, v * (1 + bonus)]))
1488
+ }
1489
+
1490
+ awardXp(task, worker) {
1491
+ if (!this.sup.farmhands || !task.persona || task.owner === "human") return null
1492
+ const run = task.runId ? this.run(task.runId) : null
1493
+ const usage = this.taskUsage(task)
1494
+ let { xp, reasons } = xpForCard({ task, usage, paused: !!task.pausedMs || !!task.autoExtended, openQuestions: task.handoff?.open_questions?.length ?? 0 })
1495
+ // A fix round earns a little, never a full card again: bugs must not pay.
1496
+ // Catching something that needed fixing is worth a little to the checker.
1497
+ if (task.rework && task.round > 1) { xp = 3; reasons = [[`fixed in round ${task.round}`, 3]] }
1498
+ else if (task.rounds && Object.keys(task.rounds).length) { const n = Object.values(task.rounds).reduce((a, b) => a + b, 0); reasons.push([`sent back ${n} time${n > 1 ? "s" : ""} and verified the fix`, 2]); xp += 2 }
1499
+ const out = this.sup.farmhands.award(run?.workspaceId ?? null, task.persona, xp, { reasons, card: task.title })
1500
+ task.xp = { gained: xp, reasons: reasons.map(([r, n]) => `${r} +${n}`) }
1501
+ if (run) {
1502
+ run.xp ??= {}
1503
+ run.xp[task.persona] = (run.xp[task.persona] ?? 0) + xp
1504
+ if (out.levelUp) (run.levelUps ??= []).push({ persona: task.persona, level: out.levelUp.level, hat: out.levelUp.hat, title: out.levelUp.title })
1505
+ this.db.put("runs", run)
1506
+ }
1507
+ if (out.levelUp) {
1508
+ this.sup.store.note(`🎉 ${task.persona} reached level ${out.levelUp.level} — ${out.levelUp.title} ${out.levelUp.hat ?? ""}`, worker?.id ?? null, "info")
1509
+ this.sup.replay?.note(task.projectId, `${task.persona} reached level ${out.levelUp.level} ${out.levelUp.hat ?? ""}`)
1510
+ }
1511
+ return out
1512
+ }
1513
+
1514
+ /** A finished workstream: totals for the harvest log, the celebration, and its PR packet. */
1515
+ async harvest(run) {
1516
+ const project = this.sup.projects.get(run.projectId)
1517
+ const bots = this.sup.projects.sessions(run.projectId)
1518
+ const used = this.runUsage(run)
1519
+ const cards = this.tasks.all({ runId: run.id }).filter((t) => t.status === "done" && !t.children)
1520
+ const h = {
1521
+ id: run.id,
1522
+ ws: run.workspaceId ?? null,
1523
+ project: run.projectId,
1524
+ name: project?.name ?? run.title,
1525
+ at: Date.now(),
1526
+ minutes: used.minutes,
1527
+ usd: used.usd,
1528
+ tokens: used.tokens,
1529
+ budget: run.limits?.usd ?? null,
1530
+ saved: run.limits?.usd ? run.limits.usd - used.usd : null,
1531
+ cards: cards.length,
1532
+ botMinutes: cards.reduce((a, t) => a + (t.startedAt ? Math.max(0, (t.updated ?? Date.now()) - t.startedAt - (t.pausedMs ?? 0)) : 0), 0) / 60000,
1533
+ xp: Object.values(run.xp ?? {}).reduce((a, n) => a + n, 0),
1534
+ xpBy: run.xp ?? {},
1535
+ levelUps: run.levelUps ?? [],
1536
+ bots: bots.map((s) => ({ handle: s.handle, persona: s.persona })),
1537
+ }
1538
+ this.sup.farmhands?.saveHarvest(h)
1539
+ if (project) this.sup.projects.save({ ...this.sup.projects.get(run.projectId), harvest: h })
1540
+ this.sup.replay?.harvest(run.projectId, h)
1541
+ await this.sup.writePacket(run.projectId).catch(() => {})
1542
+ this.sup.onChange()
1543
+ return h
1544
+ }
1545
+
1546
+ // --- budgets ---------------------------------------------------------------
1547
+
1548
+ costOf(session) {
1549
+ if (!session?.cost) return 0
1550
+ return session.cost(this.sup.store.pricing?.() ?? {})?.usd ?? 0
1551
+ }
1552
+
1553
+ /** Minutes, tokens and dollars one card has used since it was picked up. */
1554
+ taskUsage(task, now = Date.now()) {
1555
+ const s = this.sup.store.get(task.assignee)
1556
+ const paused = (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0)
1557
+ return {
1558
+ minutes: task.startedAt ? Math.max(0, now - task.startedAt - paused) / 60000 : 0,
1559
+ tokens: s ? Math.max(0, tokensOf(s) - (task.tokensAtStart ?? 0)) : 0,
1560
+ usd: s ? Math.max(0, this.costOf(s) - (task.usdAtStart ?? 0)) : 0,
1561
+ }
1562
+ }
1563
+
1564
+ /** What a whole workstream has used: every bot on it, and its time since it started. */
1565
+ runUsage(run, now = Date.now()) {
1566
+ const sessions = this.sup.projects.sessions(run.projectId)
1567
+ const paused = (run.pausedMs ?? 0) + (run.paused?.at ? now - run.paused.at : 0)
1568
+ return {
1569
+ minutes: Math.max(0, now - run.created - paused) / 60000,
1570
+ tokens: sessions.reduce((a, s) => a + tokensOf(s), 0) + (run.retired?.tokens ?? 0),
1571
+ usd: sessions.reduce((a, s) => a + this.costOf(s), 0) + (run.retired?.usd ?? 0),
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * Stop anything that has gone past its time, token or dollar limit and ask
1577
+ * the operator. A card over its own limit stops alone; a workstream over its
1578
+ * limit stops every bot on it. Nothing continues until you say so.
1579
+ */
1580
+ async checkBudgets(now = Date.now()) {
1581
+ await this.checkQuotas().catch((e) => this.sup.store.note(`quota check: ${e.message}`, null, "warn"))
1582
+ for (const run of this.runs()) {
1583
+ if (run.status !== "running" || run.paused) continue
1584
+ const hit = overLimit(run.limits, this.runUsage(run, now))
1585
+ if (hit) { await this.pauseRun(run, hit); continue }
1586
+ }
1587
+ for (const task of this.tasks.all({ status: "active" })) {
1588
+ if (task.children || !task.assignee) continue
1589
+ if (task.runId && this.run(task.runId)?.paused) continue
1590
+ const usage = this.taskUsage(task, now)
1591
+ const loop = await this.loopCheck(task, usage, now)
1592
+ if (loop) { await this.pauseTask(task, loop); continue }
1593
+ if (!task.limits) continue
1594
+ const limits = this.effectiveLimits(task)
1595
+ const hit = overLimit(limits, usage)
1596
+ if (!hit) continue
1597
+ // Trusted bots (level 5) extend themselves once per card before asking.
1598
+ const lvl = this.levelFor(task)
1599
+ if (perksAt(lvl).some((p) => p.kind === "autoextend") && !task.autoExtended) {
1600
+ task.autoExtended = true
1601
+ task.limits = Object.fromEntries(Object.entries(task.limits).map(([k, v]) => [k, k === hit.kind ? Math.max(v, hit.used) * 1.25 : v]))
1602
+ this.tasks.save(task)
1603
+ this.sup.store.note(`"${task.title}" reached its ${limitText(hit)} limit — its bot is Trusted (level ${lvl}), so it got 25% more once`, task.assignee, "info")
1604
+ continue
1605
+ }
1606
+ await this.pauseTask(task, hit)
1607
+ }
1608
+ }
1609
+
1610
+ async pauseTask(task, hit) {
1611
+ const s = this.sup.store.get(task.assignee)
1612
+ if (s && (s.status === "busy" || s.status === "waiting")) await this.sup.abort(s.id).catch(() => {})
1613
+ task.status = "review"
1614
+ task.budget = { ...hit, at: Date.now() }
1615
+ task.pausedAt = Date.now()
1616
+ this.tasks.save(task)
1617
+ this.sup.store.note(hit.kind === "loop"
1618
+ ? `⏸ @${s?.handle ?? "a bot"} looks stuck on "${task.title}": ${hit.why} — stopped and waiting for you`
1619
+ : `⏸ "${task.title}" reached its ${limitText(hit)} limit — stopped @${s?.handle ?? "its bot"} and waiting for you`, s?.id ?? null, "warn")
1620
+ this.sup.replay?.note(task.projectId, hit.kind === "loop" ? `@${s?.handle} looked stuck: ${hit.why}` : `"${task.title}" hit its ${hit.kind} limit`, "warn")
1621
+ }
1622
+
1623
+ /** Give a paused card more room and let its bot carry on (or stop it for good). */
1624
+ async budgetTask(task, { action = "continue", add = null, limits = undefined } = {}) {
1625
+ if (!task.budget) throw new Error("this card is not paused on a limit")
1626
+ const now = Date.now()
1627
+ if (task.budget.kind === "rounds" && action !== "stop") return this.roundsDecision(task, action)
1628
+ if (action === "stop") {
1629
+ task.status = "cancelled"
1630
+ task.budget = { ...task.budget, stopped: true }
1631
+ this.tasks.save(task)
1632
+ this.sup.store.note(`stopped "${task.title}" at its limit`, task.assignee, "warn")
1633
+ if (task.parentId) await this.joinParent(task.parentId)
1634
+ this.tasks.unblock()
1635
+ await this.dispatch()
1636
+ this.checkRunDone(task.runId)
1637
+ return task
1638
+ }
1639
+ if (task.budget.kind === "loop") {
1640
+ // Not a limit: nothing to raise. Leave the loop check quiet for a while
1641
+ // and tell the bot what it looked like.
1642
+ task.loopQuietUntil = now + 10 * 60_000
1643
+ } else if (limits !== undefined) {
1644
+ const next = cleanLimits(limits)
1645
+ const hit = overLimit(next, this.taskUsage({ ...task, pausedAt: null, pausedMs: (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0) }, now))
1646
+ if (hit) throw new Error(`still over the ${limitText(hit)} limit — raise it past what is used`)
1647
+ task.limits = next
1648
+ } else task.limits = raiseLimits(task.limits, task.budget, add)
1649
+ task.pausedMs = (task.pausedMs ?? 0) + (task.pausedAt ? now - task.pausedAt : 0)
1650
+ task.pausedAt = null
1651
+ const was = task.budget
1652
+ task.budget = null
1653
+ task.status = "active"
1654
+ task.nudgedAt = null
1655
+ this.tasks.save(task)
1656
+ const s = this.sup.store.get(task.assignee)
1657
+ if (s && was.kind === "loop") await this.sup.deliver(s, {
1658
+ text: `[botfarm] You were stopped on task ${task.id} — "${task.title}" — because you looked stuck: ${was.why}. The operator says carry on. Try a different approach rather than repeating the same step; check botfarm_notes_read and ask in the chat if you are missing something.`,
1659
+ description: `continue ${task.title}`,
1660
+ })
1661
+ else if (s) await this.sup.deliver(s, {
1662
+ text: `[botfarm] You were stopped at this card's ${limitText(was)} limit. The operator gave you more room (${limitsText(task.limits)}). Carry on with task ${task.id} — "${task.title}" — from where you left off, and call botfarm_task_complete when it is done.`,
1663
+ description: `continue ${task.title}`,
1664
+ })
1665
+ return task
1666
+ }
1667
+
1668
+ async pauseRun(run, hit) {
1669
+ const interrupted = []
1670
+ for (const s of this.sup.projects.sessions(run.projectId)) {
1671
+ if (s.status !== "busy" && s.status !== "waiting") continue
1672
+ await this.sup.abort(s.id).catch(() => {})
1673
+ for (const t of this.tasks.all({ assignee: s.id, status: "active" })) interrupted.push(t.id)
1674
+ }
1675
+ run.paused = { ...hit, at: Date.now(), interrupted }
1676
+ this.db.put("runs", run)
1677
+ this.sup.store.note(hit.kind === "manual" ? `⏸ workstream paused — ${interrupted.length} bot(s) stopped until you resume it` : `⏸ workstream reached its ${limitText(hit)} limit — every bot on it is stopped until you raise it`, null, "warn")
1678
+ this.sup.replay?.note(run.projectId, hit.kind === "manual" ? "paused by you" : `paused at its ${hit.kind} limit`, "warn")
1679
+ this.sup.onChange()
1680
+ }
1681
+
1682
+ /** Raise (or clear) a workstream's limits and pick up where it stopped — or stop it. */
1683
+ async budgetRun(projectId, { action = "continue", limits = undefined } = {}) {
1684
+ const project = this.sup.projects.get(projectId)
1685
+ const run = project?.runId ? this.run(project.runId) : null
1686
+ if (!run) throw new Error("this workstream was not started from a pipeline")
1687
+ const now = Date.now()
1688
+ if (action === "stop") {
1689
+ for (const s of this.sup.projects.sessions(projectId)) if (s.status === "busy") await this.sup.abort(s.id).catch(() => {})
1690
+ for (const t of this.tasks.all({ runId: run.id })) if (!["done", "cancelled"].includes(t.status)) this.tasks.update(t.id, { status: "cancelled" })
1691
+ run.status = "stopped"
1692
+ run.paused = null
1693
+ this.db.put("runs", run)
1694
+ this.sup.projects.save({ ...project, status: "stopped" })
1695
+ this.sup.store.note(`stopped workstream ${project.name}`, null, "warn")
1696
+ return run
1697
+ }
1698
+ if (limits !== undefined) run.limits = cleanLimits(limits)
1699
+ const paused = run.paused
1700
+ if (paused) {
1701
+ // Still over after the change? Say so rather than resume into another stop.
1702
+ const hit = overLimit(run.limits, this.runUsage({ ...run, paused: null, pausedMs: (run.pausedMs ?? 0) + (now - paused.at) }, now))
1703
+ if (hit) throw new Error(`still over the ${limitText(hit)} limit — raise it past what is used`)
1704
+ run.pausedMs = (run.pausedMs ?? 0) + (now - paused.at)
1705
+ run.paused = null
1706
+ }
1707
+ this.db.put("runs", run)
1708
+ if (paused) {
1709
+ for (const id of paused.interrupted ?? []) {
1710
+ const t = this.tasks.get(id)
1711
+ const s = t && this.sup.store.get(t.assignee)
1712
+ if (t?.status !== "active" || !s) continue
1713
+ await this.sup.deliver(s, { text: `[botfarm] The workstream was paused${paused.kind === "manual" ? " by the operator" : ` at its ${limitText(paused)} limit`} and has been resumed. Carry on with task ${t.id} — "${t.title}" — from where you left off.`, description: `resume ${t.title}` }).catch(() => {})
1714
+ }
1715
+ this.sup.store.note(`resumed workstream ${project.name} (${limitsText(run.limits)})`, null, "info")
1716
+ await this.dispatch()
1717
+ }
1718
+ this.sup.onChange()
1719
+ return run
1720
+ }
1721
+
1722
+ /** For the dashboard: limits, use and pause state of the workstream behind a project. */
1723
+ budgetView(project) {
1724
+ const run = project?.runId ? this.run(project.runId) : null
1725
+ if (!run) return null
1726
+ return { limits: run.limits ?? null, used: this.runUsage(run), paused: run.paused ?? null, status: run.status }
1727
+ }
1728
+
1729
+ // --- watchdog --------------------------------------------------------------
1730
+
1731
+ /**
1732
+ * A session went idle. If it still holds an active task, it most likely
1733
+ * finished its turn without calling botfarm_task_complete — and nothing
1734
+ * downstream will ever start. Remind it once; if it stops again, hand the
1735
+ * task to the operator instead of letting the run sit there forever.
1736
+ */
1737
+ idle(session, { delay = 5_000 } = {}) {
1738
+ this.idleTimers ??= new Map()
1739
+ clearTimeout(this.idleTimers.get(session.id))
1740
+ this.idleTimers.set(
1741
+ session.id,
1742
+ setTimeout(() => {
1743
+ this.idleTimers.delete(session.id)
1744
+ const fresh = this.sup.store.get(session.id)
1745
+ // Busy again: opencode was only between turns.
1746
+ if (!fresh || fresh.status !== "idle") return
1747
+ this.checkForgotten(fresh).catch((err) => this.sup.store.note(`watchdog: ${err.message}`, session.id, "warn"))
1748
+ }, delay),
1749
+ )
1750
+ }
1751
+
1752
+ async checkForgotten(session, { now = Date.now(), grace = 15_000 } = {}) {
1753
+ const open = this.tasks.all({ assignee: session.id, status: "active" }).filter((t) => t.owner !== "human" && !(t.runId && this.run(t.runId)?.paused))
1754
+ for (const task of open) {
1755
+ if (now - (task.startedAt ?? task.updated) < grace) continue
1756
+ // Handed the card but never spent a token: the turn did not run at all
1757
+ // (wrong model, provider error). A reminder would fail the same way.
1758
+ if (tokensOf(session) === (task.tokensAtStart ?? 0)) {
1759
+ if (!task.noRun) {
1760
+ task.noRun = true
1761
+ this.tasks.save(task)
1762
+ this.sup.store.note(`@${session.handle} was handed "${task.title}" but never ran${session.lastError ? `: ${session.lastError}` : " — see the opencode log"}. Fix it, then Restart the workstream.`, session.id, "error")
1763
+ }
1764
+ continue
1765
+ }
1766
+ if (!task.nudgedAt) {
1767
+ task.nudgedAt = now
1768
+ this.tasks.save(task)
1769
+ await this.sup.deliver(session, {
1770
+ text: [
1771
+ `[botfarm] You stopped, but task ${task.id} (${task.title}) is still open.`,
1772
+ "",
1773
+ "Nothing downstream starts until you hand off. If the work is done, call botfarm_task_complete now with",
1774
+ "your summary, acceptance_criteria, artifacts and open_questions. If you are blocked, call",
1775
+ "botfarm_task_complete with what you have and put the blocker in open_questions, or use botfarm_ask_human.",
1776
+ ].join("\n"),
1777
+ description: `reminder: hand off ${task.stage ?? task.title}`,
1778
+ })
1779
+ this.sup.store.note(`@${session.handle} went idle without handing off "${task.title}" — reminded it`, session.id, "warn")
1780
+ continue
1781
+ }
1782
+ if (task.escalated || now - task.nudgedAt < grace) continue
1783
+ task.escalated = true
1784
+ task.status = "review"
1785
+ this.tasks.save(task)
1786
+ this.tasks.create({
1787
+ title: `@${session.handle} stopped without finishing "${task.stage ?? task.title}"`.slice(0, 120),
1788
+ brief: [
1789
+ `@${session.handle} went idle twice with ${task.id} still open, even after a reminder.`,
1790
+ session.lastText ? `Its last words: "${session.lastText}"` : "",
1791
+ "Your answer is sent to it. Or move the task to done on the board to let the run continue.",
1792
+ ].filter(Boolean).join("\n\n"),
1793
+ kind: "question",
1794
+ owner: "human",
1795
+ askedBy: session.id,
1796
+ projectId: task.projectId,
1797
+ runId: task.runId,
1798
+ createdBy: "watchdog",
1799
+ })
1800
+ this.sup.store.note(`@${session.handle} is stuck on "${task.title}" — it needs you`, session.id, "error")
1801
+ }
1802
+ }
1803
+
1804
+ // --- export --------------------------------------------------------------
1805
+
1806
+ /** Turn a finished run back into a pipeline definition you can share. */
1807
+ exportRun(runId) {
1808
+ const run = this.run(runId)
1809
+ if (!run) throw new Error("unknown run")
1810
+ const def = this.defFor(run)
1811
+ const stages = (def?.stages ?? []).map((s) => {
1812
+ const task = this.tasks.get(run.taskIds[s.id])
1813
+ return {
1814
+ ...s,
1815
+ notes: task?.handoff?.summary ? `previous run: ${task.handoff.summary.slice(0, 200)}` : undefined,
1816
+ }
1817
+ })
1818
+ return YAML.stringify({
1819
+ id: `${run.pipeline}-from-${run.id.slice(-4)}`,
1820
+ title: def?.title ?? run.pipeline,
1821
+ description: `Exported from run ${run.id}`,
1822
+ stages,
1823
+ })
1824
+ }
1825
+ }
1826
+
1827
+ /** What was said in the chat before this bot's stage started, handed over once. */
1828
+ function earlier(room, session, when = "before your stage started") {
1829
+ if (!room) return ""
1830
+ const ids = room.pending.get(session.id) ?? []
1831
+ if (!ids.length) return ""
1832
+ room.pending.set(session.id, [])
1833
+ room.unread?.set(session.id, 0)
1834
+ const msgs = room.messages.filter((m) => ids.includes(m.id))
1835
+ if (!msgs.length) return ""
1836
+ return [
1837
+ `\n\nSaid in ${room.name} ${when} (lines marked operator are the human running this; @handles are other agents):`,
1838
+ ...msgs.map((m) => `${m.from === "operator" ? "operator" : "@" + m.from}: ${m.text}`),
1839
+ ].join("\n")
1840
+ }
1841
+
1842
+ /** Items for a split: strings or { title, detail } objects, from a handoff list. */
1843
+ function normalizeItems(list) {
1844
+ if (!Array.isArray(list)) return []
1845
+ return list.map((x) => {
1846
+ if (typeof x === "string") return x.trim() ? { title: x.trim().slice(0, 200) } : null
1847
+ if (x && typeof x === "object") {
1848
+ const title = String(x.title ?? x.name ?? x.summary ?? "").trim()
1849
+ const detail = String(x.detail ?? x.description ?? x.details ?? "").trim()
1850
+ const diff = ["easy", "normal", "hard"].includes(String(x.difficulty ?? "").toLowerCase()) ? String(x.difficulty).toLowerCase() : null
1851
+ return title ? { title: title.slice(0, 200), ...(detail ? { detail: detail.slice(0, 4000) } : {}), ...(diff ? { difficulty: diff } : {}) } : null
1852
+ }
1853
+ return null
1854
+ }).filter(Boolean)
1855
+ }
1856
+
1857
+ const LIMIT_UNITS = { minutes: "time", tokens: "token", usd: "dollar" }
1858
+ function overLimit(limits, used) {
1859
+ if (!limits) return null
1860
+ for (const kind of ["usd", "tokens", "minutes"]) {
1861
+ if (limits[kind] > 0 && used[kind] >= limits[kind]) return { kind, limit: limits[kind], used: used[kind] }
1862
+ }
1863
+ return null
1864
+ }
1865
+ const fmtLimit = (kind, v) => kind === "usd" ? `$${Number(v).toFixed(2)}` : kind === "tokens" ? `${Math.round(v).toLocaleString("en-US")} tokens` : `${Math.round(v)} min`
1866
+ const limitText = (hit) => `${LIMIT_UNITS[hit.kind]} (${fmtLimit(hit.kind, hit.limit)})`
1867
+ const limitsText = (l) => l ? Object.entries(l).map(([k, v]) => fmtLimit(k, v)).join(", ") : "no limits"
1868
+ /** The limit that was hit goes up by what the operator adds, or by half again. */
1869
+ function raiseLimits(limits, hit, add) {
1870
+ const out = { ...(limits ?? {}) }
1871
+ const extra = cleanLimits(add)
1872
+ if (extra) for (const [k, v] of Object.entries(extra)) out[k] = Math.max(out[k] ?? 0, hit.kind === k ? hit.used : 0) + v
1873
+ else if (hit) out[hit.kind] = Math.max(hit.limit, hit.used) * 1.5
1874
+ return cleanLimits(out)
1875
+ }
1876
+
1877
+ const clampParallel = (n) => Math.max(1, Math.min(10, Math.round(Number(n) || 1)))
1878
+
1879
+ /** { minutes, tokens, usd } with only positive numbers kept, or null. */
1880
+ export function cleanLimits(l) {
1881
+ if (!l || typeof l !== "object") return null
1882
+ const out = {}
1883
+ for (const k of ["minutes", "tokens", "usd"]) {
1884
+ const v = Number(l[k] ?? (k === "usd" ? l.dollars ?? l.cost : undefined))
1885
+ if (Number.isFinite(v) && v > 0) out[k] = v
1886
+ }
1887
+ return Object.keys(out).length ? out : null
1888
+ }
1889
+
1890
+ const tokensOf = (s) => {
1891
+ const t = s?.totals ?? {}
1892
+ return (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
1893
+ }
1894
+
1895
+ function teamEntry(p, name) {
1896
+ if (!p) return null
1897
+ return { agent: p.agent ?? null, model: p.model ?? null, variant: p.variant ?? null, tools: p.tools ?? null, title: p.title ?? name }
1898
+ }
1899
+
1900
+ function slugify(s, max = 36) {
1901
+ const full = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
1902
+ return (full.length <= max ? full : full.slice(0, max).replace(/-[^-]*$/, "")) || "work"
1903
+ }
1904
+
1905
+ function topicFrom(story) {
1906
+ const line = String(story ?? "").split(/\n/).map((l) => l.trim()).find(Boolean) ?? ""
1907
+ if (line.length <= 90) return line
1908
+ return line.slice(0, 90).replace(/\s+\S*$/, "") + "…"
1909
+ }
1910
+
1911
+ function stageContextFor(def, persona) {
1912
+ return (def.stages ?? [])
1913
+ .filter((st) => st.persona === persona && st.context)
1914
+ .map((st) => st.context)
1915
+ .join("\n\n")
1916
+ }
1917
+
1918
+ export function checkPersona(doc) {
1919
+ const out = []
1920
+ if (!doc.prompt) out.push("no prompt")
1921
+ if (doc.tools && !Array.isArray(doc.tools)) out.push("tools should be a list")
1922
+ if (doc.tiers && (typeof doc.tiers !== "object" || Object.keys(doc.tiers).some((k) => !["easy", "normal", "hard"].includes(k)))) out.push("tiers has easy, normal and hard, each { model, variant }")
1923
+ return out
1924
+ }
1925
+
1926
+ const GENERIC_TASK_PROMPT = `{{story}}
1927
+
1928
+ {{#acceptance_criteria}}Acceptance criteria:
1929
+ {{#acceptance_criteria}} - {{.}}
1930
+ {{/acceptance_criteria}}
1931
+ {{/acceptance_criteria}}
1932
+ {{#received}}
1933
+ From {{persona}} ({{stage}}):
1934
+ {{summary}}
1935
+ {{#artifacts}} changed: {{.}}
1936
+ {{/artifacts}}
1937
+ {{/received}}`
1938
+
1939
+ // Shipped once into ~/.botfarm so there is something to run on day one, and never
1940
+ // overwritten afterwards — they are yours to edit.
1941
+ export const DEFAULTS = {
1942
+ personas: {
1943
+ "product.yaml": `id: product
1944
+ title: Product Bot
1945
+ role: product
1946
+ # A light touch on purpose: this bot polishes criteria, it does not study the
1947
+ # code. Only read-only tools, and a small budget of them.
1948
+ tools: [read, grep, glob, list]
1949
+ # model: anthropic/claude-haiku-4-5 # a smaller model is usually plenty here
1950
+ prompt: |
1951
+ You are the product analyst on this change. Your job is to polish the
1952
+ acceptance criteria you are handed into a list a developer can build against
1953
+ and a tester can verify. You are not the engineer: understanding the code
1954
+ deeply, designing the change and finding edge cases in the implementation
1955
+ are the dev's job, not yours.
1956
+
1957
+ Work quickly:
1958
+ - Start from the story and any criteria it already has. Keep what is there;
1959
+ tighten wording, make each one observable, fill obvious gaps.
1960
+ - Look at the code only to get names right (the endpoint, the entity, the
1961
+ error type) — a handful of searches at most, no deep reading, no running
1962
+ anything. If you catch yourself tracing call chains, stop.
1963
+ - "Login works" is not a criterion. "After SSO login the user lands on /home
1964
+ and the session cookie is SameSite=Lax" is. Four to eight is usually right.
1965
+ - Things you genuinely cannot decide go in open_questions, with choices where
1966
+ there are obvious ones. Do not ask what the dev can find out from the code.
1967
+
1968
+ Then call botfarm_task_complete. You do not write production code.
1969
+ `,
1970
+ "dev.yaml": `id: dev
1971
+ title: Dev Bot
1972
+ role: dev
1973
+ prompt: |
1974
+ You are the engineer on this change.
1975
+
1976
+ You will be handed acceptance criteria. Build the smallest change that
1977
+ satisfies all of them, following the conventions already in the repository.
1978
+ If a criterion is ambiguous or wrong, say so in your handoff rather than
1979
+ guessing quietly — and ask the product bot in the group chat if it blocks you.
1980
+
1981
+ Commit nothing you have not run.
1982
+ `,
1983
+ "qa.yaml": `id: qa
1984
+ title: QA Bot
1985
+ role: qa
1986
+ prompt: |
1987
+ You are the tester on this change.
1988
+
1989
+ You will be handed the criteria and a description of what was built. Verify
1990
+ each criterion against the running code, then write automated tests that
1991
+ would fail if the behaviour regressed. Tests that pass whether or not the
1992
+ feature works are worse than no tests; check that each one fails before the
1993
+ fix and passes after.
1994
+
1995
+ Report every criterion you could not verify. Do not fix the code yourself —
1996
+ raise a task for the dev bot instead.
1997
+ may_spawn: false
1998
+ `,
1999
+ "reviewer.yaml": `id: reviewer
2000
+ title: Double-Check Bot
2001
+ role: reviewer
2002
+ prompt: |
2003
+ You are the final check.
2004
+
2005
+ You will be handed the original story, the acceptance criteria, what was
2006
+ built and what was tested. Your only question is whether the whole chain
2007
+ holds together: does the delivered change actually satisfy the criteria, and
2008
+ do the criteria actually satisfy the story?
2009
+
2010
+ Be specific about gaps. "Criterion 3 is untested: the test asserts the
2011
+ redirect fires but not that the cookie is set" is useful. "Looks good" is
2012
+ not. If something is missing, raise a task for whoever should fix it.
2013
+ `,
2014
+ },
2015
+ pipelines: {
2016
+ "story.yaml": `id: story
2017
+ title: Story to verified change
2018
+ description: Product sharpens the story, dev builds it, QA proves it, a reviewer checks the chain.
2019
+ stages:
2020
+ - id: analyse
2021
+ persona: product
2022
+ title: Polish the acceptance criteria
2023
+ receives: []
2024
+ prompt: |
2025
+ Story:
2026
+
2027
+ {{story}}
2028
+
2029
+ Polish this into acceptance criteria. Keep the criteria it already has,
2030
+ sharpen them, add only what is clearly missing. Glance at the code only
2031
+ to get names right — the dev does the deep reading. Put the list in
2032
+ acceptance_criteria on your handoff; everything downstream is built and
2033
+ tested against it.
2034
+
2035
+ - id: build
2036
+ persona: dev
2037
+ title: Implement the change
2038
+ receives: [analyse]
2039
+ prompt: |
2040
+ Original story:
2041
+
2042
+ {{story}}
2043
+
2044
+ {{#handoffs.analyse}}
2045
+ {{persona}} sharpened it into these criteria:
2046
+ {{#acceptance_criteria}} - {{.}}
2047
+ {{/acceptance_criteria}}
2048
+
2049
+ Their notes: {{summary}}
2050
+ {{#open_questions}}Unresolved: {{.}}
2051
+ {{/open_questions}}
2052
+ {{/handoffs.analyse}}
2053
+
2054
+ Implement it. {{#room}}You can reach the others in {{room}}.{{/room}}
2055
+
2056
+ - id: verify
2057
+ persona: qa
2058
+ title: Test the change and automate the proof
2059
+ receives: [analyse, build]
2060
+ prompt: |
2061
+ {{#handoffs.analyse}}
2062
+ Acceptance criteria:
2063
+ {{#acceptance_criteria}} - {{.}}
2064
+ {{/acceptance_criteria}}
2065
+ {{/handoffs.analyse}}
2066
+
2067
+ {{#handoffs.build}}
2068
+ What was built, per {{persona}}:
2069
+ {{summary}}
2070
+ {{#artifacts}} changed: {{.}}
2071
+ {{/artifacts}}
2072
+ {{/handoffs.build}}
2073
+
2074
+ Verify each criterion and write the automated tests. List in
2075
+ open_questions any criterion you could not verify.
2076
+
2077
+ - id: check
2078
+ persona: reviewer
2079
+ title: Check the chain end to end
2080
+ receives: [analyse, build, verify]
2081
+ prompt: |
2082
+ Original story:
2083
+
2084
+ {{story}}
2085
+
2086
+ {{#received}}
2087
+ --- {{persona}} ({{stage}}) ---
2088
+ {{summary}}
2089
+ {{#open_questions}} open: {{.}}
2090
+ {{/open_questions}}
2091
+ {{/received}}
2092
+
2093
+ Does the delivered change satisfy the criteria, and do the criteria
2094
+ satisfy the story? Raise a task for anything that does not hold.
2095
+
2096
+ # A stage with "human: true" is yours: it lands in Needs you rather than
2097
+ # being dispatched, and the pipeline waits for your answer.
2098
+ - id: signoff
2099
+ human: true
2100
+ title: Sign off before this ships
2101
+ receives: [check]
2102
+ prompt: |
2103
+ {{#handoffs.check}}{{summary}}{{/handoffs.check}}
2104
+
2105
+ Anything you want changed before this ships?
2106
+ `,
2107
+ },
2108
+ }
2109
+
2110
+ export function checkPipelineDoc(doc, personas) {
2111
+ const out = []
2112
+ const stages = doc.stages ?? []
2113
+ if (!stages.length) out.push("no stages")
2114
+ const ids = stages.map((s) => s.id)
2115
+ for (const s of stages) {
2116
+ if (!s.id) out.push("a stage has no id")
2117
+ if (!s.persona && !s.human) out.push(`stage ${s.id}: no persona (or mark it human: true)`)
2118
+ if (s.persona && !s.human && !personas.has(s.persona)) out.push(`stage ${s.id}: no persona called "${s.persona}"`)
2119
+ for (const r of s.receives ?? []) {
2120
+ if (!ids.includes(r)) out.push(`stage ${s.id} receives "${r}", which is not a stage in this pipeline`)
2121
+ }
2122
+ const before = ids.slice(0, ids.indexOf(s.id))
2123
+ if (s.after !== undefined && !Array.isArray(s.after)) out.push(`stage ${s.id}: after must be a list of earlier stage ids`)
2124
+ for (const a of Array.isArray(s.after) ? s.after : []) {
2125
+ if (!before.includes(a)) out.push(`stage ${s.id} is after "${a}", which is not an earlier stage`)
2126
+ }
2127
+ if (s.split && s.human) out.push(`stage ${s.id}: a human stage cannot be split`)
2128
+ if (s.difficulty !== undefined && !["easy", "normal", "hard"].includes(s.difficulty)) out.push(`stage ${s.id}: difficulty must be easy, normal or hard`)
2129
+ if (s.max_rounds !== undefined && !(Number.isInteger(s.max_rounds) && s.max_rounds >= 0 && s.max_rounds <= 20)) out.push(`stage ${s.id}: max_rounds is a whole number from 0 (never send back) to 20`)
2130
+ if (s.send_back !== undefined && s.send_back !== false) {
2131
+ const list = Array.isArray(s.send_back) ? s.send_back : null
2132
+ if (!list) out.push(`stage ${s.id}: send_back is a list of earlier stage ids, or false`)
2133
+ for (const a of list ?? []) if (!before.includes(a)) out.push(`stage ${s.id} can send back to "${a}", which is not an earlier stage`)
2134
+ }
2135
+ if (s.split && typeof s.split === "string" && s.split.includes(".") && !before.includes(s.split.split(".")[0])) out.push(`stage ${s.id} splits on "${s.split}", but ${s.split.split(".")[0]} is not an earlier stage`)
2136
+ if (s.parallel !== undefined && !(Number(s.parallel) >= 1 && Number(s.parallel) <= 10)) out.push(`stage ${s.id}: parallel must be 1–10`)
2137
+ if (s.limits !== undefined && (typeof s.limits !== "object" || !cleanLimits(s.limits))) out.push(`stage ${s.id}: limits needs minutes, tokens and/or usd as positive numbers`)
2138
+ // A prompt referring to a handoff it does not receive renders empty and
2139
+ // the bot silently works with less context than the author intended.
2140
+ for (const v of variables(s.prompt ?? "")) {
2141
+ if (v === "handoffs" && !(s.receives ?? []).length) out.push(`stage ${s.id}: prompt uses handoffs but receives nothing`)
2142
+ }
2143
+ }
2144
+ return out
2145
+ }