@teamclaws/teamclaw 2026.3.21 → 2026.3.25

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.
@@ -5,6 +5,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
6
6
  import type {
7
7
  ClarificationRequest,
8
+ ControllerRunInfo,
9
+ ControllerRunSource,
8
10
  GitRepoState,
9
11
  PluginConfig,
10
12
  RepoSyncInfo,
@@ -29,7 +31,7 @@ import {
29
31
  generateId,
30
32
  } from "../protocol.js";
31
33
  import { listWorkspaceTree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
32
- import { ROLES } from "../roles.js";
34
+ import { ROLES, normalizeRecommendedSkills, resolveRecommendedSkillsForRole } from "../roles.js";
33
35
  import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js";
34
36
  import type { LocalWorkerManager } from "./local-worker-manager.js";
35
37
  import { TaskRouter } from "./task-router.js";
@@ -51,6 +53,7 @@ export type ControllerHttpDeps = {
51
53
  };
52
54
 
53
55
  const MAX_TASK_EXECUTION_EVENTS = 250;
56
+ const MAX_CONTROLLER_RUNS = 40;
54
57
  const MAX_RECENT_TASK_CONTEXT = 3;
55
58
  const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
56
59
  const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
@@ -167,6 +170,196 @@ function buildTaskExecutionSummary(execution?: TaskExecution): TaskExecutionSumm
167
170
  };
168
171
  }
169
172
 
