@fradser/pi-agent-teams 0.9.2 → 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) {
@@ -44,8 +52,12 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
44
52
  ? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
45
53
  : undefined;
46
54
  const assignment = rosterEntry?.assignment;
47
- if (assignment && !assignment.closed) state = "claimed";
48
- else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
55
+ // A superseded holder still owns cancellation authority and must be
56
+ // able to acknowledge it; hiding work here strands its resource lease.
57
+ if (binding && rosterEntry && rosterEntry.spawnId === binding.spawnId
58
+ && rosterEntry.status !== "stopped" && assignment) {
59
+ state = "held";
60
+ } else if (!assignment && prompt?.includes("=== TASK NOTICE ===")) state = "notice";
49
61
  else state = "none";
50
62
  apply();
51
63
  },
@@ -62,7 +74,7 @@ export interface WorkerBinding {
62
74
  outbox: string;
63
75
  inbox: string;
64
76
  rosterFile: string;
65
- boardFile: string;
77
+ tasksFile: string;
66
78
  claimsDir: string;
67
79
  submissionsDir: string;
68
80
  }
@@ -73,7 +85,7 @@ const REQUIRED_ENV = [
73
85
  "PI_TEAMMATE_OUTBOX_FILE",
74
86
  "PI_TEAMMATE_INBOX_FILE",
75
87
  "PI_TEAMMATE_ROSTER_FILE",
76
- "PI_TEAMMATE_BOARD_FILE",
88
+ "PI_TEAMMATE_TASKS_FILE",
77
89
  "PI_TEAMMATE_CLAIMS_DIR",
78
90
  "PI_TEAMMATE_SUBMISSIONS_DIR",
79
91
  ] as const;
@@ -81,35 +93,40 @@ const REQUIRED_ENV = [
81
93
  export function workerBinding(): WorkerBinding | undefined {
82
94
  const values = REQUIRED_ENV.map((name) => process.env[name]);
83
95
  if (values.some((value) => !value)) return undefined;
84
- const [worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir] = values as string[];
85
- 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 };
86
98
  }
87
99
 
88
- function loadBoardTasks(binding: WorkerBinding): BoardTask[] {
89
- const board = readBoardFile(binding.boardFile);
90
- 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));
91
112
  }
92
113
 
93
- function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolean {
114
+ function dependenciesMet(task: Task, tasks: Map<string, Task>): boolean {
94
115
  return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
95
116
  }
96
117
 
97
- function taskCounts(tasks: BoardTask[], byId: Map<string, BoardTask>): string {
98
- const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
99
- for (const task of tasks) counts[task.status] += 1;
100
- const claimable = tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId)).length;
101
- 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}`;
102
123
  }
103
124
 
104
- function resourcesConflict(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
105
- return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
106
- }
107
-
108
- function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
125
+ function claimRejection(binding: WorkerBinding, task: Task): string | undefined {
109
126
  const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
110
127
  if (self?.assignment) {
111
128
  const state = self.assignment.closed ? "closed pending leader reopen" : "active";
112
- 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.`;
113
130
  }
114
131
  const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
115
132
  && entry.assignment && !entry.assignment.closed
@@ -120,14 +137,19 @@ function claimRejection(binding: WorkerBinding, task: BoardTask): string | undef
120
137
  return undefined;
121
138
  }
122
139
 
123
- function taskStatusLabel(task: BoardTask, tasks: Map<string, BoardTask>): string {
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";
124
146
  if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
125
147
  return task.status;
126
148
  }
127
149
 
