@fradser/pi-agent-teams 0.10.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worker.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { isTaskReplaced } from "@fradser/pi-tasks";
1
2
  /**
2
- * Teammate capabilities. A single addressed agent_event primitive covers
3
+ * Agent capabilities. A single addressed agent_event primitive covers
3
4
  * leader reports and peer mail. Task-list registration is shared by leader
4
5
  * and worker processes; claiming and submitting remain worker-only.
5
6
  */
@@ -8,34 +9,41 @@ import { createHash, randomUUID } from "node:crypto";
8
9
  import * as fs from "node:fs";
9
10
  import * as path from "node:path";
10
11
  import { truncateTail, type ExtensionAPI, type MessageEndEvent } from "@earendil-works/pi-coding-agent";
12
+ import { setOwnedTools } from "@fradser/pi-kit";
13
+ import { isTaskStatus, resourcesConflict, type TaskStatus } from "@fradser/pi-tasks";
11
14
  import { messageRow, workerWorkRow } from "./tool-copy.ts";
12
15
  import { emptyToolCall, renderCoordinationRow, textOf } from "./tool-render.ts";
13
16
  import { resolveRecipient } from "./recipient.ts";
14
- import { appendInboxMessage, appendWorkerEvent, createTaskIntent, readBoardFile, readRoster } from "./statefile.ts";
17
+ import { appendInboxMessage, appendWorkerEvent, readRoster } from "./statefile.ts";
18
+ import { createTaskIntent, readTasksFile } from "@fradser/pi-tasks";
15
19
  import {
16
20
  AgentEventParams,
17
21
  LEADER_RECIPIENT,
18
22
  messageTitle,
19
23
  WorkerWorkToolParams,
20
- type BoardTask,
24
+ type Task,
21
25
  } from "./types.ts";
22
26
 
23
- const BOARD_DISCLOSURE_TOOLS = ["work"] as const;
24
- type BoardDisclosure = "none" | "notice" | "claimed";
27
+ const TASK_DISCLOSURE_TOOLS = ["task"] as const;
28
+ /** Why the tasks tool is shown to this worker. `held` means it already
29
+ * owns an assignment, which is a fact about this worker rather than a task
30
+ * status — the two were never the same state and must not share a word. */
31
+ type TaskDisclosure = "none" | "notice" | "held";
25
32
 
26
33
  export interface WorkerToolDisclosure {
27
- update(prompt: string): void;
34
+ update(prompt?: string): void;
28
35
  reset(): void;
29
36
  }
30
37
 
31
38
  function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
32
- let state: BoardDisclosure = "none";
39
+ let state: TaskDisclosure = "none";
33
40
  const apply = () => {
34
41
  if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
35
- const active = pi.getActiveTools();
36
- const withoutBoardControls = active.filter((tool) => !BOARD_DISCLOSURE_TOOLS.includes(tool as typeof BOARD_DISCLOSURE_TOOLS[number]));
37
- const revealed = state === "notice" || state === "claimed" ? ["work"] : [];
38
- pi.setActiveTools([...withoutBoardControls, ...revealed]);
42
+ const revealed = state === "notice" || state === "held" ? ["task"] : [];
43
+ // `work` is re-asserted from the owned set rather than the snapshot, so this
44
+ // disclosure write cannot deactivate a tool the harness has not published
45
+ // yet. See setOwnedTools.
46
+ setOwnedTools(pi, { owned: TASK_DISCLOSURE_TOOLS, toggled: revealed, enabled: revealed.length > 0 });
39
47
  };
40
48
  return {
41
49
  update(prompt) {
@@ -48,8 +56,8 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
48
56
  // able to acknowledge it; hiding work here strands its resource lease.
49
57
  if (binding && rosterEntry && rosterEntry.spawnId === binding.spawnId
50
58
  && rosterEntry.status !== "stopped" && assignment) {
51
- state = "claimed";
52
- } else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
59
+ state = "held";
60
+ } else if (!assignment && prompt?.includes("=== TASK NOTICE ===")) state = "notice";
53
61
  else state = "none";
54
62
  apply();
55
63
  },
@@ -66,7 +74,7 @@ export interface WorkerBinding {
66
74
  outbox: string;
67
75
  inbox: string;
68
76
  rosterFile: string;
69
- boardFile: string;
77
+ tasksFile: string;
70
78
  claimsDir: string;
71
79
  submissionsDir: string;
72
80
  }