173
+ function ensureControllerRunExecution(run: ControllerRunInfo): TaskExecution {
174
+ if (!run.execution) {
175
+ run.execution = {
176
+ status: run.status,
177
+ runId: run.runId,
178
+ startedAt: run.startedAt,
179
+ endedAt: run.completedAt,
180
+ lastUpdatedAt: run.updatedAt,
181
+ events: [],
182
+ };
183
+ }
184
+
185
+ if (!Array.isArray(run.execution.events)) {
186
+ run.execution.events = [];
187
+ }
188
+
189
+ run.execution.status = run.status;
190
+ run.execution.runId = run.runId ?? run.execution.runId;
191
+ run.execution.startedAt = run.startedAt ?? run.execution.startedAt;
192
+ run.execution.endedAt = run.completedAt ?? run.execution.endedAt;
193
+ run.execution.lastUpdatedAt = run.updatedAt ?? run.execution.lastUpdatedAt;
194
+ return run.execution;
195
+ }
196
+
197
+ function appendControllerRunEvent(run: ControllerRunInfo, input: TaskExecutionEventInput): TaskExecutionEvent {
198
+ const now = input.createdAt ?? Date.now();
199
+ const execution = ensureControllerRunExecution(run);
200
+
201
+ if (input.runId) {
202
+ run.runId = input.runId;
203
+ execution.runId = input.runId;
204
+ }
205
+ if (input.sessionKey) {
206
+ execution.sessionKey = input.sessionKey;
207
+ }
208
+ if (input.status) {
209
+ run.status = input.status;
210
+ execution.status = input.status;
211
+ }
212
+
213
+ if ((input.status === "running" || input.phase === "run_started") && !run.startedAt) {
214
+ run.startedAt = now;
215
+ execution.startedAt = now;
216
+ }
217
+ if (run.status === "completed" || run.status === "failed") {
218
+ run.completedAt = run.completedAt ?? now;
219
+ execution.endedAt = execution.endedAt ?? now;
220
+ }
221
+
222
+ run.updatedAt = now;
223
+ execution.lastUpdatedAt = now;
224
+
225
+ const event: TaskExecutionEvent = {
226
+ id: generateId(),
227
+ type: input.type,
228
+ createdAt: now,
229
+ message: input.message,
230
+ phase: input.phase,
231
+ source: input.source,
232
+ stream: input.stream,
233
+ };
234
+
235
+ execution.events.push(event);
236
+ if (execution.events.length > MAX_TASK_EXECUTION_EVENTS) {
237
+ execution.events = execution.events.slice(-MAX_TASK_EXECUTION_EVENTS);
238
+ }
239
+
240
+ return event;
241
+ }
242
+
243
+ function trimControllerRuns(state: TeamState): void {
244
+ const runs = Object.values(state.controllerRuns)
245
+ .sort((left, right) => left.updatedAt - right.updatedAt);
246
+ if (runs.length <= MAX_CONTROLLER_RUNS) {
247
+ return;
248
+ }
249
+ for (const run of runs.slice(0, runs.length - MAX_CONTROLLER_RUNS)) {
250
+ delete state.controllerRuns[run.id];
251
+ }
252
+ }
253
+
254
+ function serializeControllerRun(run?: ControllerRunInfo, includeExecutionEvents = true): Record<string, unknown> | undefined {
255
+ if (!run) {
256
+ return undefined;
257
+ }
258
+
259
+ const payload: Record<string, unknown> = { ...run };
260
+ if (!run.execution) {
261
+ return payload;
262
+ }
263
+
264
+ payload.execution = includeExecutionEvents
265
+ ? {
266
+ status: run.execution.status,
267
+ runId: run.execution.runId,
268
+ startedAt: run.execution.startedAt,
269
+ endedAt: run.execution.endedAt,
270
+ lastUpdatedAt: run.execution.lastUpdatedAt,
271
+ events: run.execution.events.map((event) => ({ ...event })),
272
+ }
273
+ : buildTaskExecutionSummary(run.execution);
274
+
275
+ return payload;
276
+ }
277
+
278
+ function buildControllerRunTitle(
279
+ message: string,
280
+ source: ControllerRunSource,
281
+ sourceTaskTitle?: string,
282
+ ): string {
283
+ if (source === "task_follow_up") {
284
+ return sourceTaskTitle
285
+ ? `Controller follow-up after ${sourceTaskTitle}`
286
+ : "Controller workflow follow-up";
287
+ }
288
+
289
+ const normalized = message.replace(/\s+/g, " ").trim();
290
+ if (!normalized) {
291
+ return "Controller intake";
292
+ }
293
+ if (normalized.length <= 100) {
294
+ return normalized;
295
+ }
296
+ return `${normalized.slice(0, 100).trimEnd()}…`;
297
+ }
298
+
299
+ function createControllerRun(
300
+ message: string,
301
+ sessionKey: string,
302
+ deps: ControllerHttpDeps,
303
+ options?: {
304
+ source?: ControllerRunSource;
305
+ sourceTaskId?: string;
306
+ sourceTaskTitle?: string;
307
+ },
308
+ ): ControllerRunInfo {
309
+ const now = Date.now();
310
+ const run: ControllerRunInfo = {
311
+ id: generateId(),
312
+ title: buildControllerRunTitle(message, options?.source ?? "human", options?.sourceTaskTitle),
313
+ sessionKey,
314
+ source: options?.source ?? "human",
315
+ sourceTaskId: options?.sourceTaskId,
316
+ sourceTaskTitle: options?.sourceTaskTitle,
317
+ request: message,
318
+ createdTaskIds: [],
319
+ status: "pending",
320
+ createdAt: now,
321
+ updatedAt: now,
322
+ };
323
+
324
+ const state = deps.updateTeamState((teamState) => {
325
+ teamState.controllerRuns[run.id] = run;
326
+ trimControllerRuns(teamState);
327
+ });
328
+ const createdRun = state.controllerRuns[run.id] ?? run;
329
+ deps.wsServer.broadcastUpdate({ type: "controller:run", data: serializeControllerRun(createdRun) });
330
+ return createdRun;
331
+ }
332
+
333
+ function updateControllerRun(
334
+ runId: string,
335
+ deps: ControllerHttpDeps,
336
+ updater: (run: ControllerRunInfo) => void,
337
+ ): ControllerRunInfo | undefined {
338
+ const state = deps.updateTeamState((teamState) => {
339
+ const run = teamState.controllerRuns[runId];
340
+ if (!run) {
341
+ return;
342
+ }
343
+ updater(run);
344
+ trimControllerRuns(teamState);
345
+ });
346
+ const updatedRun = state.controllerRuns[runId];
347
+ if (updatedRun) {
348
+ deps.wsServer.broadcastUpdate({ type: "controller:run", data: serializeControllerRun(updatedRun) });
349
+ }
350
+ return updatedRun;
351
+ }
352
+
353
+ function recordControllerRunEvent(
354
+ runId: string,
355
+ input: TaskExecutionEventInput,
356
+ deps: ControllerHttpDeps,
357
+ ): ControllerRunInfo | undefined {
358
+ return updateControllerRun(runId, deps, (run) => {
359
+ appendControllerRunEvent(run, input);
360
+ });
361
+ }
362
+
170
363
  function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record<string, unknown> | undefined {
171
364
  if (!task) {
172
365
  return undefined;
@@ -248,7 +441,8 @@ function tagControllerCreatedTasks(
248
441
  taskIdsBeforeRun: Set<string>,
249
442
  sessionKey: string,
250
443
  deps: ControllerHttpDeps,
251
- ): void {
444
+ ): string[] {
445
+ const taggedTaskIds: string[] = [];
252
446
  deps.updateTeamState((state) => {
253
447
  for (const task of Object.values(state.tasks)) {
254
448
  if (taskIdsBeforeRun.has(task.id)) {
@@ -258,8 +452,10 @@ function tagControllerCreatedTasks(
258
452
  continue;
259
453
  }
260
454
  task.controllerSessionKey = sessionKey;
455
+ taggedTaskIds.push(task.id);
261
456
  }
262
457
  });
458
+ return taggedTaskIds;
263
459
  }
264
460
 
265
461
  function buildControllerFollowUpMessage(task: TaskInfo): string {
@@ -297,20 +493,48 @@ async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDe
297
493
  if (task.createdBy !== "controller" || !task.controllerSessionKey) {
298
494
  return;
299
495
  }
300
- await runControllerIntake(buildControllerFollowUpMessage(task), task.controllerSessionKey, deps);
496
+ await runControllerIntake(buildControllerFollowUpMessage(task), task.controllerSessionKey, deps, {
497
+ source: "task_follow_up",
498
+ sourceTaskId: task.id,
499
+ sourceTaskTitle: task.title,
500
+ });
301
501
  }
302
502
 
303
503
  async function runControllerIntake(
304
504
  message: string,
305
505
  sessionKey: string,
306
506
  deps: ControllerHttpDeps,
307
- ): Promise<{ sessionKey: string; runId: string; reply: string }> {
507
+ options?: {
508
+ source?: ControllerRunSource;
509
+ sourceTaskId?: string;
510
+ sourceTaskTitle?: string;
511
+ },
512
+ ): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> {
308
513
  const taskIdsBeforeRun = collectTaskIds(deps.getTeamState());
514
+ const controllerRun = createControllerRun(message, sessionKey, deps, options);
515
+ recordControllerRunEvent(controllerRun.id, {
516
+ type: "lifecycle",
517
+ phase: "queued",
518
+ source: "controller",
519
+ status: "pending",
520
+ sessionKey,
521
+ message: "Controller intake queued.",
522
+ }, deps);
523
+
309
524
  const runResult = await deps.runtime.subagent.run({
310
525
  sessionKey,
311
526
  message,
312
527
  idempotencyKey: `controller-intake-${generateId()}`,
313
528
  });
529
+ recordControllerRunEvent(controllerRun.id, {
530
+ type: "lifecycle",
531
+ phase: "run_started",
532
+ source: "controller",
533
+ status: "running",
534
+ sessionKey,
535
+ runId: runResult.runId,
536
+ message: `Controller intake started (${runResult.runId}).`,
537
+ }, deps);
314
538
 
315
539
  const waitResult = await deps.runtime.subagent.waitForRun({
316
540
  runId: runResult.runId,
@@ -318,26 +542,90 @@ async function runControllerIntake(
318
542
  });
319
543
 
320
544
  if (waitResult.status === "timeout") {
321
- tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
545
+ const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
546
+ updateControllerRun(controllerRun.id, deps, (run) => {
547
+ run.createdTaskIds = createdTaskIds;
548
+ run.error = "Controller intake timed out";
549
+ appendControllerRunEvent(run, {
550
+ type: "error",
551
+ phase: "timeout",
552
+ source: "controller",
553
+ status: "failed",
554
+ sessionKey,
555
+ runId: runResult.runId,
556
+ message: "Controller intake timed out.",
557
+ });
558
+ });
322
559
  throw new Error("Controller intake timed out");
323
560
  }
324
561
  if (waitResult.status !== "ok") {
325
- tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
326
- throw new Error(waitResult.error || "Controller intake failed");
562
+ const errorMessage = waitResult.error || "Controller intake failed";
563
+ const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
564
+ updateControllerRun(controllerRun.id, deps, (run) => {
565
+ run.createdTaskIds = createdTaskIds;
566
+ run.error = errorMessage;
567
+ appendControllerRunEvent(run, {
568
+ type: "error",
569
+ phase: "run_failed",
570
+ source: "controller",
571
+ status: "failed",
572
+ sessionKey,
573
+ runId: runResult.runId,
574
+ message: errorMessage,
575
+ });
576
+ });
577
+ throw new Error(errorMessage);
327
578
  }
328
579
 
329
- tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
580
+ const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
330
581
 
331
582
  const sessionMessages = await deps.runtime.subagent.getSessionMessages({
332
583
  sessionKey,
333
584
  limit: 100,
334
585
  });
335
- const reply = extractLastAssistantText(sessionMessages.messages);
586
+ const reply = extractLastAssistantText(sessionMessages.messages)
587
+ || "Controller completed the intake run but did not return any text.";
588
+
589
+ updateControllerRun(controllerRun.id, deps, (run) => {
590
+ run.reply = reply;
591
+ run.error = undefined;
592
+ run.createdTaskIds = createdTaskIds;
593
+ appendControllerRunEvent(run, {
594
+ type: "output",
595
+ phase: "final_reply",
596
+ source: "subagent",
597
+ status: "running",
598
+ sessionKey,
599
+ runId: runResult.runId,
600
+ message: reply,
601
+ });
602
+ if (createdTaskIds.length > 0) {
603
+ appendControllerRunEvent(run, {
604
+ type: "lifecycle",
605
+ phase: "tasks_created",
606
+ source: "controller",
607
+ status: "running",
608
+ sessionKey,
609
+ runId: runResult.runId,
610
+ message: `Controller created ${createdTaskIds.length} task(s): ${createdTaskIds.join(", ")}`,
611
+ });
612
+ }
613
+ appendControllerRunEvent(run, {
614
+ type: "lifecycle",
615
+ phase: "run_completed",
616
+ source: "controller",
617
+ status: "completed",
618
+ sessionKey,
619
+ runId: runResult.runId,
620
+ message: "Controller intake completed.",
621
+ });
622
+ });
336
623
 
337
624
  return {
338
625
  sessionKey,
339
626
  runId: runResult.runId,
340
- reply: reply || "Controller completed the intake run but did not return any text.",
627
+ reply,
628
+ controllerRunId: controllerRun.id,
341
629
  };
342
630
  }
343
631
 
@@ -384,8 +672,27 @@ function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null
384
672
  ].join("\n");
385
673
  }
386
674
 
675
+ function buildRecommendedSkillsContext(task: TaskInfo): string {
676
+ const recommendedSkills = resolveRecommendedSkillsForRole(task.assignedRole, task.recommendedSkills ?? []);
677
+ if (recommendedSkills.length === 0) {
678
+ return "";
679
+ }
680
+
681
+ return [
682
+ "## Recommended Skills",
683
+ "- Prefer these skill slugs for this task when relevant:",
684
+ ...recommendedSkills.map((skill) => ` - ${skill}`),
685
+ "- Before starting, search/install missing recommended skills in the current workspace when the runtime supports it.",
686
+ "- Prefer exact ClawHub/OpenClaw skill slugs over vague descriptions whenever possible.",
687
+ ].join("\n");
688
+ }
689
+
387
690
  function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
388
691
  const parts = [task.description];
692
+ const recommendedSkillsContext = buildRecommendedSkillsContext(task);
693
+ if (recommendedSkillsContext) {
694
+ parts.push("", recommendedSkillsContext);
695
+ }
389
696
  const recentContext = buildRecentCompletedTaskContext(task, state);
390
697
  if (recentContext) {
391
698
  parts.push("", recentContext);
@@ -746,11 +1053,13 @@ async function dispatchTaskToWorker(
746
1053
  const repoState = await refreshControllerRepoState(deps);
747
1054
  const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
748
1055
  const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
1056
+ const recommendedSkills = resolveRecommendedSkillsForRole(task.assignedRole, task.recommendedSkills ?? []);
749
1057
  const assignment: TaskAssignmentPayload = {
750
1058
  taskId: task.id,
751
1059
  title: task.title,
752
1060
  description,
753
1061
  priority: task.priority,
1062
+ recommendedSkills,
754
1063
  repo: repoInfo,
755
1064
  };
756
1065
 
@@ -1139,6 +1448,9 @@ async function handleRequest(
1139
1448
  const priority = typeof body.priority === "string" ? body.priority as TaskPriority : "medium";
1140
1449
  const assignedRole = typeof body.assignedRole === "string" ? body.assignedRole as RoleId : undefined;
1141
1450
  const createdBy = typeof body.createdBy === "string" ? body.createdBy : "boss";
1451
+ const recommendedSkills = normalizeRecommendedSkills(
1452
+ Array.isArray(body.recommendedSkills) ? body.recommendedSkills.map((entry) => String(entry ?? "")) : [],
1453
+ );
1142
1454
 
1143
1455
  if (!title) {
1144
1456
  sendError(res, 400, "title is required");
@@ -1157,6 +1469,7 @@ async function handleRequest(
1157
1469
  priority,
1158
1470
  assignedRole,
1159
1471
  createdBy,
1472
+ recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined,
1160
1473
  createdAt: now,
1161
1474
  updatedAt: now,
1162
1475
  };
@@ -1184,6 +1497,16 @@ async function handleRequest(
1184
1497
  role: assignedRole,
1185
1498
  }, deps);
1186
1499
  }
1500
+ if (recommendedSkills.length > 0) {
1501
+ recordTaskExecutionEvent(taskId, {
1502
+ type: "lifecycle",
1503
+ phase: "skills_recommended",
1504
+ source: "controller",
1505
+ status: "pending",
1506
+ message: `Recommended skills: ${recommendedSkills.join(", ")}`,
1507
+ role: assignedRole,
1508
+ }, deps);
1509
+ }
1187
1510
 
1188
1511
  await autoAssignPendingTasks(deps);
1189
1512
 
@@ -1259,6 +1582,12 @@ async function handleRequest(
1259
1582
  if (typeof body.progress === "string") task.progress = body.progress as string;
1260
1583
  if (typeof body.priority === "string") task.priority = body.priority as TaskPriority;
1261
1584
  if (typeof body.assignedRole === "string") task.assignedRole = body.assignedRole as RoleId;
1585
+ if (Array.isArray(body.recommendedSkills)) {
1586
+ const recommendedSkills = normalizeRecommendedSkills(
1587
+ body.recommendedSkills.map((entry: unknown) => String(entry ?? "")),
1588
+ );
1589
+ task.recommendedSkills = recommendedSkills.length > 0 ? recommendedSkills : undefined;
1590
+ }
1262
1591
  task.updatedAt = Date.now();
1263
1592
 
1264
1593
  if (typeof body.status === "string" && body.status !== previousStatus) {
@@ -1463,6 +1792,18 @@ async function handleRequest(
1463
1792
 
1464
1793
  // ==================== Message Routing ====================
1465
1794
 
1795
+ // GET /api/v1/controller/runs
1796
+ if (req.method === "GET" && pathname === "/api/v1/controller/runs") {
1797
+ const state = getTeamState();
1798
+ const controllerRuns = state
1799
+ ? Object.values(state.controllerRuns)
1800
+ .sort((left, right) => right.updatedAt - left.updatedAt)
1801
+ .map((run) => serializeControllerRun(run))
1802
+ : [];
1803
+ sendJson(res, 200, { controllerRuns });
1804
+ return;
1805
+ }
1806
+
1466
1807
  // POST /api/v1/controller/intake
1467
1808
  if (req.method === "POST" && pathname === "/api/v1/controller/intake") {
1468
1809
  const body = await parseJsonBody(req);
@@ -1907,6 +2248,7 @@ async function handleRequest(
1907
2248
  teamName: config.teamName,
1908
2249
  workers: [],
1909
2250
  tasks: [],
2251
+ controllerRuns: [],
1910
2252
  messages: [],
1911
2253
  clarifications: [],
1912
2254
  repo: null,
@@ -1920,6 +2262,9 @@ async function handleRequest(
1920
2262
  teamName: state.teamName,
1921
2263
  workers: Object.values(state.workers),
1922
2264
  tasks: Object.values(state.tasks).map((task) => serializeTask(task)),
2265
+ controllerRuns: Object.values(state.controllerRuns)
2266
+ .sort((left, right) => right.updatedAt - left.updatedAt)
2267
+ .map((run) => serializeControllerRun(run)),
1923
2268
  messages: state.messages,
1924
2269
  clarifications,
1925
2270
  repo: state.repo ?? null,
@@ -1,5 +1,4 @@
1
1
  import fs from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import net from "node:net";
5
4
  import { spawn, type ChildProcess } from "node:child_process";
@@ -19,6 +18,7 @@ import type {
19
18
  import {
20
19
  resolveDefaultOpenClawConfigPath,
21
20
  resolveDefaultOpenClawStateDir,
21
+ resolveDefaultTeamClawRuntimeRootDir,
22
22
  resolveDefaultOpenClawWorkspaceDir,
23
23
  } from "../openclaw-workspace.js";
24
24
 
@@ -125,8 +125,10 @@ export class LocalWorkerManager {
125
125
  }
126
126
 
127
127
  this.stoppingAll = false;
128
+ const workerBaseRoot = path.join(resolveDefaultTeamClawRuntimeRootDir(), "local-workers");
129
+ await fs.mkdir(workerBaseRoot, { recursive: true });
128
130
  this.workerBaseDir = await fs.mkdtemp(
129
- path.join(os.tmpdir(), `teamclaw-local-workers-${sanitizePathSegment(this.deps.config.teamName)}-`),
131
+ path.join(workerBaseRoot, `${sanitizePathSegment(this.deps.config.teamName)}-`),
130
132
  );
131
133
 
132
134
  const sourceStateDir = resolveDefaultOpenClawStateDir();
@@ -153,7 +155,7 @@ export class LocalWorkerManager {
153
155
 
154
156
  if (this.workerBaseDir) {
155
157
  await fs.rm(this.workerBaseDir, { recursive: true, force: true }).catch(() => {
156
- // Best-effort cleanup; tmpdirs are safe to leave behind.
158
+ // Best-effort cleanup; managed runtime dirs are safe to leave behind.
157
159
  });
158
160
  this.workerBaseDir = null;
159
161
  }
@@ -80,7 +80,10 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
80
80
  parts.push("");
81
81
  parts.push("### Available Roles");
82
82
  for (const role of ROLES) {
83
- parts.push(`- ${role.icon} ${role.label}: ${role.description}`);
83
+ const skillLine = role.recommendedSkills.length > 0
84
+ ? ` Recommended skills: ${role.recommendedSkills.join(", ")}.`
85
+ : "";
86
+ parts.push(`- ${role.icon} ${role.label}: ${role.description}.${skillLine}`);
84
87
  }
85
88
 
86
89
  parts.push("");
@@ -89,6 +92,8 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
89
92
  parts.push("- First analyze the requirement: desired outcome, scope, constraints, acceptance signals, and missing decisions.");
90
93
  parts.push("- If critical information is missing, ask the human a concrete clarification question before creating execution tasks.");
91
94
  parts.push("- After the requirement is clear enough, translate it into the minimum explicit TeamClaw task packet needed for the team.");
95
+ parts.push("- When creating a task, include a recommendedSkills array whenever you know a useful OpenClaw/ClawHub skill slug (or a short search query if you do not know the exact slug).");
96
+ parts.push("- Prefer exact skill slugs over vague labels so the assigned worker can auto-search/install them before starting.");
92
97
  parts.push("- 'Minimum task packet' means only tasks that can start immediately with the currently available information and already-satisfied prerequisites.");
93
98
  parts.push("- If later phases depend on outputs that do not exist yet, describe them to the human as the plan, but do not create those TeamClaw tasks yet.");
94
99
  parts.push("- Downstream QA/review/release/README/integration tasks must stay in the plan until the upstream code or artifacts already exist in the workspace.");
@@ -5,6 +5,7 @@ import type { PluginLogger } from "../../api.js";
5
5
  export type WsEvent =
6
6
  | { type: "worker:online"; data: unknown }
7
7
  | { type: "worker:offline"; data: unknown }
8
+ | { type: "controller:run"; data: unknown }
8
9
  | { type: "task:created"; data: unknown }
9
10
  | { type: "task:updated"; data: unknown }
10
11
  | { type: "task:completed"; data: unknown }