128
- export function renderTaskBoard(tasks: BoardTask[]): string {
150
+ export function renderTaskList(tasks: Task[]): string {
129
151
  const byId = new Map(tasks.map((task) => [task.id, task]));
130
- const lines = [`BOARD · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
152
+ const lines = [`TASKS · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
131
153
  if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
132
154
  lines.push("TASKS");
133
155
  for (const task of tasks) {
@@ -176,30 +198,43 @@ function automaticResultBody(binding: WorkerBinding, assignmentId: string, body:
176
198
 
177
199
  function createWorkerReports(pi: ExtensionAPI) {
178
200
  const initial = workerBinding();
179
- const initialAssignment = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker)?.assignment : undefined;
201
+ const initialSelf = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker && entry.spawnId === initial.spawnId) : undefined;
202
+ const initialAssignment = initialSelf?.assignment;
180
203
  let assignmentId = initialAssignment?.id;
204
+ let taskId = initialSelf?.currentTaskId;
181
205
  let reportClosed = initialAssignment?.closed === true;
206
+ let terminalOutcome: "completed" | "failed" | undefined;
182
207
  let pending: { binding: WorkerBinding; assignmentId: string; timestamp: number; awaitingFinal?: boolean; outcome?: AutomaticResult } | undefined;
183
208
  const finalizedMessages = new WeakSet<AssistantResponse>();
184
209
  const currentAssignment = (binding: WorkerBinding) => {
185
- const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
210
+ const self = readRoster(binding.rosterFile)
211
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
186
212
  const assignment = self?.assignment;
187
213
  return !reportClosed && self?.spawnId === binding.spawnId && self.status !== "stopped"
188
214
  && assignment && assignment.id === assignmentId && !assignment.closed ? assignment : undefined;
189
215
  };
190
- const close = () => {
216
+ const close = (outcome: "completed" | "failed") => {
191
217
  reportClosed = true;
218
+ terminalOutcome = outcome;
192
219
  pending = undefined;
193
220
  };
194
- const reportAssignment = (binding: WorkerBinding) => {
195
- const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
196
- if (current?.kind === "board" && current.id !== assignmentId) {
197
- assignmentId = current.id;
198
- reportClosed = false;
199
- pending = undefined;
221
+ const authorize = (binding: WorkerBinding, cancellation = false) => {
222
+ const sameBinding = initial && binding.worker === initial.worker && binding.spawnId === initial.spawnId
223
+ && binding.outbox === initial.outbox && binding.rosterFile === initial.rosterFile;
224
+ const self = readRoster(binding.rosterFile)
225
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
226
+ if (!sameBinding || !self || self.status === "stopped") {
227
+ throw new Error("Work authority rejected: this is not the bound living worker incarnation.");
228
+ }
229
+ if (self.assignment?.id !== assignmentId || (taskId !== undefined && self.currentTaskId !== taskId)) {
230
+ throw new Error("Work authority rejected: this turn belongs to a different assignment. Wait for its fresh assignment marker.");
200
231
  }
201
- if (reportClosed) throw new Error("Leader report rejected: this assignment already has a terminal report. Wait for an explicit leader assignment.");
202
- return assignmentId;
232
+ const cancelled = cancellation && assignmentId && self.currentTaskId
233
+ && (self.assignment?.closed || loadWorkerTasks(binding).some((task) => task.id === self.currentTaskId && isTaskReplaced(task)));
234
+ if ((reportClosed || self.assignment?.closed) && !(cancelled && terminalOutcome !== "failed")) {
235
+ throw new Error("Leader report rejected: this assignment already has a terminal report or is closed. Stop; no repeated acknowledgment is required.");
236
+ }
237
+ return { assignmentId, taskId: taskId ?? self.currentTaskId };
203
238
  };
204
239
  pi.on("message_start", ({ message }) => {
205
240
  const binding = workerBinding();
@@ -208,12 +243,27 @@ function createWorkerReports(pi: ExtensionAPI) {
208
243
  const content = message.content;
209
244
  const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
210
245
  const marker = /^\[agent-teams-assignment:([^\n]+)\]\n/.exec(text);
211
- const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
246
+ const self = readRoster(binding.rosterFile)
247
+ .find((entry) => entry.name === binding.worker && entry.spawnId === binding.spawnId);
248
+ const current = self?.assignment;
212
249
  if (marker) {
213
250
  const id = marker[1] === "none" ? undefined : marker[1];
214
251
  if (id !== current?.id) return;
215
- if (id !== assignmentId || current?.kind === "board") reportClosed = current?.closed === true;
252
+ if (id !== assignmentId) {
253
+ reportClosed = current?.closed === true;
254
+ terminalOutcome = undefined;
255
+ }
216
256
  assignmentId = id;
257
+ taskId = self?.currentTaskId;
258
+ pending = undefined;
259
+ } else if (!current) {
260
+ // The harness retired this attempt without a replacement marker: this is a
261
+ // fresh unassigned turn (peer mail, tasks notice, ordinary discussion).
262
+ // Holding the previous terminal state here would dead-end the worker.
263
+ assignmentId = undefined;
264
+ taskId = undefined;
265
+ reportClosed = false;
266
+ terminalOutcome = undefined;
217
267
  pending = undefined;
218
268
  }
219
269
  const assignment = currentAssignment(binding);
@@ -249,31 +299,33 @@ function createWorkerReports(pi: ExtensionAPI) {
249
299
  assignmentId: pending.assignmentId, timestamp: Date.now(), status: result.status,
250
300
  body: automaticResultBody(binding, pending.assignmentId, result.body),
251
301
  });
252
- close();
302
+ close(result.status);
253
303
  });
254
304
  return {
255
305
  close,
306
+ authorize,
256
307
  send(binding: WorkerBinding, body: string, status: import("./types.ts").WorkerReportEvent["status"]) {
308
+ const authority = authorize(binding, status === "failed");
257
309
  appendWorkerEvent(binding.outbox, {
258
- assignmentId: reportAssignment(binding), id: randomUUID(), type: "message",
310
+ assignmentId: authority.assignmentId, id: randomUUID(), type: "message",
259
311
  worker: binding.worker, spawnId: binding.spawnId, body, status, timestamp: Date.now(),
260
312
  });
261
313
  const isTerminal = status === "completed" || status === "failed";
262
- if (isTerminal) close();
314
+ if (isTerminal) close(status);
263
315
  return isTerminal;
264
316
  },
265
317
  reset() { pending = undefined; },
266
318
  };
267
319
  }
268
320
 
269
- export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
321
+ export function registerChildCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
270
322
  const disclosure = createWorkerToolDisclosure(pi);
271
323
  const reports = createWorkerReports(pi);
272
324
  pi.registerTool({
273
- name: "agent_event",
325
+ name: "message",
274
326
  promptSnippet: "Send an event or message to a participant",
275
327
  label: "Agent Event",
276
- 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.",
277
329
  parameters: AgentEventParams,
278
330
  renderShell: "self",
279
331
  renderCall: emptyToolCall,
@@ -287,14 +339,15 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
287
339
  async execute(_toolCallId, params) {
288
340
  const binding = workerBinding();
289
341
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
342
+ const authority = reports.authorize(binding);
290
343
  if (!params.to || params.to === LEADER_RECIPIENT) {
291
344
  appendWorkerEvent(binding.outbox, {
292
- assignmentId: readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
293
- body: params.message, status: params.intent ?? "inform", timestamp: Date.now(),
345
+ assignmentId: authority.assignmentId, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
346
+ body: params.body, status: params.kind ?? "inform", timestamp: Date.now(),
294
347
  });
295
348
  return {
296
- 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` }],
297
- 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" },
298
351
  };
299
352
  }
300
353
  const recipient = resolveRecipient(params.to, readRoster(binding.rosterFile));
@@ -304,32 +357,49 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
304
357
  appendInboxMessage(recipientInbox, {
305
358
  id: randomUUID(),
306
359
  from: binding.worker,
307
- subject: messageTitle(params.message),
308
- body: params.message,
360
+ subject: messageTitle(params.body),
361
+ body: params.body,
309
362
  ...(recipient.spawnId ? { toSpawnId: recipient.spawnId } : {}),
310
363
  });
311
364
  return {
312
365
  content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${to}\nNEXT · harness will route the inbox message into a recipient turn` }],
313
- details: { to, outcome: "queued", intent: params.intent ?? "inform" },
366
+ details: { to, outcome: "queued", kind: params.kind ?? "inform" },
314
367
  };
315
368
  },
316
369
  });
317
370
 
318
- async function queueWorkClaim(id: string | undefined, presentation: "board" | "work") {
371
+ async function queueWorkClaim(id: string | undefined, presentation: "task" | "work") {
319
372
  const binding = workerBinding();
320
373
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
374
+ // Claims pass the same attempt authority as sends and submissions: a closed or
375
+ // terminal attempt must not queue new task work even when work is re-disclosed.
376
+ reports.authorize(binding);
321
377
  const label = presentation === "work" ? "Work Item" : "Task";
322
378
  const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
323
- 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.";
324
- 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);
325
381
  const byId = new Map(tasks.map((task) => [task.id, task]));
326
382
  const candidates = id
327
383
  ? tasks.filter((task) => task.id === id)
328
- : tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
384
+ : tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId));
329
385
  if (candidates.length === 0) {
330
- throw new Error(id ? `${label} "${id}" was not found${presentation === "board" ? " on the board" : ""}.` : empty);
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");
389
+ if (!id && held.length > 0) {
390
+ throw new Error(`No claimable ${label.toLowerCase()} right now. Held for explicit leader recovery: ${held.map((task) => task.id).join(", ")}.`);
391
+ }
392
+ throw new Error(id ? `${label} "${id}" was not found${presentation === "task" ? " in the task list" : ""}.` : empty);
331
393
  }
332
394
  for (const task of candidates) {
395
+ if (task.status === "blocked") {
396
+ if (id) throw new Error(`Work Item "${task.id}" requires explicit leader Work assignment after failure.`);
397
+ continue;
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
+ }
333
403
  if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
334
404
  const rejected = claimRejection(binding, task);
335
405
  if (rejected) {
@@ -337,12 +407,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
337
407
  continue;
338
408
  }
339
409
  const won = createTaskIntent(binding.claimsDir, task.id, {
340
- 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(),
341
411
  });
342
412
  if (won) {
343
413
  const content = presentation === "work"
344
414
  ? `WORK · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness claim acceptance before starting Work`
345
- : `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`;
346
416
  const details = presentation === "work"
347
417
  ? { action: "claim", outcome: "queued", id: task.id, subject: task.subject, worker: binding.worker }
348
418
  : { taskId: task.id, subject: task.subject, worker: binding.worker };
@@ -354,7 +424,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
354
424
  }
355
425
 
356
426
  pi.registerTool({
357
- name: "work",
427
+ name: "task",
358
428
  renderShell: "self",
359
429
  renderCall: emptyToolCall,
360
430
  renderResult(result, options, theme, context) {
@@ -373,8 +443,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
373
443
  const binding = workerBinding();
374
444
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
375
445
  if (params.action === "list") {
376
- const tasks = loadBoardTasks(binding);
377
- const lines = tasks.map((task) => `- ${task.id} · ${task.status} · ${task.subject}`);
446
+ const tasks = loadWorkerTasks(binding);
447
+ const byId = new Map(tasks.map((task) => [task.id, task]));
448
+ const lines = tasks.map((task) => `- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}`);
378
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 } });
379
450
  }
380
451
  if (params.action === "claim") return queueWorkClaim(params.id, "work");
@@ -387,18 +458,20 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
387
458
  },
388
459
  });
389
460
 
390
- 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" }) {
391
462
  const binding = workerBinding();
392
463
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
393
- const assignmentId = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id;
464
+ const authority = reports.authorize(binding, input.status === "failed");
465
+ if (!authority.assignmentId || authority.taskId !== input.taskId) throw new Error("This turn does not own the submitted Work Item.");
466
+ const assignmentId = authority.assignmentId;
394
467
  const won = createTaskIntent(binding.submissionsDir, input.taskId, {
395
- taskId: input.taskId, worker: binding.worker, spawnId: binding.spawnId, assignmentId,
468
+ taskId: input.taskId, participant: binding.worker, spawnId: binding.spawnId, assignmentId,
396
469
  status: input.status, result: input.result, timestamp: Date.now(),
397
470
  });
398
471
  if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
399
- reports.close();
472
+ reports.close(input.status);
400
473
  disclosure.reset();
401
- const task = loadBoardTasks(binding).find((candidate) => candidate.id === input.taskId);
474
+ const task = loadWorkerTasks(binding).find((candidate) => candidate.id === input.taskId);
402
475
  const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
403
476
  const verify = input.status === "completed"
404
477
  ? task?.verify
@@ -407,12 +480,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
407
480
  ? "VERIFY · queued (role gate)"
408
481
  : "VERIFY · none configured"
409
482
  : "VERIFY · skipped (failed submission)";
410
- const next = input.status === "completed" ? "NEXT · wait for the harness result" : input.presentation === "work" ? "NEXT · Work returns to pending" : "NEXT · task returns to pending";
483
+ const next = "NEXT · End this turn. The harness settles the outcome; do not repeat submission or cancellation acknowledgments.";
411
484
  const content = input.presentation === "work"
412
485
  ? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
413
- : `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}`;
414
487
  // The subject rides along in details: a worker process never hydrates the
415
- // 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.
416
489
  const details = input.presentation === "work"
417
490
  ? { action: "submit", outcome: "queued", id: input.taskId, subject: task?.subject, status: input.status === "completed" ? "success" : "failed", verify: Boolean(task?.verify || roleVerify) }
418
491
  : { taskId: input.taskId, subject: task?.subject, status: input.status, verify: Boolean(task?.verify || roleVerify) };