@mclawnet/mcp-server 0.1.3 → 0.1.4

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.
@@ -0,0 +1,823 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createLogger } from "@mclawnet/logger";
5
+ import { TaskStore, classifyBlockers, computeLeadBriefing, computeMemberBriefing, computeTaskBriefing, formatLeadBriefing, formatMemberBriefing, formatTaskBriefing, projectRoot, } from "@mclawnet/task";
6
+ import { InboxStore } from "@mclawnet/swarm";
7
+ const log = createLogger({ module: "mcp-server/tools/task" });
8
+ export const TASK_TOOL_NAMES = new Set([
9
+ "task_create",
10
+ "task_create_from_message",
11
+ "task_get",
12
+ "task_list",
13
+ "task_set_status",
14
+ "task_set_owner",
15
+ "task_add_comment",
16
+ "task_link",
17
+ "task_unlink",
18
+ "task_briefing",
19
+ "lead_briefing",
20
+ "member_briefing",
21
+ ]);
22
+ const VALID_STATUSES = new Set(["pending", "in_progress", "completed", "cancelled"]);
23
+ function parseBriefingFormat(args) {
24
+ const raw = args.format;
25
+ if (raw === undefined || raw === null)
26
+ return "json";
27
+ if (raw !== "json" && raw !== "markdown") {
28
+ throw new Error(`invalid format: ${String(raw)} (expected "json" | "markdown")`);
29
+ }
30
+ return raw;
31
+ }
32
+ export function getTaskToolDefinitions() {
33
+ return [
34
+ {
35
+ name: "task_create",
36
+ description: "Create a new task in the project task store. Returns the created task JSON. Restricted to queen instances: caller must pass `from` starting with 'queen-'.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
41
+ teamName: { type: "string", description: "Team name owning this task" },
42
+ subject: { type: "string", description: "Short task subject/title" },
43
+ description: { type: "string", description: "Optional longer description" },
44
+ swarmId: { type: "string", description: "Swarm id (optional)" },
45
+ owner: { type: "string", description: "Owner agent instance id (optional)" },
46
+ from: { type: "string", description: "Caller instance id; must start with 'queen-'" },
47
+ blockedBy: {
48
+ type: "array",
49
+ items: { type: "string" },
50
+ description: "Task ids this task is blocked by (optional)",
51
+ },
52
+ },
53
+ required: ["workDir", "teamName", "subject", "from"],
54
+ },
55
+ },
56
+ {
57
+ name: "task_create_from_message",
58
+ description: "Create a new task derived from an inbox message. Adds a system 'origin_message' comment recording the source message id and instance. Restricted to queen instances: caller must pass `from` starting with 'queen-'.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
63
+ teamName: { type: "string", description: "Team name owning this task" },
64
+ subject: { type: "string", description: "Short task subject/title" },
65
+ description: { type: "string", description: "Optional longer description" },
66
+ swarmId: { type: "string", description: "Swarm id (optional)" },
67
+ owner: { type: "string", description: "Owner agent instance id (optional)" },
68
+ from: { type: "string", description: "Caller instance id; must start with 'queen-'" },
69
+ blockedBy: {
70
+ type: "array",
71
+ items: { type: "string" },
72
+ description: "Task ids this task is blocked by (optional)",
73
+ },
74
+ sourceMessageId: { type: "string", description: "Inbox message id this task is spun off from" },
75
+ sourceInstanceId: { type: "string", description: "Instance id that owned the source message" },
76
+ },
77
+ required: ["workDir", "teamName", "subject", "sourceMessageId", "sourceInstanceId", "from"],
78
+ },
79
+ },
80
+ {
81
+ name: "task_get",
82
+ description: "Fetch a task by id from the project task store.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
87
+ taskId: { type: "string", description: "Task id" },
88
+ },
89
+ required: ["workDir", "taskId"],
90
+ },
91
+ },
92
+ {
93
+ name: "task_list",
94
+ description: "List tasks from the project task store. Optional filters: swarmId, status, owner.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
99
+ swarmId: { type: "string", description: "Filter by swarm id (optional)" },
100
+ status: { type: "string", description: "Filter by status (optional)" },
101
+ owner: { type: "string", description: "Filter by owner agent instance id (optional)" },
102
+ },
103
+ required: ["workDir"],
104
+ },
105
+ },
106
+ {
107
+ name: "task_set_status",
108
+ description: "Set the status of a task. When status becomes 'cancelled', the task is unlinked from any tasks it was blocking.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
113
+ taskId: { type: "string", description: "Task id" },
114
+ status: {
115
+ type: "string",
116
+ enum: ["pending", "in_progress", "completed", "cancelled"],
117
+ description: "New status",
118
+ },
119
+ },
120
+ required: ["workDir", "taskId", "status"],
121
+ },
122
+ },
123
+ {
124
+ name: "task_set_owner",
125
+ description: "Set (or clear, by passing null) the owner of a task. Restricted to queen instances: caller must pass `from` starting with 'queen-'.",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
130
+ taskId: { type: "string", description: "Task id" },
131
+ owner: {
132
+ type: ["string", "null"],
133
+ description: "New owner agent instance id, or null to clear",
134
+ },
135
+ from: { type: "string", description: "Caller instance id; must start with 'queen-'" },
136
+ },
137
+ required: ["workDir", "taskId", "owner", "from"],
138
+ },
139
+ },
140
+ {
141
+ name: "task_add_comment",
142
+ description: "Append a comment to a task. Idempotent on the comment id; if id is omitted a UUID is generated.",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {
146
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
147
+ taskId: { type: "string", description: "Task id" },
148
+ author: { type: "string", description: "Author identifier (agent or user)" },
149
+ body: { type: "string", description: "Comment body" },
150
+ type: { type: "string", description: "Optional comment type (default: 'note')" },
151
+ id: { type: "string", description: "Optional comment id (auto-generated UUID if omitted)" },
152
+ },
153
+ required: ["workDir", "taskId", "author", "body"],
154
+ },
155
+ },
156
+ {
157
+ name: "task_link",
158
+ description: "Add a dependency edge: fromTask is blocked by toTask, and toTask blocks fromTask. Rejects self-link, cycles, and missing tasks.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
163
+ fromTaskId: { type: "string", description: "Task that becomes blocked" },
164
+ toTaskId: { type: "string", description: "Task it depends on" },
165
+ },
166
+ required: ["workDir", "fromTaskId", "toTaskId"],
167
+ },
168
+ },
169
+ {
170
+ name: "task_unlink",
171
+ description: "Remove a dependency edge between fromTask and toTask in both directions. No-op if the edge does not exist.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
176
+ fromTaskId: { type: "string", description: "Task that was blocked" },
177
+ toTaskId: { type: "string", description: "Task it depended on" },
178
+ },
179
+ required: ["workDir", "fromTaskId", "toTaskId"],
180
+ },
181
+ },
182
+ {
183
+ name: "task_briefing",
184
+ description: "Summarise an instance's task situation within a swarm: actionable (owned, unblocked, pending/in_progress), awareness (owned, with waiting deps), counters and warnings (broken deps, orphaned in_progress).",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
189
+ swarmId: { type: "string", description: "Swarm id to scope the briefing" },
190
+ instanceId: { type: "string", description: "Instance id to focus the briefing on" },
191
+ format: {
192
+ type: "string",
193
+ enum: ["json", "markdown"],
194
+ description: "Response format. Default 'json'.",
195
+ default: "json",
196
+ },
197
+ },
198
+ required: ["workDir", "swarmId", "instanceId"],
199
+ },
200
+ },
201
+ {
202
+ name: "lead_briefing",
203
+ description: "Queen-facing situation summary for a swarm. Returns five buckets: assign_owner (pending && owner=null), assign_reviewer (always empty in Phase 1 — no reviewer field yet), clarify (tasks whose latest comment body contains '?'), repair_dependencies (tasks with broken/cancelled deps), lead_owned (tasks owned by the queen instance resolved from recovery.json).",
204
+ inputSchema: {
205
+ type: "object",
206
+ properties: {
207
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
208
+ swarmId: { type: "string", description: "Swarm id to scope the briefing" },
209
+ format: {
210
+ type: "string",
211
+ enum: ["json", "markdown"],
212
+ description: "Response format. Default 'json'.",
213
+ default: "json",
214
+ },
215
+ },
216
+ required: ["workDir", "swarmId"],
217
+ },
218
+ },
219
+ {
220
+ name: "member_briefing",
221
+ description: "Worker/queen boot briefing. Returns a hardcoded list of 4-6 boot rules (slightly different for queen vs. worker) plus a bootstrap object echoing { swarmId, instanceId, roleType }.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ workDir: { type: "string", description: "Absolute path of the project working directory" },
226
+ swarmId: { type: "string", description: "Swarm id" },
227
+ instanceId: { type: "string", description: "Instance id of the booting agent" },
228
+ roleType: { type: "string", description: "Role type, e.g. 'queen' or 'worker'" },
229
+ format: {
230
+ type: "string",
231
+ enum: ["json", "markdown"],
232
+ description: "Response format. Default 'json'.",
233
+ default: "json",
234
+ },
235
+ },
236
+ required: ["workDir", "swarmId", "instanceId", "roleType"],
237
+ },
238
+ },
239
+ ];
240
+ }
241
+ export async function handleTaskToolCall(name, args, context) {
242
+ try {
243
+ log.info({ tool: name }, "MCP task tool called");
244
+ switch (name) {
245
+ case "task_create":
246
+ return await handleTaskCreate(args, context);
247
+ case "task_create_from_message":
248
+ return await handleTaskCreateFromMessage(args, context);
249
+ case "task_get":
250
+ return await handleTaskGet(args, context);
251
+ case "task_list":
252
+ return await handleTaskList(args, context);
253
+ case "task_set_status":
254
+ return await handleTaskSetStatus(args, context);
255
+ case "task_set_owner":
256
+ return await handleTaskSetOwner(args, context);
257
+ case "task_add_comment":
258
+ return await handleTaskAddComment(args, context);
259
+ case "task_link":
260
+ return await handleTaskLink(args, context);
261
+ case "task_unlink":
262
+ return await handleTaskUnlink(args, context);
263
+ case "task_briefing":
264
+ return await handleTaskBriefing(args, context);
265
+ case "lead_briefing":
266
+ return await handleLeadBriefing(args, context);
267
+ case "member_briefing":
268
+ return await handleMemberBriefing(args, context);
269
+ default:
270
+ return {
271
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
272
+ isError: true,
273
+ };
274
+ }
275
+ }
276
+ catch (err) {
277
+ const message = err instanceof Error ? err.message : String(err);
278
+ log.error({ tool: name, err }, "MCP task tool error");
279
+ return {
280
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
281
+ isError: true,
282
+ };
283
+ }
284
+ }
285
+ /**
286
+ * Defense-in-depth ("防君子"): task mutation tools that should only be invoked
287
+ * by a queen/lead instance call this guard first. The harness already blocks
288
+ * non-queen workers via `--disallowedTools`, but this is a second layer in
289
+ * case a worker spawns its own MCP client or the policy is misconfigured.
290
+ *
291
+ * Caller identity is taken from `args.from` (free-form string the caller
292
+ * supplies, same convention as inbox_message_send). A queen instance id
293
+ * always starts with the literal prefix "queen-" (see role instanceId minting
294
+ * in @mclawnet/swarm). Empty / non-string / non-queen values are rejected.
295
+ */
296
+ function assertQueenCaller(args, toolName) {
297
+ const from = args.from;
298
+ if (typeof from !== "string" || !from.startsWith("queen-")) {
299
+ throw new Error(`Only queen instances may call ${toolName}`);
300
+ }
301
+ }
302
+ function getStore(args, context) {
303
+ const workDir = args.workDir;
304
+ if (!workDir)
305
+ throw new Error("workDir is required");
306
+ return new TaskStore({ workDir, home: context.rootDir });
307
+ }
308
+ async function handleTaskCreate(args, context) {
309
+ assertQueenCaller(args, "task_create");
310
+ const teamName = args.teamName;
311
+ const subject = args.subject;
312
+ if (!teamName)
313
+ throw new Error("teamName is required");
314
+ if (!subject)
315
+ throw new Error("subject is required");
316
+ const store = getStore(args, context);
317
+ const task = await store.create({
318
+ teamName,
319
+ subject,
320
+ description: args.description,
321
+ swarmId: args.swarmId ?? null,
322
+ owner: args.owner ?? null,
323
+ blockedBy: args.blockedBy ?? [],
324
+ });
325
+ if (task.swarmId && task.owner) {
326
+ const msg = {
327
+ id: `assigned-${task.id}`,
328
+ from: "system",
329
+ type: "task_assigned",
330
+ data: JSON.stringify({
331
+ taskId: task.id,
332
+ subject: task.subject,
333
+ description: task.description,
334
+ }),
335
+ taskId: task.id,
336
+ timestamp: Date.now(),
337
+ delivered: false,
338
+ };
339
+ await appendInboxBestEffort(args.workDir, task.swarmId, task.owner, msg, context);
340
+ }
341
+ return {
342
+ content: [{ type: "text", text: JSON.stringify(task) }],
343
+ };
344
+ }
345
+ async function handleTaskCreateFromMessage(args, context) {
346
+ assertQueenCaller(args, "task_create_from_message");
347
+ const teamName = args.teamName;
348
+ const subject = args.subject;
349
+ const sourceMessageId = args.sourceMessageId;
350
+ const sourceInstanceId = args.sourceInstanceId;
351
+ if (!teamName)
352
+ throw new Error("teamName is required");
353
+ if (!subject)
354
+ throw new Error("subject is required");
355
+ if (!sourceMessageId)
356
+ throw new Error("sourceMessageId is required");
357
+ if (!sourceInstanceId)
358
+ throw new Error("sourceInstanceId is required");
359
+ const store = getStore(args, context);
360
+ const task = await store.create({
361
+ teamName,
362
+ subject,
363
+ description: args.description,
364
+ swarmId: args.swarmId ?? null,
365
+ owner: args.owner ?? null,
366
+ blockedBy: args.blockedBy ?? [],
367
+ });
368
+ await store.addComment(task.id, {
369
+ id: randomUUID(),
370
+ author: "system",
371
+ type: "origin_message",
372
+ body: `Spun off from message ${sourceMessageId} (instance ${sourceInstanceId})`,
373
+ });
374
+ const fresh = store.get(task.id);
375
+ return {
376
+ content: [{ type: "text", text: JSON.stringify(fresh) }],
377
+ };
378
+ }
379
+ async function handleTaskGet(args, context) {
380
+ const taskId = args.taskId;
381
+ if (!taskId)
382
+ throw new Error("taskId is required");
383
+ const store = getStore(args, context);
384
+ const task = store.get(taskId);
385
+ return {
386
+ content: [{ type: "text", text: JSON.stringify(task) }],
387
+ };
388
+ }
389
+ async function handleTaskList(args, context) {
390
+ const store = getStore(args, context);
391
+ const swarmId = args.swarmId;
392
+ const status = args.status;
393
+ const owner = args.owner;
394
+ // store.list() returns lightweight IndexEntry[]; load full Task objects so callers
395
+ // get the same shape as task_get.
396
+ const entries = store.list();
397
+ const tasks = entries
398
+ .map(e => store.tryGet(e.id))
399
+ .filter((t) => t !== null)
400
+ .filter(t => (swarmId === undefined ? true : t.swarmId === swarmId))
401
+ .filter(t => (status === undefined ? true : t.status === status))
402
+ .filter(t => (owner === undefined ? true : t.owner === owner));
403
+ return {
404
+ content: [{ type: "text", text: JSON.stringify({ tasks }) }],
405
+ };
406
+ }
407
+ async function handleTaskSetStatus(args, context) {
408
+ const taskId = args.taskId;
409
+ const status = args.status;
410
+ if (!taskId)
411
+ throw new Error("taskId is required");
412
+ if (!status)
413
+ throw new Error("status is required");
414
+ if (!VALID_STATUSES.has(status)) {
415
+ throw new Error(`invalid status: ${status}`);
416
+ }
417
+ const store = getStore(args, context);
418
+ // Will throw "Task not found" if missing — surfaced as isError by the wrapper.
419
+ const current = store.get(taskId);
420
+ // Dependency contract: refuse to transition *into* in_progress when any
421
+ // blockedBy dep is not completed. Briefings already filter blocked tasks
422
+ // out of `actionable`, but workers/queens can call this tool directly, so
423
+ // the server must enforce the contract too. Same-state writes (already
424
+ // in_progress) are not transitions and remain allowed for idempotency.
425
+ if (status === "in_progress" && current.status !== "in_progress") {
426
+ const cls = classifyBlockers(current, store);
427
+ if (cls.isBlocked) {
428
+ throw new Error(`task is blocked by unresolved dependencies — waiting=[${cls.waiting.join(", ")}] broken=[${cls.broken.join(", ")}]`);
429
+ }
430
+ }
431
+ const updated = await store.update(taskId, { status: status });
432
+ if (status === "cancelled") {
433
+ // Per design §5.2: a cancelled task should no longer block its dependents.
434
+ // We unlink each (self -> dep) edge using the existing store API so both
435
+ // sides of the edge stay consistent (self.blockedBy and dep.blocks).
436
+ for (const depId of [...current.blockedBy]) {
437
+ await store.unlink(taskId, depId);
438
+ }
439
+ }
440
+ if (current.swarmId) {
441
+ const queenId = resolveQueenInstanceId(args.workDir, current.swarmId, context.inbox?.rootDir ?? context.rootDir);
442
+ if (queenId && !(queenId === current.owner && status === "in_progress")) {
443
+ const msg = {
444
+ id: `status-${taskId}-${status}`,
445
+ from: "system",
446
+ type: "task_status_changed",
447
+ data: JSON.stringify({
448
+ taskId,
449
+ status,
450
+ owner: current.owner,
451
+ subject: current.subject,
452
+ }),
453
+ taskId,
454
+ timestamp: Date.now(),
455
+ delivered: false,
456
+ };
457
+ await appendInboxBestEffort(args.workDir, current.swarmId, queenId, msg, context);
458
+ }
459
+ }
460
+ // Task 6: when transitioning *into* completed (i.e. previous status was not
461
+ // already completed), fan out dep-resolved comments + unblock inbox messages
462
+ // to each downstream task. Best-effort: any failure here must not abort the
463
+ // status change. Stable ids guarantee idempotency on accidental re-fires.
464
+ if (status === "completed" && current.status !== "completed") {
465
+ await notifyUnblockedAfterCompletion(store, updated, args, context);
466
+ }
467
+ // Re-read to reflect any link cleanup we may have done above.
468
+ const fresh = store.tryGet(taskId) ?? updated;
469
+ return {
470
+ content: [{ type: "text", text: JSON.stringify(fresh) }],
471
+ };
472
+ }
473
+ async function handleTaskSetOwner(args, context) {
474
+ assertQueenCaller(args, "task_set_owner");
475
+ const taskId = args.taskId;
476
+ if (!taskId)
477
+ throw new Error("taskId is required");
478
+ if (!("owner" in args))
479
+ throw new Error("owner is required (use null to clear)");
480
+ const ownerRaw = args.owner;
481
+ if (ownerRaw !== null && typeof ownerRaw !== "string") {
482
+ throw new Error("owner must be a string or null");
483
+ }
484
+ const owner = ownerRaw;
485
+ const store = getStore(args, context);
486
+ const current = store.get(taskId);
487
+ const updated = await store.update(taskId, { owner });
488
+ if (owner && current.swarmId) {
489
+ const msg = {
490
+ id: `owner-${taskId}-${owner}`,
491
+ from: "system",
492
+ type: "task_owner_set",
493
+ data: JSON.stringify({ taskId, owner, subject: current.subject }),
494
+ taskId,
495
+ timestamp: Date.now(),
496
+ delivered: false,
497
+ };
498
+ await appendInboxBestEffort(args.workDir, current.swarmId, owner, msg, context);
499
+ }
500
+ return {
501
+ content: [{ type: "text", text: JSON.stringify(updated) }],
502
+ };
503
+ }
504
+ async function handleTaskAddComment(args, context) {
505
+ const taskId = args.taskId;
506
+ const author = args.author;
507
+ const body = args.body;
508
+ if (!taskId)
509
+ throw new Error("taskId is required");
510
+ if (!author)
511
+ throw new Error("author is required");
512
+ if (body === undefined || body === null)
513
+ throw new Error("body is required");
514
+ const id = args.id ?? randomUUID();
515
+ const type = args.type ?? "note";
516
+ const store = getStore(args, context);
517
+ // Capture pre-state so we know the owner/swarmId for fanout below; addComment
518
+ // would also throw "Task not found", so this doubles as an explicit guard.
519
+ const current = store.get(taskId);
520
+ await store.addComment(taskId, { id, author, type, body });
521
+ const fresh = store.get(taskId);
522
+ // Notify the task's owner and the swarm's queen (best-effort), suppressing
523
+ // the author so commenters don't notify themselves. Stable id `comment-<id>`
524
+ // gives per-file dedup across recipients.
525
+ if (current.swarmId) {
526
+ const recipients = new Set();
527
+ if (current.owner && current.owner !== author)
528
+ recipients.add(current.owner);
529
+ const queenId = resolveQueenInstanceId(args.workDir, current.swarmId, context.inbox?.rootDir ?? context.rootDir);
530
+ if (queenId && queenId !== author)
531
+ recipients.add(queenId);
532
+ if (recipients.size > 0) {
533
+ const msg = {
534
+ id: `comment-${id}`,
535
+ from: "system",
536
+ type: "task_comment",
537
+ data: JSON.stringify({ taskId, commentId: id, author, body, type }),
538
+ taskId,
539
+ timestamp: Date.now(),
540
+ delivered: false,
541
+ };
542
+ for (const recipient of recipients) {
543
+ await appendInboxBestEffort(args.workDir, current.swarmId, recipient, msg, context);
544
+ }
545
+ }
546
+ }
547
+ return {
548
+ content: [{ type: "text", text: JSON.stringify({ id, task: fresh }) }],
549
+ };
550
+ }
551
+ async function handleTaskLink(args, context) {
552
+ const fromTaskId = args.fromTaskId;
553
+ const toTaskId = args.toTaskId;
554
+ if (!fromTaskId)
555
+ throw new Error("fromTaskId is required");
556
+ if (!toTaskId)
557
+ throw new Error("toTaskId is required");
558
+ if (fromTaskId === toTaskId)
559
+ throw new Error("cannot link a task to itself");
560
+ const store = getStore(args, context);
561
+ await store.link(fromTaskId, toTaskId);
562
+ const from = store.get(fromTaskId);
563
+ return {
564
+ content: [{ type: "text", text: JSON.stringify(from) }],
565
+ };
566
+ }
567
+ async function handleTaskUnlink(args, context) {
568
+ const fromTaskId = args.fromTaskId;
569
+ const toTaskId = args.toTaskId;
570
+ if (!fromTaskId)
571
+ throw new Error("fromTaskId is required");
572
+ if (!toTaskId)
573
+ throw new Error("toTaskId is required");
574
+ const store = getStore(args, context);
575
+ await store.unlink(fromTaskId, toTaskId);
576
+ const from = store.tryGet(fromTaskId);
577
+ return {
578
+ content: [{ type: "text", text: JSON.stringify({ from }) }],
579
+ };
580
+ }
581
+ /**
582
+ * task_briefing — produce a per-instance situation summary inside one swarm.
583
+ *
584
+ * Simplifications vs. the original Phase 3 plan, due to current Task schema:
585
+ * - No `priority` field exists, so actionable is sorted by createdAt asc only
586
+ * (the plan specified `priority desc, createdAt asc`).
587
+ * - No `reviewer` field exists, so awareness only checks
588
+ * `classifyBlockers(t).waiting.length > 0` (the plan also OR'd a reviewer
589
+ * match) and the `reviewNeeded` counter is omitted.
590
+ * - Warnings cover only broken deps + orphaned in_progress
591
+ * (status === "in_progress" && owner === null).
592
+ */
593
+ async function handleTaskBriefing(args, context) {
594
+ const swarmId = args.swarmId;
595
+ const instanceId = args.instanceId;
596
+ if (!swarmId)
597
+ throw new Error("swarmId is required");
598
+ if (!instanceId)
599
+ throw new Error("instanceId is required");
600
+ const format = parseBriefingFormat(args);
601
+ const store = getStore(args, context);
602
+ const body = computeTaskBriefing(store, swarmId, instanceId);
603
+ const text = format === "markdown"
604
+ ? formatTaskBriefing(body, { instanceId })
605
+ : JSON.stringify(body);
606
+ return {
607
+ content: [{ type: "text", text }],
608
+ };
609
+ }
610
+ /**
611
+ * Resolve the queen instance id for a swarm by reading the swarm snapshot
612
+ * (`recovery.json`) from disk. Returns null when the snapshot is missing,
613
+ * malformed, or contains no queen role. Errors are swallowed: queen routing
614
+ * is best-effort and never aborts fanout.
615
+ *
616
+ * Resolution order:
617
+ * 1. Prefer `role.type === "queen"` (post P3-3 snapshots) so custom queen
618
+ * role names (e.g. "lead", "chief") are still routed correctly.
619
+ * 2. Fall back to `role.roleName === "queen"` for snapshots written before
620
+ * the `type` field was added to `SwarmSnapshot.roles[]`.
621
+ */
622
+ function resolveQueenInstanceId(workDir, swarmId, rootDir) {
623
+ try {
624
+ const snapshotPath = join(projectRoot(workDir, rootDir), "swarms", swarmId, "recovery.json");
625
+ if (!existsSync(snapshotPath))
626
+ return null;
627
+ const snapshot = JSON.parse(readFileSync(snapshotPath, "utf-8"));
628
+ if (!Array.isArray(snapshot.roles))
629
+ return null;
630
+ for (const role of snapshot.roles) {
631
+ if (role && role.type === "queen" && typeof role.instanceId === "string") {
632
+ return role.instanceId;
633
+ }
634
+ }
635
+ // Backward compat for snapshots written before `type` was persisted.
636
+ for (const role of snapshot.roles) {
637
+ if (role && role.roleName === "queen" && typeof role.instanceId === "string") {
638
+ return role.instanceId;
639
+ }
640
+ }
641
+ return null;
642
+ }
643
+ catch (err) {
644
+ log.warn({ err, swarmId }, "resolveQueenInstanceId failed");
645
+ return null;
646
+ }
647
+ }
648
+ /**
649
+ * Write an inbox message for a recipient, then best-effort relay.deliver.
650
+ * All errors are swallowed and logged at warn — callers must never depend
651
+ * on this for correctness; the underlying task action has already succeeded.
652
+ */
653
+ async function appendInboxBestEffort(workDir, swarmId, instanceId, msg, context) {
654
+ try {
655
+ const inbox = new InboxStore(workDir, swarmId, context.inbox?.rootDir);
656
+ await inbox.append(instanceId, msg);
657
+ }
658
+ catch (err) {
659
+ log.warn({ err, instanceId, swarmId, msgId: msg.id }, "inbox append failed (best-effort)");
660
+ return;
661
+ }
662
+ if (context.inbox?.relay) {
663
+ try {
664
+ await context.inbox.relay.deliver(swarmId, instanceId);
665
+ }
666
+ catch (err) {
667
+ log.warn({ err, instanceId, swarmId, msgId: msg.id }, "inbox relay deliver failed (best-effort)");
668
+ }
669
+ }
670
+ }
671
+ /**
672
+ * Fan out completion notifications to downstream tasks (those listed in
673
+ * `completed.blocks`). For each downstream:
674
+ * 1. Append a `dep-resolved-${completedId}-${blockedId}` system comment
675
+ * (idempotent via stable id; addComment dedupes).
676
+ * 2. If `classifyBlockers` reports the downstream is now fully unblocked,
677
+ * write an `unblock-${completedId}-${blockedId}` inbox message to the
678
+ * downstream owner (when set) AND to the queen instance (when one can
679
+ * be resolved from the snapshot). Recipients are deduped via Set.
680
+ *
681
+ * Cross-swarm safety: each downstream task uses its OWN swarmId for inbox
682
+ * routing, not the caller's. Callers should pass `args.workDir` through.
683
+ *
684
+ * Best-effort: comment / inbox / relay failures are logged at warn and never
685
+ * propagate to the caller (the status change has already succeeded).
686
+ */
687
+ async function notifyUnblockedAfterCompletion(store, completed, args, context) {
688
+ const workDir = args.workDir;
689
+ for (const blockedId of completed.blocks) {
690
+ const blocked = store.tryGet(blockedId);
691
+ if (!blocked) {
692
+ log.warn({ completedId: completed.id, blockedId }, "downstream task not found, skip fanout");
693
+ continue;
694
+ }
695
+ // Step 1: dep-resolved comment (idempotent on stable id).
696
+ const commentId = `dep-resolved-${completed.id}-${blockedId}`;
697
+ try {
698
+ await store.addComment(blockedId, {
699
+ id: commentId,
700
+ author: "system",
701
+ type: "dep_resolved",
702
+ body: `Dependency ${completed.id} is now completed.`,
703
+ });
704
+ }
705
+ catch (err) {
706
+ log.warn({ err, completedId: completed.id, blockedId }, "addComment fanout failed");
707
+ }
708
+ // Step 2: classify and, if fully unblocked, write inbox messages.
709
+ let isFullyUnblocked = false;
710
+ try {
711
+ isFullyUnblocked = !classifyBlockers(blocked, store).isBlocked;
712
+ }
713
+ catch (err) {
714
+ log.warn({ err, blockedId }, "classifyBlockers failed");
715
+ continue;
716
+ }
717
+ if (!isFullyUnblocked)
718
+ continue;
719
+ if (!context.inbox) {
720
+ log.warn({ blockedId }, "inbox context missing, skip unblock message");
721
+ continue;
722
+ }
723
+ if (!blocked.swarmId) {
724
+ log.warn({ blockedId }, "downstream has no swarmId, skip unblock message");
725
+ continue;
726
+ }
727
+ const recipients = new Set();
728
+ if (blocked.owner)
729
+ recipients.add(blocked.owner);
730
+ const queenId = resolveQueenInstanceId(workDir, blocked.swarmId, context.inbox.rootDir);
731
+ if (queenId)
732
+ recipients.add(queenId);
733
+ if (recipients.size === 0)
734
+ continue;
735
+ const messageId = `unblock-${completed.id}-${blockedId}`;
736
+ const inbox = new InboxStore(workDir, blocked.swarmId, context.inbox?.rootDir);
737
+ for (const instanceId of recipients) {
738
+ const msg = {
739
+ id: messageId,
740
+ from: "system",
741
+ type: "task_unblocked",
742
+ data: JSON.stringify({
743
+ taskId: blockedId,
744
+ unblockedBy: completed.id,
745
+ subject: blocked.subject,
746
+ }),
747
+ taskId: blockedId,
748
+ timestamp: Date.now(),
749
+ delivered: false,
750
+ };
751
+ try {
752
+ await inbox.append(instanceId, msg);
753
+ }
754
+ catch (err) {
755
+ log.warn({ err, instanceId, blockedId }, "inbox append failed (best-effort)");
756
+ continue;
757
+ }
758
+ if (context.inbox.relay) {
759
+ try {
760
+ await context.inbox.relay.deliver(blocked.swarmId, instanceId);
761
+ }
762
+ catch (err) {
763
+ log.warn({ err, instanceId, blockedId }, "inbox relay deliver failed (best-effort)");
764
+ }
765
+ }
766
+ }
767
+ }
768
+ }
769
+ /**
770
+ * lead_briefing — Queen-facing situation summary for one swarm.
771
+ *
772
+ * Phase-1 simplifications (documented inline):
773
+ * - `assign_reviewer` is **always** an empty array because the Phase 1
774
+ * `Task` schema has no `reviewer` field. Kept in the response shape so
775
+ * downstream callers don't need to branch.
776
+ * - `clarify` uses a heuristic: a task is bucketed when its most recent
777
+ * comment body contains a "?" character. Once a `question`/`resolved`
778
+ * comment-type convention exists, this can be tightened.
779
+ * - `lead_owned` requires `recovery.json` to expose a `roleName === "queen"`
780
+ * entry. When absent (or unparseable), `lead_owned` is silently empty —
781
+ * the other buckets still work.
782
+ */
783
+ async function handleLeadBriefing(args, context) {
784
+ const workDir = args.workDir;
785
+ const swarmId = args.swarmId;
786
+ if (!workDir)
787
+ throw new Error("workDir is required");
788
+ if (!swarmId)
789
+ throw new Error("swarmId is required");
790
+ const format = parseBriefingFormat(args);
791
+ const store = getStore(args, context);
792
+ const queenId = resolveQueenInstanceId(workDir, swarmId, context.rootDir);
793
+ const body = computeLeadBriefing(store, swarmId, queenId);
794
+ const text = format === "markdown" ? formatLeadBriefing(body) : JSON.stringify(body);
795
+ return {
796
+ content: [{ type: "text", text }],
797
+ };
798
+ }
799
+ /**
800
+ * member_briefing — boot-time briefing for a worker (or queen) instance.
801
+ *
802
+ * Returns a hardcoded list of 4-6 boot rules plus a `bootstrap` echo of the
803
+ * caller's identity. Rules are slightly tweaked when `roleType === "queen"`
804
+ * to reflect lead responsibilities (assigning owners, repairing deps).
805
+ */
806
+ async function handleMemberBriefing(args, _context) {
807
+ const swarmId = args.swarmId;
808
+ const instanceId = args.instanceId;
809
+ const roleType = args.roleType;
810
+ if (!swarmId)
811
+ throw new Error("swarmId is required");
812
+ if (!instanceId)
813
+ throw new Error("instanceId is required");
814
+ if (!roleType)
815
+ throw new Error("roleType is required");
816
+ const format = parseBriefingFormat(args);
817
+ const body = computeMemberBriefing({ swarmId, instanceId, roleType });
818
+ const text = format === "markdown" ? formatMemberBriefing(body) : JSON.stringify(body);
819
+ return {
820
+ content: [{ type: "text", text }],
821
+ };
822
+ }
823
+ //# sourceMappingURL=task.js.map