@teamclaws/teamclaw 2026.3.25 → 2026.3.26-1

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/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
2
2
  import { parsePluginConfig } from "./src/types.js";
3
- import type { TaskExecutionEventInput, WorkerIdentity } from "./src/types.js";
3
+ import type { TaskAssignmentPayload, TaskExecutionEventInput, TeamState, WorkerIdentity } from "./src/types.js";
4
4
  import { buildConfigSchema } from "./src/config.js";
5
5
  import { loadTeamState } from "./src/state.js";
6
6
  import { createRoleTaskExecutor } from "./src/task-executor.js";
@@ -39,9 +39,18 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
39
39
  logger,
40
40
  runtime: api.runtime,
41
41
  });
42
+ let getControllerTeamState = (): TeamState | null => null;
42
43
 
43
44
  // Service (starts HTTP server + mDNS + WebSocket)
44
- api.registerService(createControllerService({ config, logger, runtime: api.runtime, localWorkerManager }));
45
+ api.registerService(createControllerService({
46
+ config,
47
+ logger,
48
+ runtime: api.runtime,
49
+ localWorkerManager,
50
+ onTeamStateAvailable: (getter) => {
51
+ getControllerTeamState = getter;
52
+ },
53
+ }));
45
54
 
46
55
  // Prompt injection
47
56
  api.on("before_prompt_build", async (_event: unknown, ctx: { sessionKey?: string | null }) => {
@@ -56,7 +65,7 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
56
65
  return injector() ?? {};
57
66
  }
58
67
 
59
- const state = await loadTeamState(config.teamName);
68
+ const state = getControllerTeamState() ?? await loadTeamState(config.teamName);
60
69
  const injector = createControllerPromptInjector({
61
70
  config,
62
71
  getTeamState: () => state,
@@ -78,7 +87,8 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
78
87
  return createControllerTools({
79
88
  config,
80
89
  controllerUrl,
81
- getTeamState: () => null,
90
+ getTeamState: getControllerTeamState,
91
+ sessionKey: ctx.sessionKey ?? null,
82
92
  });
83
93
  });
84
94
  }
@@ -122,7 +132,8 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
122
132
  }
123
133
  }
124
134
 
125
- const getWorkerSessionKey = (taskId: string) => `teamclaw-task-${taskId}`;
135
+ const getWorkerSessionKey = (assignment: TaskAssignmentPayload) =>
136
+ assignment.executionSessionKey?.trim() || `teamclaw-task-${assignment.taskId}`;
126
137
 
127
138
  const taskExecutor = createRoleTaskExecutor({
128
139
  runtime: api.runtime,
@@ -130,7 +141,8 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
130
141
  role: config.role,
131
142
  taskTimeoutMs: config.taskTimeoutMs,
132
143
  getSessionKey: getWorkerSessionKey,
133
- getIdempotencyKey: (taskId) => `teamclaw-${taskId}`,
144
+ getIdempotencyKey: (assignment) =>
145
+ assignment.executionIdempotencyKey?.trim() || `teamclaw-${assignment.taskId}`,
134
146
  reportExecutionEvent,
135
147
  });
136
148
 
@@ -144,6 +156,7 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
144
156
  currentWorkerId = identity.workerId;
145
157
  },
146
158
  prepareTaskAssignment: async (assignment) => {
159
+ const controllerUrl = currentControllerUrl || config.controllerUrl.trim();
147
160
  if (assignment.recommendedSkills?.length) {
148
161
  try {
149
162
  const skillInstall = await installRecommendedSkills(assignment, logger);
@@ -163,7 +176,7 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
163
176
  }
164
177
  }
165
178
 
166
- if (!assignment.repo?.enabled || !currentControllerUrl) {
179
+ if (!assignment.repo?.enabled || !controllerUrl) {
167
180
  return;
168
181
  }
169
182
 
@@ -176,7 +189,7 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
176
189
  });
177
190
 
