@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/README.md +6 -0
- package/cli.mjs +519 -28
- package/index.ts +31 -16
- package/openclaw.plugin.json +3 -3
- package/package.json +16 -4
- package/src/config.ts +2 -2
- package/src/controller/controller-capacity.ts +23 -0
- package/src/controller/controller-service.ts +27 -1
- package/src/controller/controller-tools.ts +251 -7
- package/src/controller/http-server.ts +976 -38
- package/src/controller/orchestration-manifest.ts +105 -0
- package/src/controller/prompt-injector.ts +42 -7
- package/src/controller/worker-provisioning.ts +171 -13
- package/src/git-collaboration.ts +2 -0
- package/src/interaction-contracts.ts +459 -0
- package/src/task-executor.ts +482 -33
- package/src/types.ts +96 -0
- package/src/ui/app.js +313 -8
- package/src/ui/style.css +152 -0
- package/src/worker/http-handler.ts +15 -7
- package/src/worker/prompt-injector.ts +2 -0
- package/src/worker/skill-installer.ts +13 -0
- package/src/worker/tools.ts +172 -3
- package/src/worker/worker-service.ts +9 -6
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({
|
|
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:
|
|
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 = (
|
|
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: (
|
|
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 || !
|
|
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,
|
|
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
|
-
|
|
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,
|
|
228
|
+
const publishResult = await publishWorkerRepo(config, logger, controllerUrl, assignment.repo, {
|
|
214
229
|
taskId: assignment.taskId,
|
|
215
|
-
workerId
|
|
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
|
|
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 ${
|
|
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 ${
|
|
260
|
+
logger.warn(`Worker: failed to cancel session ${resolvedSessionKey} for task ${taskId}: ${String(err)}`);
|
|
246
261
|
return false;
|
|
247
262
|
}
|
|
248
263
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": "
|
|
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.
|
|
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: "
|
|
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: "
|
|
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://
|
|
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 {
|
|
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:
|
|
15
|
-
{ pattern:
|
|
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
|
-
?
|
|
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
|
+
}
|