@@ -77,7 +85,7 @@ const REQUIRED_ENV = [
77
85
  "PI_TEAMMATE_OUTBOX_FILE",
78
86
  "PI_TEAMMATE_INBOX_FILE",
79
87
  "PI_TEAMMATE_ROSTER_FILE",
80
- "PI_TEAMMATE_BOARD_FILE",
88
+ "PI_TEAMMATE_TASKS_FILE",
81
89
  "PI_TEAMMATE_CLAIMS_DIR",
82
90
  "PI_TEAMMATE_SUBMISSIONS_DIR",
83
91
  ] as const;
@@ -85,35 +93,40 @@ const REQUIRED_ENV = [
85
93
  export function workerBinding(): WorkerBinding | undefined {
86
94
  const values = REQUIRED_ENV.map((name) => process.env[name]);
87
95
  if (values.some((value) => !value)) return undefined;
88
- const [worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir] = values as string[];
89
- return { worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir };
96
+ const [worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir] = values as string[];
97
+ return { worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir };
90
98
  }
91
99
 
92
- function loadBoardTasks(binding: WorkerBinding): BoardTask[] {
93
- const board = readBoardFile(binding.boardFile);
94
- return Object.values(board?.tasks ?? {}).sort((a, b) => a.id.localeCompare(b.id));
100
+ function loadWorkerTasks(binding: WorkerBinding): Task[] {
101
+ const tasks = readTasksFile(binding.tasksFile);
102
+ return Object.values(tasks?.tasks ?? {}).map(task => {
103
+ // Workers read the leader's file without restoring the shared store. Legacy
104
+ // files can still be present until its next mutation, so normalize status
105
+ // here without resetting an active participant's holding.
106
+ const raw: unknown = task.status;
107
+ const status = raw === "recovery_hold" ? "blocked"
108
+ : raw === "superseded" ? (task.supersededBy ? "pending" : "blocked")
109
+ : isTaskStatus(raw) ? raw : "blocked";
110
+ return { ...task, status, ...(raw === "superseded" && !task.supersededBy ? { replacementUnresolved: true } : {}) };
111
+ }).sort((a, b) => a.id.localeCompare(b.id));
95
112
  }
96
113
 
97
- function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolean {
114
+ function dependenciesMet(task: Task, tasks: Map<string, Task>): boolean {
98
115
  return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
99
116
  }
100
117
 
101
- function taskCounts(tasks: BoardTask[], byId: Map<string, BoardTask>): string {
102
- const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
103
- for (const task of tasks) counts[task.status] += 1;
104
- const claimable = tasks.filter((task) => task.status === "pending" && !task.recoveryRequired && dependenciesMet(task, byId)).length;
105
- return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · claimed=${counts.claimed} · completed=${counts.completed} · superseded=${counts.superseded}`;
118
+ function taskCounts(tasks: Task[], byId: Map<string, Task>): string {
119
+ const counts: Record<TaskStatus, number> = { pending: 0, in_progress: 0, blocked: 0, completed: 0, cancelled: 0 };
120
+ for (const task of tasks) if (!isTaskReplaced(task)) counts[task.status] += 1;
121
+ const claimable = tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId)).length;
122
+ return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · in_progress=${counts.in_progress} · blocked=${counts.blocked} · completed=${counts.completed} · cancelled=${counts.cancelled} · superseded=${tasks.filter(isTaskReplaced).length}`;
106
123
  }
107
124
 
108
- function resourcesConflict(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
109
- return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
110
- }
111
-
112
- function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
125
+ function claimRejection(binding: WorkerBinding, task: Task): string | undefined {
113
126
  const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
114
127
  if (self?.assignment) {
115
128
  const state = self.assignment.closed ? "closed pending leader reopen" : "active";
116
- return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming board work.`;
129
+ return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming task work.`;
117
130
  }
118
131
  const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
119
132
  && entry.assignment && !entry.assignment.closed
@@ -124,14 +137,19 @@ function claimRejection(binding: WorkerBinding, task: BoardTask): string | undef
124
137
  return undefined;
125
138
  }
126
139
 
127
- function taskStatusLabel(task: BoardTask, tasks: Map<string, BoardTask>): string {
128
- if (task.status === "pending") return task.recoveryRequired ? "pending/recovery-required" : dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
140
+ function taskStatusLabel(task: Task, tasks: Map<string, Task>): string {
141
+ // `blocked` is a state of its own, so it is named as one. A task in it
142
+ // was attempted and the attempt did not stand: it is not claimable work, and
143
+ // calling it "pending" hid that from the participant reading this list.
144
+ if (isTaskReplaced(task)) return task.supersededBy ? `replaced-by=${task.supersededBy}` : "replacement-unresolved";
145
+ if (task.status === "blocked") return "blocked";
146
+ if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
129
147
  return task.status;
130
148
  }
131
149
 
132
- export function renderTaskBoard(tasks: BoardTask[]): string {
150
+ export function renderTaskList(tasks: Task[]): string {
133
151
  const byId = new Map(tasks.map((task) => [task.id, task]));
134
- const lines = [`BOARD · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
152
+ const lines = [`TASKS · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
135
153
  if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
136
154
  lines.push("TASKS");
137
155
  for (const task of tasks) {
@@ -212,7 +230,7 @@ function createWorkerReports(pi: ExtensionAPI) {
212
230
  throw new Error("Work authority rejected: this turn belongs to a different assignment. Wait for its fresh assignment marker.");
213
231
  }
214
232
  const cancelled = cancellation && assignmentId && self.currentTaskId
215
- && (self.assignment?.closed || loadBoardTasks(binding).some((task) => task.id === self.currentTaskId && task.status === "superseded"));
233
+ && (self.assignment?.closed || loadWorkerTasks(binding).some((task) => task.id === self.currentTaskId && isTaskReplaced(task)));
216
234
  if ((reportClosed || self.assignment?.closed) && !(cancelled && terminalOutcome !== "failed")) {
217
235
  throw new Error("Leader report rejected: this assignment already has a terminal report or is closed. Stop; no repeated acknowledgment is required.");
218
236
  }
@@ -240,7 +258,7 @@ function createWorkerReports(pi: ExtensionAPI) {
240
258
  pending = undefined;
241
259
  } else if (!current) {
242
260
  // The harness retired this attempt without a replacement marker: this is a
243
- // fresh unassigned turn (peer mail, board notice, ordinary discussion).
261
+ // fresh unassigned turn (peer mail, tasks notice, ordinary discussion).
244
262
  // Holding the previous terminal state here would dead-end the worker.
245
263
  assignmentId = undefined;
246
264
  taskId = undefined;
@@ -300,14 +318,14 @@ function createWorkerReports(pi: ExtensionAPI) {
300
318
  };
301
319
  }
302
320
 
303
- export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
321
+ export function registerChildCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
304
322
  const disclosure = createWorkerToolDisclosure(pi);
305
323
  const reports = createWorkerReports(pi);
306
324
  pi.registerTool({
307
- name: "agent_event",
325
+ name: "message",
308
326
  promptSnippet: "Send an event or message to a participant",
309
327
  label: "Agent Event",
310
- description: "Communication-only interface across Leader, Worker, and Peers. Work completion is reported automatically after settlement or uses work action=submit.",
328
+ description: "Communication-only interface across Leader, Worker, and Peers. Task completion is reported automatically after settlement or delivered with task action=complete.",
311
329
  parameters: AgentEventParams,
312
330
  renderShell: "self",
313
331
  renderCall: emptyToolCall,
@@ -325,11 +343,11 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
325
343
  if (!params.to || params.to === LEADER_RECIPIENT) {
326
344
  appendWorkerEvent(binding.outbox, {
327
345
  assignmentId: authority.assignmentId, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
328
- body: params.message, status: params.intent ?? "inform", timestamp: Date.now(),
346
+ body: params.body, status: params.kind ?? "inform", timestamp: Date.now(),
329
347
  });
330
348
  return {
331
- content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader · intent=${params.intent ?? "inform"}\nNEXT · harness will deliver this message; Work completion is automatic or uses work submit` }],
332
- details: { to: LEADER_RECIPIENT, intent: params.intent ?? "inform", outcome: "queued" },
349
+ content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader · kind=${params.kind ?? "inform"}\nNEXT · harness will deliver this message; Task completion is automatic or uses task complete` }],
350
+ details: { to: LEADER_RECIPIENT, kind: params.kind ?? "inform", outcome: "queued" },
333
351
  };
334
352
  }
335
353
  const recipient = resolveRecipient(params.to, readRoster(binding.rosterFile));
@@ -339,45 +357,49 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
339
357
  appendInboxMessage(recipientInbox, {
340
358
  id: randomUUID(),
341
359
  from: binding.worker,
342
- subject: messageTitle(params.message),
343
- body: params.message,
360
+ subject: messageTitle(params.body),
361
+ body: params.body,
344
362
  ...(recipient.spawnId ? { toSpawnId: recipient.spawnId } : {}),
345
363
  });
346
364
  return {
347
365
  content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${to}\nNEXT · harness will route the inbox message into a recipient turn` }],
348
- details: { to, outcome: "queued", intent: params.intent ?? "inform" },
366
+ details: { to, outcome: "queued", kind: params.kind ?? "inform" },
349
367
  };
350
368
  },
351
369
  });
352
370
 
353
- async function queueWorkClaim(id: string | undefined, presentation: "board" | "work") {
371
+ async function queueWorkClaim(id: string | undefined, presentation: "task" | "work") {
354
372
  const binding = workerBinding();
355
373
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
356
374
  // Claims pass the same attempt authority as sends and submissions: a closed or
357
- // terminal attempt must not queue new board work even when work is re-disclosed.
375
+ // terminal attempt must not queue new task work even when work is re-disclosed.
358
376
  reports.authorize(binding);
359
377
  const label = presentation === "work" ? "Work Item" : "Task";
360
378
  const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
361
- const race = presentation === "work" ? "All candidate Work Items were claimed in the race. Check Work again." : "All candidate tasks were claimed in the race. Check the board again.";
362
- const tasks = loadBoardTasks(binding);
379
+ const race = presentation === "work" ? "All candidate Work Items were claimed in the race. Check Work again." : "All candidate tasks were claimed in the race. Check the tasks again.";
380
+ const tasks = loadWorkerTasks(binding);
363
381
  const byId = new Map(tasks.map((task) => [task.id, task]));
364
382
  const candidates = id
365
383
  ? tasks.filter((task) => task.id === id)
366
- : tasks.filter((task) => task.status === "pending" && !task.recoveryRequired && dependenciesMet(task, byId));
384
+ : tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId));
367
385
  if (candidates.length === 0) {
368
- // Say why nothing is claimable: a board whose only pending Work is held for
369
- // Leader recovery must not read as an ordinary empty board.
370
- const held = loadBoardTasks(binding).filter((task) => task.status === "pending" && task.recoveryRequired);
386
+ // Say why nothing is claimable: a task set whose only pending Work is held for
387
+ // Leader recovery must not read as an ordinary empty tasks.
388
+ const held = loadWorkerTasks(binding).filter((task) => task.status === "blocked");
371
389
  if (!id && held.length > 0) {
372
390
  throw new Error(`No claimable ${label.toLowerCase()} right now. Held for explicit leader recovery: ${held.map((task) => task.id).join(", ")}.`);
373
391
  }
374
- throw new Error(id ? `${label} "${id}" was not found${presentation === "board" ? " on the board" : ""}.` : empty);
392
+ throw new Error(id ? `${label} "${id}" was not found${presentation === "task" ? " in the task list" : ""}.` : empty);
375
393
  }
376
394
  for (const task of candidates) {
377
- if (task.recoveryRequired) {
395
+ if (task.status === "blocked") {
378
396
  if (id) throw new Error(`Work Item "${task.id}" requires explicit leader Work assignment after failure.`);
379
397
  continue;
380
398
  }
399
+ if (isTaskReplaced(task)) {
400
+ if (id) throw new Error(task.supersededBy ? `Work Item "${task.id}" was replaced by "${task.supersededBy}".` : `Work Item "${task.id}" has an unresolved replacement target.`);
401
+ continue;
402
+ }
381
403
  if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
382
404
  const rejected = claimRejection(binding, task);
383
405
  if (rejected) {
@@ -385,12 +407,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
385
407
  continue;
386
408
  }
387
409
  const won = createTaskIntent(binding.claimsDir, task.id, {
388
- taskId: task.id, worker: binding.worker, spawnId: binding.spawnId, timestamp: Date.now(),
410
+ taskId: task.id, participant: binding.worker, spawnId: binding.spawnId, timestamp: Date.now(),
389
411
  });
390
412
  if (won) {
391
413
  const content = presentation === "work"
392
414
  ? `WORK · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness claim acceptance before starting Work`
393
- : `BOARD · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness Claim accepted feedback; do not start work until it arrives`;
415
+ : `TASKS · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness Claim accepted feedback; do not start work until it arrives`;
394
416
  const details = presentation === "work"
395
417
  ? { action: "claim", outcome: "queued", id: task.id, subject: task.subject, worker: binding.worker }
396
418
  : { taskId: task.id, subject: task.subject, worker: binding.worker };
@@ -402,7 +424,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
402
424
  }
403
425
 
404
426
  pi.registerTool({
405
- name: "work",
427
+ name: "task",
406
428
  renderShell: "self",
407
429
  renderCall: emptyToolCall,
408
430
  renderResult(result, options, theme, context) {
@@ -421,7 +443,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
421
443
  const binding = workerBinding();
422
444
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
423
445
  if (params.action === "list") {
424
- const tasks = loadBoardTasks(binding);
446
+ const tasks = loadWorkerTasks(binding);
425
447
  const byId = new Map(tasks.map((task) => [task.id, task]));
426
448
  const lines = tasks.map((task) => `- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}`);
427
449
  return Promise.resolve({ content: [{ type: "text", text: `WORK · current session\nWORK ITEMS\n${lines.join("\n") || "(none)"}` }], details: { action: "list", outcome: "listed", count: tasks.length } });
@@ -436,20 +458,20 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
436
458
  },
437
459
  });
438
460
 
439
- async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "board" | "work" }) {
461
+ async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "task" | "work" }) {
440
462
  const binding = workerBinding();
441
463
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
442
464
  const authority = reports.authorize(binding, input.status === "failed");
443
465
  if (!authority.assignmentId || authority.taskId !== input.taskId) throw new Error("This turn does not own the submitted Work Item.");
444
466
  const assignmentId = authority.assignmentId;
445
467
  const won = createTaskIntent(binding.submissionsDir, input.taskId, {
446
- taskId: input.taskId, worker: binding.worker, spawnId: binding.spawnId, assignmentId,
468
+ taskId: input.taskId, participant: binding.worker, spawnId: binding.spawnId, assignmentId,
447
469
  status: input.status, result: input.result, timestamp: Date.now(),
448
470
  });
449
471
  if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
450
472
  reports.close(input.status);
451
473
  disclosure.reset();
452
- const task = loadBoardTasks(binding).find((candidate) => candidate.id === input.taskId);
474
+ const task = loadWorkerTasks(binding).find((candidate) => candidate.id === input.taskId);
453
475
  const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
454
476
  const verify = input.status === "completed"
455
477
  ? task?.verify
@@ -461,9 +483,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
461
483
  const next = "NEXT · End this turn. The harness settles the outcome; do not repeat submission or cancellation acknowledgments.";
462
484
  const content = input.presentation === "work"
463
485
  ? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
464
- : `BOARD · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;
486
+ : `TASKS · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;
465
487
  // The subject rides along in details: a worker process never hydrates the
466
- // leader's in-memory board, so its row cannot look the task up by itself.
488
+ // leader's in-memory tasks, so its row cannot look the task up by itself.
467
489
  const details = input.presentation === "work"
468
490
  ? { action: "submit", outcome: "queued", id: input.taskId, subject: task?.subject, status: input.status === "completed" ? "success" : "failed", verify: Boolean(task?.verify || roleVerify) }
469
491
  : { taskId: input.taskId, subject: task?.subject, status: input.status, verify: Boolean(task?.verify || roleVerify) };
package/src/agents.ts DELETED
@@ -1,296 +0,0 @@
1
- /**
2
- * Declarative agent definitions: Markdown files with YAML-style frontmatter.
3
- *
4
- * Discovery scopes (later scopes override earlier ones for the same name):
5
- * 1. user — ~/.pi/agent/agents/ (respects PI_CODING_AGENT_DIR)
6
- * 2. project — <cwd>/.pi/agents/<name>.md
7
- * 3. project-local — <cwd>/.pi/agents/<name>.local.md (personal override)
8
- *
9
- * There are no built-in roles: references/agent-roles.md ships template
10
- * shapes that the leader consults when generating a new definition on demand.
11
- *
12
- * Frontmatter fields:
13
- * name — unique agent id (required)
14
- * description — routing contract: when the leader should choose this agent
15
- * tools — comma-separated list or YAML list of Pi tool ids
16
- * model — optional provider/model pin, or "inherit" for the leader's
17
- * current model at spawn time
18
- * verify — optional role-default completion gate: a review prompt a
19
- * fresh one-shot reviewer answers with VERDICT: PASS/FAIL
20
- * worktree — optional role-default Git worktree isolation (true/false)
21
- * The Markdown body is the worker's role prompt.
22
- */
23
-
24
- import * as fs from "node:fs";
25
- import * as path from "node:path";
26
- import { fileURLToPath } from "node:url";
27
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
28
-
29
- export type AgentScope = "user" | "project" | "project-local" | "session";
30
-
31
- export interface AgentDefinition {
32
- name: string;
33
- description: string;
34
- tools: string[];
35
- model?: string;
36
- /** Role-default completion gate; task-level verify overrides this. */
37
- verify?: string;
38
- /** Whether this role always receives a dedicated Git worktree. */
39
- worktree: boolean;
40
- /** The Markdown body, trimmed; used as the teammate's role prompt. */
41
- prompt: string;
42
- /** Scope that supplied this definition (precedence: project-local > project > user > bundled). */
43
- scope: AgentScope;
44
- /** True only for the shared project scope that is expected to be git-managed.
45
- * user and project-local definitions are machine/personal by definition. */
46
- gitManaged: boolean;
47
- /** Absolute path of the definition file; absent for session-only roles. */
48
- source?: string;
49
- }
50
-
51
- export interface AgentDefinitionInput {
52
- name: string;
53
- description: string;
54
- tools: string[];
55
- model?: string;
56
- verify?: string;
57
- worktree?: boolean;
58
- prompt: string;
59
- }
60
-
61
- /** Reserved model value meaning "use the leader session's current model",
62
- * resolved at spawn time (Claude-Code-style inherit). */
63
- export const MODEL_INHERIT_ALIAS = "inherit";
64
-
65
- const sessionAgents = new Map<string, AgentDefinition>();
66
- const AGENT_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
67
-
68
- function normalizeAgentInput(input: AgentDefinitionInput): AgentDefinitionInput {
69
- const name = input.name.trim();
70
- if (!AGENT_NAME_PATTERN.test(name)) {
71
- throw new Error(`Invalid agent name "${name}". Use letters, digits, dots, dashes, underscores.`);
72
- }
73
- const prompt = input.prompt.trim();
74
- if (!prompt) throw new Error(`Agent "${name}" prompt must not be empty.`);
75
- return {
76
- ...input,
77
- name,
78
- description: input.description.trim(),
79
- tools: input.tools.map((tool) => tool.trim()).filter(Boolean),
80
- model: input.model?.trim() || undefined,
81
- verify: input.verify?.trim() || undefined,
82
- worktree: input.worktree ?? false,
83
- prompt,
84
- };
85
- }
86
-
87
- /** Register a generated role for this session without writing a definition file. */
88
- export function registerSessionAgent(input: AgentDefinitionInput): AgentDefinition {
89
- const normalized = normalizeAgentInput(input);
90
- const definition: AgentDefinition = {
91
- ...normalized,
92
- worktree: normalized.worktree ?? false,
93
- scope: "session",
94
- gitManaged: false,
95
- source: undefined,
96
- };
97
- sessionAgents.set(definition.name, definition);
98
- return definition;
99
- }
100
-
101
- /** Persist a generated role only when the user explicitly requests it. */
102
- export function persistAgentDefinition(
103
- input: AgentDefinitionInput,
104
- scope: "project" | "project-local",
105
- cwd = process.cwd(),
106
- ): AgentDefinition {
107
- const normalized = normalizeAgentInput(input);
108
- const directory = agentsDir("project", cwd);
109
- fs.mkdirSync(directory, { recursive: true });
110
- const filename = `${normalized.name}${scope === "project-local" ? LOCAL_DEFINITION_SUFFIX : ".md"}`;
111
- const source = path.join(directory, filename);
112
- const frontmatter = [
113
- "---",
114
- `name: ${normalized.name}`,
115
- `description: ${normalized.description.replace(/[\\r\\n]+/g, " ")}`,
116
- `tools: ${normalized.tools.join(", ")}`,
117
- ...(normalized.model ? [`model: ${normalized.model}`] : []),
118
- ...(normalized.verify ? [`verify: ${normalized.verify.replace(/[\\r\\n]+/g, " ")}`] : []),
119
- `worktree: ${normalized.worktree ? "true" : "false"}`,
120
- "---",
121
- normalized.prompt,
122
- "",
123
- ].join("\n");
124
- fs.writeFileSync(source, frontmatter, { encoding: "utf8", mode: 0o600 });
125
- sessionAgents.delete(normalized.name);
126
- return {
127
- ...normalized,
128
- worktree: normalized.worktree ?? false,
129
- scope,
130
- gitManaged: scope === "project",
131
- source,
132
- };
133
- }
134
-
135
- /** Drop generated session roles; persistent user/project definitions remain discoverable. */
136
- export function clearSessionAgents(): void {
137
- sessionAgents.clear();
138
- }
139
-
140
- /** Shipped role templates: reference material for generating new definitions
141
- * on demand — never discovered and never spawnable directly. */
142
- export const AGENT_REFERENCE_PATH = path.resolve(
143
- path.dirname(fileURLToPath(import.meta.url)), "..", "references", "agent-roles.md",
144
- );
145
-
146
- /** Files named `xxx.local.md` in the project agents directory are personal
147
- * overrides: same teammate name as `xxx.md`, project-local scope, never git-
148
- * managed by convention. */
149
- export const LOCAL_DEFINITION_SUFFIX = ".local.md";
150
-
151
- function agentsDir(scope: "user" | "project", cwd?: string): string {
152
- if (scope === "user") {
153
- return path.join(getAgentDir(), "agents");
154
- }
155
- return path.join(cwd || process.cwd(), ".pi", "agents");
156
- }
157
-
158
- function parseFrontmatter(raw: string): { fields: Record<string, string | string[]>; body: string } {
159
- const fields: Record<string, string | string[]> = {};
160
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
161
- if (!match) return { fields, body: raw.trim() };
162
- const lines = match[1].split("\n");
163
- for (let index = 0; index < lines.length; index++) {
164
- const line = lines[index];
165
- const separator = line.indexOf(":");
166
- if (separator <= 0) continue;
167
- const key = line.slice(0, separator).trim();
168
- // Strip inline YAML comments (# ...) and surrounding whitespace/quotes.
169
- let value = line.slice(separator + 1).replace(/\s+#.*$/, "").trim();
170
- if (key === "tools") {
171
- if (value === "") {
172
- // YAML block sequence: "- item" lines, flush-left or indented,
173
- // possibly interleaved with blank or comment-only lines.
174
- const items: string[] = [];
175
- index += 1;
176
- while (index < lines.length) {
177
- const trimmed = lines[index].trim();
178
- if (trimmed === "" || trimmed.startsWith("#")) {
179
- index += 1;
180
- continue;
181
- }
182
- if (!/^-.+/.test(trimmed)) break;
183
- const rawItem = trimmed.replace(/^-\s*/, "");
184
- const item = rawItem
185
- .replace(/\s+#.*$/, "")
186
- .trim()
187
- .replace(/^["']|["']$/g, "");
188
- // "- # comment" is a comment-only entry, not a tool.
189
- if (item && !rawItem.trimStart().startsWith("#")) items.push(item);
190
- index += 1;
191
- }
192
- index -= 1; // The for-loop steps past the last consumed line.
193
- fields.tools = items;
194
- continue;
195
- }
196
- const tools = value
197
- .replace(/^\[|\]$/g, "")
198
- .split(",")
199
- .map((tool) => tool.trim().replace(/^["']|["']$/g, ""))
200
- .filter(Boolean);
201
- fields.tools = tools;
202
- continue;
203
- }
204
- fields[key] = value.replace(/^["']|["']$/g, "");
205
- }
206
- return { fields, body: match[2].trim() };
207
- }
208
-
209
- function loadDir(baseScope: AgentScope, dir: string, agents: Map<string, AgentDefinition>): void {
210
- let entries: fs.Dirent[];
211
- try {
212
- entries = fs.readdirSync(dir, { withFileTypes: true });
213
- } catch {
214
- return; // A missing agents directory is not an error.
215
- }
216
- // Dedup by name within the directory: plain .md first so a same-name
217
- // xxx.local.md (personal override) overwrites it in the map.
218
- const ordered = [...entries].sort((left, right) =>
219
- Number(left.name.endsWith(LOCAL_DEFINITION_SUFFIX)) - Number(right.name.endsWith(LOCAL_DEFINITION_SUFFIX))
220
- || left.name.localeCompare(right.name),
221
- );
222
- for (const entry of ordered) {
223
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
224
- const source = path.join(dir, entry.name);
225
- let raw: string;
226
- try {
227
- raw = fs.readFileSync(source, "utf8");
228
- } catch {
229
- continue;
230
- }
231
- const { fields, body } = parseFrontmatter(raw);
232
- const isLocal = baseScope === "project" && entry.name.endsWith(LOCAL_DEFINITION_SUFFIX);
233
- const baseName = isLocal
234
- ? entry.name.slice(0, -LOCAL_DEFINITION_SUFFIX.length)
235
- : entry.name.replace(/\.md$/, "");
236
- const name = typeof fields.name === "string" ? fields.name : baseName;
237
- if (!name || !body) continue;
238
- agents.set(name, {
239
- name,
240
- description: typeof fields.description === "string" ? fields.description : "",
241
- tools: Array.isArray(fields.tools) ? fields.tools : [],
242
- model: typeof fields.model === "string" && fields.model ? fields.model : undefined,
243
- verify: typeof fields.verify === "string" && fields.verify ? fields.verify : undefined,
244
- worktree: fields.worktree === "true",
245
- prompt: body,
246
- scope: isLocal ? "project-local" : baseScope,
247
- gitManaged: scopeIsGitManaged(isLocal ? "project-local" : baseScope),
248
- source,
249
- });
250
- }
251
- }
252
-
253
- /** Only the shared project layer is expected to be git-managed. */
254
- function scopeIsGitManaged(scope: AgentScope): boolean {
255
- return scope === "project";
256
- }
257
-
258
- /**
259
- * Resolve all agent definitions visible from the given cwd, with later scopes
260
- * overriding earlier ones per name: project-local > project > user.
261
- */
262
- export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
263
- const agents = new Map<string, AgentDefinition>();
264
- loadDir("user", agentsDir("user", cwd), agents);
265
- loadDir("project", agentsDir("project", cwd), agents);
266
- // Generated roles fill gaps only: filesystem scopes keep precedence over
267
- // session memory.
268
- for (const [name, definition] of sessionAgents) if (!agents.has(name)) agents.set(name, definition);
269
- return agents;
270
- }
271
-
272
- /** Resolve one agent by name, or undefined when no scope defines it. */
273
- export function resolveAgent(name: string, cwd?: string): AgentDefinition | undefined {
274
- return discoverAgents(cwd).get(name);
275
- }
276
-
277
- /**
278
- * Format discovered agents as Markdown for prompt injection in before_agent_start.
279
- */
280
- export function formatAgentGuidance(cwd?: string): string {
281
- const agents = discoverAgents(cwd);
282
- if (agents.size === 0) return "(none defined; pass an inline definition when delegating)";
283
- const lines: string[] = [];
284
- for (const agent of agents.values()) {
285
- const extras = [
286
- agent.model ? `Model: ${agent.model}` : "",
287
- agent.verify ? `Verify: ${agent.verify}` : "",
288
- agent.worktree ? "Worktree: true" : "",
289
- agent.scope === "session" ? "in-memory" : agent.gitManaged ? "git-managed" : "local",
290
- ].filter(Boolean);
291
- lines.push(`- **${agent.name}** (${agent.scope})`);
292
- if (agent.description) lines.push(` ${agent.description}`);
293
- lines.push(` Tools: ${agent.tools.join(", ") || "(role defaults)"}${extras.length > 0 ? ` | ${extras.join(" | ")}` : ""}`);
294
- }
295
- return lines.join("\n");
296
- }