178
191
  try {
179
- const syncResult = await syncWorkerRepo(config, logger, currentControllerUrl, assignment.repo);
192
+ const syncResult = await syncWorkerRepo(config, logger, controllerUrl, assignment.repo);
180
193
  await reportExecutionEvent(assignment.taskId, {
181
194
  type: "lifecycle",
182
195
  phase: "repo_sync_completed",
@@ -197,9 +210,11 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
197
210
  }
198
211
  },
199
212
  publishTaskAssignment: async (assignment) => {
200
- if (!assignment.repo?.enabled || !currentControllerUrl || !currentWorkerId) {
213
+ const controllerUrl = currentControllerUrl || config.controllerUrl.trim();
214
+ if (!assignment.repo?.enabled || !controllerUrl) {
201
215
  return;
202
216
  }
217
+ const workerId = currentWorkerId || "unknown-worker";
203
218
 
204
219
  await reportExecutionEvent(assignment.taskId, {
205
220
  type: "lifecycle",
@@ -210,9 +225,9 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
210
225
  });
211
226
 
212
227
  try {
213
- const publishResult = await publishWorkerRepo(config, logger, currentControllerUrl, assignment.repo, {
228
+ const publishResult = await publishWorkerRepo(config, logger, controllerUrl, assignment.repo, {
214
229
  taskId: assignment.taskId,
215
- workerId: currentWorkerId,
230
+ workerId,
216
231
  role: config.role,
217
232
  });
218
233
  await reportExecutionEvent(assignment.taskId, {
@@ -235,14 +250,14 @@ function registerWorker(api: OpenClawPluginApi, config: ReturnType<typeof parseP
235
250
  }
236
251
  },
237
252
  taskExecutor,
238
- cancelTaskExecution: async (taskId) => {
239
- const sessionKey = getWorkerSessionKey(taskId);
253
+ cancelTaskExecution: async (taskId, sessionKey) => {
254
+ const resolvedSessionKey = sessionKey || `teamclaw-task-${taskId}`;
240
255
  try {
241
- await api.runtime.subagent.deleteSession({ sessionKey });
242
- logger.info(`Worker: cancelled subagent session ${sessionKey} for task ${taskId}`);
256
+ await api.runtime.subagent.deleteSession({ sessionKey: resolvedSessionKey });
257
+ logger.info(`Worker: cancelled subagent session ${resolvedSessionKey} for task ${taskId}`);
243
258
  return true;
244
259
  } catch (err) {
245
- logger.warn(`Worker: failed to cancel session ${sessionKey} for task ${taskId}: ${String(err)}`);
260
+ logger.warn(`Worker: failed to cancel session ${resolvedSessionKey} for task ${taskId}: ${String(err)}`);
246
261
  return false;
247
262
  }
248
263
  },
@@ -2,7 +2,7 @@
2
2
  "id": "teamclaw",
3
3
  "name": "TeamClaw",
4
4
  "description": "Virtual team collaboration - multiple OpenClaw instances form a virtual software company with role-based task routing.",
5
- "version": "2026.3.25",
5
+ "version": "2026.3.26-1",
6
6
  "uiHints": {
7
7
  "mode": {
8
8
  "label": "Mode",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "workerProvisioningRoles": {
68
68
  "label": "Provisioned Roles",
69
- "help": "Only launch these roles on demand; leave empty for all roles"
69
+ "help": "Preferred on-demand roles; task-required roles can still launch automatically"
70
70
  },
71
71
  "workerProvisioningMinPerRole": {
72
72
  "label": "Warm Workers Per Role",
@@ -243,7 +243,7 @@
243
243
  "workerProvisioningRoles": {
244
244
  "type": "array",
245
245
  "default": [],
246
- "description": "Restrict on-demand launches to specific roles; empty means all roles",
246
+ "description": "Preferred on-demand roles; task-required roles can still launch automatically. Empty means controller-decided defaults across all roles",
247
247
  "items": {
248
248
  "type": "string",
249
249
  "enum": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamclaws/teamclaw",
3
- "version": "2026.3.25",
3
+ "version": "2026.3.26-1",
4
4
  "description": "OpenClaw virtual software team orchestration plugin",
5
5
  "private": false,
6
6
  "keywords": [
@@ -8,8 +8,12 @@
8
8
  "plugin",
9
9
  "teamclaw",
10
10
  "multi-agent",
11
- "orchestration"
11
+ "orchestration",
12
+ "virtual-team",
13
+ "sdlc",
14
+ "multi-role"
12
15
  ],
16
+ "author": "Junjun Zhang <topcheer@me.com>",
13
17
  "homepage": "https://github.com/topcheer/teamclaw/tree/main/src#readme",
14
18
  "bugs": {
15
19
  "url": "https://github.com/topcheer/teamclaw/issues"
@@ -20,8 +24,11 @@
20
24
  "directory": "src"
21
25
  },
22
26
  "type": "module",
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
23
30
  "bin": {
24
- "teamclaw": "./cli.mjs"
31
+ "teamclaw": "cli.mjs"
25
32
  },
26
33
  "files": [
27
34
  "README.md",
@@ -40,7 +47,7 @@
40
47
  "@sinclair/typebox": "0.34.48",
41
48
  "bonjour-service": "^1.3.0",
42
49
  "json5": "^2.2.3",
43
- "openclaw": "2026.3.23-2",
50
+ "openclaw": "2026.3.24",
44
51
  "ws": "^8.19.0"
45
52
  },
46
53
  "devDependencies": {
@@ -53,6 +60,11 @@
53
60
  "extensions": [
54
61
  "./index.ts"
55
62
  ],
63
+ "install": {
64
+ "npmSpec": "@teamclaws/teamclaw",
65
+ "localPath": "src",
66
+ "defaultChoice": "npm"
67
+ },
56
68
  "release": {
57
69
  "publishToNpm": true
58
70
  }
package/src/config.ts CHANGED
@@ -92,7 +92,7 @@ function buildConfigSchema() {
92
92
  workerProvisioningRoles: {
93
93
  type: "array" as const,
94
94
  default: [],
95
- description: "Restrict on-demand launches to specific roles; empty means all roles",
95
+ description: "Preferred on-demand roles; task-required roles can still launch automatically. Empty means controller-decided defaults across all roles",
96
96
  items: {
97
97
  type: "string" as const,
98
98
  enum: ROLE_IDS,
@@ -245,7 +245,7 @@ function buildConfigSchema() {
245
245
  },
246
246
  workerProvisioningRoles: {
247
247
  label: "Provisioned Roles",
248
- help: "Only launch these roles on demand; leave empty for all roles",
248
+ help: "Preferred on-demand roles; task-required roles can still launch automatically",
249
249
  },
250
250
  workerProvisioningMinPerRole: {
251
251
  label: "Warm Workers Per Role",
@@ -0,0 +1,23 @@
1
+ import type { PluginConfig, TeamState } from "../types.js";
2
+
3
+ export function hasOnDemandWorkerProvisioning(
4
+ config: Pick<PluginConfig, "workerProvisioningType">,
5
+ ): boolean {
6
+ return config.workerProvisioningType !== "none";
7
+ }
8
+
9
+ export function shouldBlockControllerWithoutWorkers(
10
+ config: Pick<PluginConfig, "workerProvisioningType">,
11
+ state: TeamState | null,
12
+ ): boolean {
13
+ return !!state && Object.keys(state.workers).length === 0 && !hasOnDemandWorkerProvisioning(config);
14
+ }
15
+
16
+ export function buildControllerNoWorkersMessage(): string {
17
+ return [
18
+ "No TeamClaw workers are registered and on-demand provisioning is disabled.",
19
+ "You may analyze the requirement and identify the roles that would be needed,",
20
+ "but do not create TeamClaw tasks and do not do the worker-role work yourself.",
21
+ "Ask the human to bring workers online or enable process/docker/kubernetes provisioning first.",
22
+ ].join(" ");
23
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../api.js";
2
+ import os from "node:os";
2
3
  import type { PluginConfig, TeamState } from "../types.js";
3
4
  import { loadTeamState, saveTeamState } from "../state.js";
4
5
  import { MDnsAdvertiser } from "../discovery.js";
@@ -17,8 +18,27 @@ export type ControllerServiceDeps = {
17
18
  logger: PluginLogger;
18
19
  runtime: OpenClawPluginApi["runtime"];
19
20
  localWorkerManager?: LocalWorkerManager;
21
+ onTeamStateAvailable?: (getter: () => TeamState | null) => void;
20
22
  };
21
23
 
24
+ function getPreferredLanUiUrl(port: number): string | null {
25
+ const candidates: string[] = [];
26
+ const interfaces = os.networkInterfaces();
27
+ for (const records of Object.values(interfaces)) {
28
+ for (const record of records ?? []) {
29
+ if (!record || record.internal || record.family !== "IPv4") {
30
+ continue;
31
+ }
32
+ candidates.push(record.address);
33
+ }
34
+ }
35
+ candidates.sort((left, right) => left.localeCompare(right));
36
+ if (candidates.length === 0) {
37
+ return null;
38
+ }
39
+ return `http://${candidates[0]}:${port}/ui`;
40
+ }
41
+
22
42
  export function createControllerService(deps: ControllerServiceDeps): OpenClawPluginService {
23
43
  const { config, logger, localWorkerManager } = deps;
24
44
  let teamState: TeamState | null = null;
@@ -61,6 +81,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
61
81
  repoStateChanged = JSON.stringify(teamState.repo ?? null) !== previousRepoState;
62
82
  logger.info(`Controller: restored team "${config.teamName}" with ${Object.keys(teamState.workers).length} workers`);
63
83
  }
84
+ deps.onTeamStateAvailable?.(() => teamState);
64
85
 
65
86
  const updateState = (updater: (state: TeamState) => void): TeamState => {
66
87
  updater(teamState!);
@@ -108,7 +129,11 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
108
129
  await new Promise<void>((resolve, reject) => {
109
130
  server.listen(config.port, () => {
110
131
  logger.info(`Controller: HTTP server listening on port ${config.port}`);
111
- logger.info(`Controller: Web UI available at http://localhost:${config.port}/ui`);
132
+ logger.info(`Controller: Web UI available at http://127.0.0.1:${config.port}/ui`);
133
+ const lanUiUrl = getPreferredLanUiUrl(config.port);
134
+ if (lanUiUrl) {
135
+ logger.info(`Controller: Web UI available on LAN at ${lanUiUrl}`);
136
+ }
112
137
  resolve();
113
138
  });
114
139
  server.on("error", reject);
@@ -180,6 +205,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
180
205
  }
181
206
  },
182
207
  async stop() {
208
+ deps.onTeamStateAvailable?.(() => null);
183
209
  if (timeoutTimer) {
184
210
  clearInterval(timeoutTimer);
185
211
  timeoutTimer = null;
@@ -1,24 +1,51 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import type { PluginConfig, TaskInfo, TeamState } from "../types.js";
2
+ import type {
3
+ ControllerOrchestrationManifest,
4
+ PluginConfig,
5
+ TaskInfo,
6
+ TeamState,
7
+ } from "../types.js";
8
+ import { buildControllerNoWorkersMessage, hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
9
+ import {
10
+ normalizeManifestCreatedTasks,
11
+ normalizeManifestDeferredTasks,
12
+ normalizeManifestRoleList,
13
+ normalizeManifestStringList,
14
+ normalizeOptionalManifestText,
15
+ } from "./orchestration-manifest.js";
16
+ import {
17
+ ensureTeamMessageContract,
18
+ normalizeContractRole,
19
+ normalizeContractStringList,
20
+ } from "../interaction-contracts.js";
3
21
 
4
22
  export type ControllerToolsDeps = {
5
23
  config: PluginConfig;
6
24
  controllerUrl: string;
7
25
  getTeamState: () => TeamState | null;
26
+ sessionKey?: string | null;
8
27
  };
9
28
 
10
29
  const EXECUTION_READY_BLOCKERS: Array<{ pattern: RegExp; reason: string }> = [
11
30
  { pattern: /\bdepends?\s+on\b/i, reason: "it explicitly depends on other unfinished work" },
12
31
  { pattern: /\bprerequisite\b/i, reason: "it references a prerequisite that may not be satisfied yet" },
13
32
  { pattern: /\bwait(?:ing)?\s+for\b/i, reason: "it says the work should wait for another output first" },
14
- { pattern: /\bafter\b.+\b(complete|completed|ready|available|exists?)\b/i, reason: "it is phrased as a later-phase task" },
15
- { pattern: /\bonce\b.+\b(complete|completed|ready|available|exists?)\b/i, reason: "it is phrased as a later-phase task" },
16
- { pattern: /依赖|前置|前提/u, reason: "it explicitly mentions a predecessor dependency" },
17
- { pattern: /完成后|就绪后|待.*完成|等待.*完成/u, reason: "it is described as work for a later phase" },
33
+ { pattern: /依赖于|前置条件|前置依赖|前提条件|前序任务|上游任务/u, reason: "it explicitly mentions a predecessor dependency" },
34
+ { pattern: /待.*完成|等待.*完成/u, reason: "it is described as work for a later phase" },
18
35
  ];
19
36
 
37
+ const ENGLISH_LATER_PHASE_CLAUSE_RE = /\b(?:after|once)\b(.+?)\b(complete|completed|ready|available|exists?)\b/i;
38
+ const ENGLISH_LATER_PHASE_DEPENDENCY_RE = /\b(?:task|tasks|service|services|module|modules|phase|phases|api|apis|interface|interfaces|review|qa|design|developer|architect|skeleton|backend|frontend|deliverable|artifact|handoff)\b/i;
39
+ const CHINESE_LATER_PHASE_CLAUSE_RE = /(.+?)(完成后|就绪后)/u;
40
+ const CHINESE_LATER_PHASE_DEPENDENCY_RE = /(?:服务|模块|任务|阶段|接口|骨架|后端|前端|设计|审查|开发|测试|架构|交付物|文档|交接)/u;
41
+ const SERVICE_NAME_RE = /\b[a-z0-9_-]+-service\b/i;
42
+ const SERVICE_NAME_GLOBAL_RE = /\b[a-z0-9_-]+-service\b/gi;
43
+ const ACTIVE_TASK_STATUSES = new Set(["pending", "assigned", "in_progress", "review", "blocked"]);
44
+ const REPO_WIDE_CODE_CHANGE_RE = /(financial-erp-backend\/|all services|all microservices|all backend services|所有服务|所有微服务|全部微服务|统一.*kafka|kafka topic|db\/migration|schema|ddl|api路径|trusted\.packages|pom\.xml|webmvcconfig|安全响应头)/i;
45
+ const DOC_ONLY_SCOPE_RE = /(文档|docs\/|api design|architecture document|设计文档|报告|report)/i;
46
+
20
47
  export function createControllerTools(deps: ControllerToolsDeps) {
21
- const { config, controllerUrl, getTeamState } = deps;
48
+ const { config, controllerUrl, getTeamState, sessionKey } = deps;
22
49
  const baseUrl = controllerUrl;
23
50
 
24
51
  return [
@@ -42,10 +69,21 @@ export function createControllerTools(deps: ControllerToolsDeps) {
42
69
  async execute(_id: string, params: Record<string, unknown>) {
43
70
  const title = String(params.title ?? "");
44
71
  const description = String(params.description ?? "");
72
+ const normalizedSessionKey = typeof sessionKey === "string" ? sessionKey.trim() : "";
45
73
  if (!title) {
46
74
  return { content: [{ type: "text" as const, text: "title is required." }] };
47
75
  }
48
76
 
77
+ const state = getTeamState();
78
+ if (shouldBlockControllerWithoutWorkers(config, state)) {
79
+ return {
80
+ content: [{
81
+ type: "text" as const,
82
+ text: `${buildControllerNoWorkersMessage()} Stop after reporting this block to the human.`,
83
+ }],
84
+ };
85
+ }
86
+
49
87
  const blocker = detectExecutionReadyBlocker(description);
50
88
  if (blocker) {
51
89
  return {
@@ -55,6 +93,15 @@ export function createControllerTools(deps: ControllerToolsDeps) {
55
93
  }],
56
94
  };
57
95
  }
96
+ const overlapBlocker = detectActiveTaskOverlap(title, description, state);
97
+ if (overlapBlocker) {
98
+ return {
99
+ content: [{
100
+ type: "text" as const,
101
+ text: `Refusing to create task "${title}" because it is not execution-ready: ${overlapBlocker}. Wait for the active task to finish or narrow the new task so it does not edit the same service scope in parallel.`,
102
+ }],
103
+ };
104
+ }
58
105
 
59
106
  try {
60
107
  const res = await fetch(`${baseUrl}/api/v1/tasks`, {
@@ -67,6 +114,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
67
114
  assignedRole: params.assignedRole ?? undefined,
68
115
  recommendedSkills: Array.isArray(params.recommendedSkills) ? params.recommendedSkills : undefined,
69
116
  createdBy: "controller",
117
+ controllerSessionKey: normalizedSessionKey || undefined,
70
118
  }),
71
119
  });
72
120
 
@@ -80,7 +128,9 @@ export function createControllerTools(deps: ControllerToolsDeps) {
80
128
  const assigned = task.assignedWorkerId
81
129
  ? ` -> assigned to ${task.assignedWorkerId}`
82
130
  : task.status === "pending"
83
- ? " (pending - no available worker)"
131
+ ? hasOnDemandWorkerProvisioning(config)
132
+ ? " (pending - waiting for worker provisioning or an available worker)"
133
+ : " (pending - no registered/available worker)"
84
134
  : "";
85
135
  const recommended = Array.isArray(task.recommendedSkills) && task.recommendedSkills.length > 0
86
136
  ? ` | skills: ${task.recommendedSkills.join(", ")}`
@@ -97,6 +147,97 @@ export function createControllerTools(deps: ControllerToolsDeps) {
97
147
  }
98
148
  },
99
149
  },
150
+ {
151
+ name: "teamclaw_submit_manifest",
152
+ label: "Submit Controller Manifest",
153
+ description: "Record the structured orchestration manifest for this intake run after role selection and task creation decisions are complete",
154
+ parameters: Type.Object({
155
+ requirementSummary: Type.String({ description: "Brief summary of the requirement the controller is orchestrating" }),
156
+ requiredRoles: Type.Array(
157
+ Type.String({
158
+ description: "Exact TeamClaw role IDs required for this requirement",
159
+ }),
160
+ ),
161
+ clarificationsNeeded: Type.Optional(Type.Boolean({ description: "Whether the controller still needs human clarification" })),
162
+ clarificationQuestions: Type.Optional(
163
+ Type.Array(Type.String({ description: "Concrete clarification questions still waiting on the human" })),
164
+ ),
165
+ createdTasks: Type.Optional(
166
+ Type.Array(
167
+ Type.Object({
168
+ title: Type.String({ description: "Title of an execution-ready task this controller run created or deliberately reused instead of duplicating" }),
169
+ assignedRole: Type.Optional(Type.String({ description: "Exact TeamClaw role ID for the created task" })),
170
+ expectedOutcome: Type.String({ description: "Expected deliverable/result for the created task" }),
171
+ }),
172
+ ),
173
+ ),
174
+ deferredTasks: Type.Optional(
175
+ Type.Array(
176
+ Type.Object({
177
+ title: Type.String({ description: "Title of a task that should wait for later" }),
178
+ assignedRole: Type.Optional(Type.String({ description: "Exact TeamClaw role ID for the deferred task" })),
179
+ blockedBy: Type.String({ description: "Why this deferred task cannot be created yet" }),
180
+ whenReady: Type.String({ description: "Condition that should become true before this deferred task is created" }),
181
+ }),
182
+ ),
183
+ ),
184
+ handoffPlan: Type.Optional(Type.String({ description: "Brief note about how workers should report progress/handoffs across this flow" })),
185
+ notes: Type.Optional(Type.String({ description: "Additional orchestration notes for the human/controller log" })),
186
+ }),
187
+ async execute(_id: string, params: Record<string, unknown>) {
188
+ const normalizedSessionKey = typeof sessionKey === "string" ? sessionKey.trim() : "";
189
+ if (!normalizedSessionKey) {
190
+ return {
191
+ content: [{
192
+ type: "text" as const,
193
+ text: "Cannot record controller manifest because the current TeamClaw controller session key is missing.",
194
+ }],
195
+ };
196
+ }
197
+
198
+ const requirementSummary = String(params.requirementSummary ?? "").trim();
199
+ if (!requirementSummary) {
200
+ return { content: [{ type: "text" as const, text: "requirementSummary is required." }] };
201
+ }
202
+
203
+ const manifest: ControllerOrchestrationManifest = {
204
+ version: "1.0",
205
+ requirementSummary,
206
+ requiredRoles: normalizeManifestRoleList(params.requiredRoles),
207
+ clarificationsNeeded: Boolean(params.clarificationsNeeded),
208
+ clarificationQuestions: normalizeManifestStringList(params.clarificationQuestions),
209
+ createdTasks: normalizeManifestCreatedTasks(params.createdTasks),
210
+ deferredTasks: normalizeManifestDeferredTasks(params.deferredTasks),
211
+ handoffPlan: normalizeOptionalManifestText(params.handoffPlan),
212
+ notes: normalizeOptionalManifestText(params.notes),
213
+ };
214
+
215
+ try {
216
+ const res = await fetch(`${baseUrl}/api/v1/controller/manifest`, {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify({
220
+ sessionKey: normalizedSessionKey,
221
+ manifest,
222
+ }),
223
+ });
224
+
225
+ if (!res.ok) {
226
+ const err = await res.text();
227
+ return { content: [{ type: "text" as const, text: `Failed to record controller manifest: ${err}` }] };
228
+ }
229
+
230
+ return {
231
+ content: [{
232
+ type: "text" as const,
233
+ text: `Controller manifest recorded: roles=${manifest.requiredRoles.join(", ") || "none"} created=${manifest.createdTasks.length} deferred=${manifest.deferredTasks.length}`,
234
+ }],
235
+ };
236
+ } catch (err) {
237
+ return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
238
+ }
239
+ },
240
+ },
100
241
  {
101
242
  name: "teamclaw_list_tasks",
102
243
  label: "List Team Tasks",
@@ -177,6 +318,11 @@ export function createControllerTools(deps: ControllerToolsDeps) {
177
318
  content: Type.String({ description: "Message content" }),
178
319
  toRole: Type.Optional(Type.String({ description: "Target role for direct message (omit for broadcast)" })),
179
320
  taskId: Type.Optional(Type.String({ description: "Related task ID" })),
321
+ summary: Type.Optional(Type.String({ description: "Short structured summary for this coordination message" })),
322
+ details: Type.Optional(Type.String({ description: "Optional extra context for the receiving worker(s)" })),
323
+ requestedAction: Type.Optional(Type.String({ description: "Concrete action expected after reading the message" })),
324
+ needsResponse: Type.Optional(Type.Boolean({ description: "Whether this message expects a direct response" })),
325
+ references: Type.Optional(Type.Array(Type.String({ description: "Relevant task IDs, files, or artifacts" }))),
180
326
  }),
181
327
  async execute(_id: string, params: Record<string, unknown>) {
182
328
  const content = String(params.content ?? "");
@@ -185,14 +331,28 @@ export function createControllerTools(deps: ControllerToolsDeps) {
185
331
  }
186
332
 
187
333
  try {
334
+ const normalizedTargetRole = normalizeContractRole(params.toRole);
188
335
  const endpoint = params.toRole
189
336
  ? `${baseUrl}/api/v1/messages/direct`
190
337
  : `${baseUrl}/api/v1/messages/broadcast`;
338
+ const contract = ensureTeamMessageContract(null, {
339
+ type: params.toRole ? "direct" : "broadcast",
340
+ content,
341
+ toRole: normalizedTargetRole,
342
+ taskId: typeof params.taskId === "string" ? params.taskId : undefined,
343
+ summary: typeof params.summary === "string" ? params.summary : undefined,
344
+ details: typeof params.details === "string" ? params.details : undefined,
345
+ requestedAction: typeof params.requestedAction === "string" ? params.requestedAction : undefined,
346
+ needsResponse: typeof params.needsResponse === "boolean" ? params.needsResponse : undefined,
347
+ references: normalizeContractStringList(params.references),
348
+ intent: params.toRole ? undefined : "announcement",
349
+ });
191
350
 
192
351
  const body: Record<string, unknown> = {
193
352
  from: "controller",
194
353
  content,
195
354
  taskId: params.taskId ?? null,
355
+ contract,
196
356
  };
197
357
  if (params.toRole) body.toRole = params.toRole;
198
358
 
@@ -231,5 +391,89 @@ function detectExecutionReadyBlocker(description: string): string | null {
231
391
  }
232
392
  }
233
393
 
394
+ const laterPhaseBlocker = detectLaterPhasePhrase(text);
395
+ if (laterPhaseBlocker) {
396
+ return laterPhaseBlocker;
397
+ }
398
+
234
399
  return null;
235
400
  }
401
+
402
+ function detectLaterPhasePhrase(text: string): string | null {
403
+ const lines = text
404
+ .split(/\r?\n/)
405
+ .map((line) => line.trim())
406
+ .filter(Boolean);
407
+
408
+ for (const line of lines) {
409
+ const englishMatch = line.match(ENGLISH_LATER_PHASE_CLAUSE_RE);
410
+ if (englishMatch) {
411
+ const dependencyClause = englishMatch[1] ?? "";
412
+ if (ENGLISH_LATER_PHASE_DEPENDENCY_RE.test(dependencyClause) || SERVICE_NAME_RE.test(dependencyClause)) {
413
+ return "it is described as work for a later phase";
414
+ }
415
+ }
416
+
417
+ const chineseMatch = line.match(CHINESE_LATER_PHASE_CLAUSE_RE);
418
+ if (chineseMatch) {
419
+ const dependencyClause = chineseMatch[1] ?? "";
420
+ if (CHINESE_LATER_PHASE_DEPENDENCY_RE.test(dependencyClause) || SERVICE_NAME_RE.test(dependencyClause)) {
421
+ return "it is described as work for a later phase";
422
+ }
423
+ }
424
+ }
425
+
426
+ return null;
427
+ }
428
+
429
+ function detectActiveTaskOverlap(title: string, description: string, state: TeamState | null): string | null {
430
+ if (!state) {
431
+ return null;
432
+ }
433
+ const scopeText = `${title}\n${description}`;
434
+ const serviceNames = extractServiceNames(scopeText);
435
+ const repoWideCodeChange = serviceNames.size === 0
436
+ && REPO_WIDE_CODE_CHANGE_RE.test(scopeText)
437
+ && !DOC_ONLY_SCOPE_RE.test(scopeText);
438
+ if (serviceNames.size === 0 && !repoWideCodeChange) {
439
+ return null;
440
+ }
441
+
442
+ const overlappingTasks = Object.values(state.tasks).filter((task) => {
443
+ if (!ACTIVE_TASK_STATUSES.has(task.status)) {
444
+ return false;
445
+ }
446
+ const taskScope = `${task.title}\n${task.description}`;
447
+ const taskServiceNames = extractServiceNames(taskScope);
448
+ if (serviceNames.size > 0) {
449
+ return [...serviceNames].some((serviceName) => taskServiceNames.has(serviceName));
450
+ }
451
+ if (taskServiceNames.size > 0) {
452
+ return true;
453
+ }
454
+ return REPO_WIDE_CODE_CHANGE_RE.test(taskScope) && !DOC_ONLY_SCOPE_RE.test(taskScope);
455
+ });
456
+ if (overlappingTasks.length === 0) {
457
+ return null;
458
+ }
459
+
460
+ const overlappingServices = new Set<string>();
461
+ for (const task of overlappingTasks) {
462
+ for (const serviceName of extractServiceNames(`${task.title}\n${task.description}`)) {
463
+ overlappingServices.add(serviceName);
464
+ }
465
+ }
466
+ if (overlappingServices.size > 0) {
467
+ return `it overlaps with active TeamClaw work on ${[...overlappingServices].slice(0, 3).join(", ")}`;
468
+ }
469
+ return `it overlaps with active TeamClaw repo-wide code changes (${overlappingTasks[0]?.title ?? "another active task"})`;
470
+ }
471
+
472
+ function extractServiceNames(text: string): Set<string> {
473
+ const normalized = String(text || "");
474
+ return new Set(
475
+ Array.from(normalized.matchAll(SERVICE_NAME_GLOBAL_RE))
476
+ .map((match) => String(match[0] || "").trim().toLowerCase())
477
+ .filter(Boolean),
478
+ );
479
+ }