@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-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.
Files changed (46) hide show
  1. package/README.md +52 -8
  2. package/cli.mjs +538 -224
  3. package/index.ts +76 -27
  4. package/openclaw.plugin.json +53 -28
  5. package/package.json +5 -2
  6. package/skills/teamclaw/SKILL.md +213 -0
  7. package/skills/teamclaw/references/api-quick-ref.md +117 -0
  8. package/skills/teamclaw-setup/SKILL.md +81 -0
  9. package/skills/teamclaw-setup/references/install-modes.md +136 -0
  10. package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
  11. package/src/config.ts +44 -16
  12. package/src/controller/controller-capacity.ts +2 -2
  13. package/src/controller/controller-service.ts +193 -47
  14. package/src/controller/controller-tools.ts +102 -2
  15. package/src/controller/delivery-report.ts +563 -0
  16. package/src/controller/http-server.ts +1907 -172
  17. package/src/controller/kickoff-orchestrator.ts +292 -0
  18. package/src/controller/managed-gateway-process.ts +330 -0
  19. package/src/controller/orchestration-manifest.ts +69 -1
  20. package/src/controller/preview-manager.ts +676 -0
  21. package/src/controller/prompt-injector.ts +116 -67
  22. package/src/controller/role-inference.ts +41 -0
  23. package/src/controller/websocket.ts +3 -1
  24. package/src/controller/worker-provisioning.ts +429 -74
  25. package/src/discovery.ts +1 -1
  26. package/src/git-collaboration.ts +198 -47
  27. package/src/identity.ts +12 -2
  28. package/src/interaction-contracts.ts +179 -3
  29. package/src/networking.ts +99 -0
  30. package/src/openclaw-workspace.ts +478 -11
  31. package/src/prompt-policy.ts +381 -0
  32. package/src/roles.ts +37 -36
  33. package/src/state.ts +40 -1
  34. package/src/task-executor.ts +282 -78
  35. package/src/types.ts +150 -7
  36. package/src/ui/app.js +1403 -175
  37. package/src/ui/assets/teamclaw-app-icon.png +0 -0
  38. package/src/ui/index.html +122 -40
  39. package/src/ui/style.css +829 -143
  40. package/src/worker/http-handler.ts +40 -4
  41. package/src/worker/prompt-injector.ts +9 -38
  42. package/src/worker/skill-installer.ts +2 -2
  43. package/src/worker/tools.ts +31 -5
  44. package/src/worker/worker-service.ts +49 -8
  45. package/src/workspace-browser.ts +20 -7
  46. package/src/controller/local-worker-manager.ts +0 -533
@@ -1,46 +1,48 @@
1
1
  import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../api.js";
2
- import os from "node:os";
3
- import type { PluginConfig, TeamState } from "../types.js";
2
+ import fs from "node:fs";
3
+ import { exec } from "node:child_process";
4
+ import type { KickoffAssessment, PluginConfig, RoleId, TeamState } from "../types.js";
4
5
  import { loadTeamState, saveTeamState } from "../state.js";
5
6
  import { MDnsAdvertiser } from "../discovery.js";
6
7
  import { WORKER_TIMEOUT_MS } from "../protocol.js";
7
8
  import { createControllerHttpServer } from "./http-server.js";
8
- import type { LocalWorkerManager } from "./local-worker-manager.js";
9
9
  import { TaskRouter } from "./task-router.js";
10
10
  import { MessageRouter } from "./message-router.js";
11
11
  import { TeamWebSocketServer } from "./websocket.js";
12
12
  import { ensureOpenClawWorkspaceMemoryDir } from "../openclaw-workspace.js";
13
13
  import { ensureControllerGitRepo } from "../git-collaboration.js";
14
14
  import { WorkerProvisioningManager } from "./worker-provisioning.js";
15
+ import { PreviewManager } from "./preview-manager.js";
16
+ import { runKickoffMeeting, buildKickoffAssessmentPrompt, ASSESSMENT_TIMEOUT_MS } from "./kickoff-orchestrator.js";
17
+ import { resolvePreferredLanAddress } from "../networking.js";
18
+
19
+ export type KickoffHandler = (
20
+ candidateRoles: RoleId[],
21
+ complexity: "simple" | "medium" | "complex",
22
+ requirement: string,
23
+ ) => Promise<{ assessments: KickoffAssessment[]; summary: string }>;
15
24
 
16
25
  export type ControllerServiceDeps = {
17
26
  config: PluginConfig;
18
27
  logger: PluginLogger;
19
28
  runtime: OpenClawPluginApi["runtime"];
20
- localWorkerManager?: LocalWorkerManager;
21
29
  onTeamStateAvailable?: (getter: () => TeamState | null) => void;
30
+ /** Called once the HTTP server has bound to an actual port. */
31
+ onActualPort?: (port: number) => void;
32
+ /** Called once the kickoff handler is ready. */
33
+ onKickoffHandlerAvailable?: (handler: KickoffHandler) => void;
22
34
  };
23
35
 
24
36
  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
+ const preferredLanAddress = resolvePreferredLanAddress();
38
+ if (!preferredLanAddress) {
37
39
  return null;
38
40
  }
39
- return `http://${candidates[0]}:${port}/ui`;
41
+ return `http://${preferredLanAddress}:${port}/ui`;
40
42
  }
41
43
 
42
44
  export function createControllerService(deps: ControllerServiceDeps): OpenClawPluginService {
43
- const { config, logger, localWorkerManager } = deps;
45
+ const { config, logger } = deps;
44
46
  let teamState: TeamState | null = null;
45
47
  let mdnsAdvertiser: MDnsAdvertiser;
46
48
  let taskRouter: TaskRouter;
@@ -48,6 +50,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
48
50
  let wsServer: TeamWebSocketServer;
49
51
  let timeoutTimer: ReturnType<typeof setInterval> | null = null;
50
52
  let workerProvisioningManager: WorkerProvisioningManager | null = null;
53
+ let previewManager: PreviewManager;
51
54
 
52
55
  return {
53
56
  id: "teamclaw-controller",
@@ -95,24 +98,62 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
95
98
  getTeamState: () => teamState,
96
99
  updateTeamState: updateState,
97
100
  });
101
+ if (workerProvisioningManager.isEnabled()) {
102
+ workerProvisioningManager.primeStartupReadiness();
103
+ }
104
+
105
+ previewManager = new PreviewManager({
106
+ logger,
107
+ getTeamState: () => teamState,
108
+ updateTeamState: updateState,
109
+ });
98
110
 
99
- if (
100
- repoStateChanged ||
101
- localWorkerManager?.syncState(teamState) ||
102
- workerProvisioningManager.syncState(teamState)
103
- ) {
111
+ // Run ALL syncState calls (avoid || short-circuit skipping some).
112
+ const syncC = workerProvisioningManager.syncState(teamState);
113
+ if (repoStateChanged || syncC) {
104
114
  await saveTeamState(teamState);
105
115
  }
106
116
 
117
+ // Clean up orphaned tasks — tasks assigned to workers that no longer
118
+ // exist OR that are offline (stale entries surviving from a previous run).
119
+ {
120
+ let orphanCleaned = false;
121
+ for (const task of Object.values(teamState.tasks)) {
122
+ if (
123
+ task.assignedWorkerId &&
124
+ (task.status === "assigned" || task.status === "in_progress")
125
+ ) {
126
+ const worker = teamState.workers[task.assignedWorkerId];
127
+ if (!worker || worker.status === "offline") {
128
+ logger.info(`Controller: resetting orphaned task ${task.id} (worker ${task.assignedWorkerId} ${worker ? "offline" : "missing"})`);
129
+ task.status = "pending";
130
+ task.assignedWorkerId = undefined;
131
+ task.updatedAt = Date.now();
132
+ orphanCleaned = true;
133
+ }
134
+ }
135
+ }
136
+ if (orphanCleaned) {
137
+ await saveTeamState(teamState);
138
+ }
139
+ }
140
+
107
141
  mdnsAdvertiser = new MDnsAdvertiser(logger);
108
142
  taskRouter = new TaskRouter(logger);
109
143
  messageRouter = new MessageRouter(logger);
110
144
  wsServer = new TeamWebSocketServer(logger);
111
145
 
112
- // Start mDNS advertising
113
- await mdnsAdvertiser.start(config.port, config.teamName);
146
+ // When running inside a container (Docker or K8s), bind to 0.0.0.0
147
+ // so that port mapping, service networking, and health probes work.
148
+ // When running locally (host machine), bind to 127.0.0.1 for safety.
149
+ const isContainer = fs.existsSync("/.dockerenv") ||
150
+ fs.existsSync("/run/.containerenv") ||
151
+ process.env.KUBERNETES_SERVICE_HOST !== undefined;
152
+ const listenPort = config.port;
153
+ const listenHost = isContainer ? "0.0.0.0" : "127.0.0.1";
154
+
155
+ let serviceKickoffHandler: KickoffHandler | undefined;
114
156
 
115
- // Start HTTP server
116
157
  const server = createControllerHttpServer({
117
158
  config,
118
159
  logger,
@@ -122,31 +163,90 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
122
163
  taskRouter,
123
164
  messageRouter,
124
165
  wsServer,
125
- localWorkerManager,
126
166
  workerProvisioningManager,
167
+ previewManager,
168
+ getKickoffHandler: () => serviceKickoffHandler,
127
169
  });
128
170
 
171
+ const PORT_RETRY_STEP = 10;
172
+ const PORT_MAX_RETRIES = 10;
173
+
174
+ let actualPort = 0;
129
175
  await new Promise<void>((resolve, reject) => {
130
- server.listen(config.port, () => {
131
- logger.info(`Controller: HTTP server listening on port ${config.port}`);
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
- }
137
- resolve();
138
- });
139
- server.on("error", reject);
176
+ let attempt = 0;
177
+ const tryListen = (port: number) => {
178
+ server.once("error", (err: NodeJS.ErrnoException) => {
179
+ if (err.code === "EADDRINUSE" && attempt < PORT_MAX_RETRIES) {
180
+ attempt++;
181
+ const nextPort = config.port + attempt * PORT_RETRY_STEP;
182
+ logger.warn(`Controller: port ${port} in use, retrying on ${nextPort}`);
183
+ tryListen(nextPort);
184
+ } else {
185
+ reject(err);
186
+ }
187
+ });
188
+ server.listen(port, listenHost, () => {
189
+ const addr = server.address();
190
+ actualPort = typeof addr === "object" && addr ? addr.port : 0;
191
+ if (actualPort !== config.port) {
192
+ logger.info(`Controller: configured port ${config.port} unavailable, bound to ${actualPort} instead`);
193
+ }
194
+ logger.info(`Controller: HTTP server listening on port ${actualPort}`);
195
+ const uiUrl = `http://127.0.0.1:${actualPort}/ui`;
196
+ logger.info(`Controller: Web UI available at ${uiUrl}`);
197
+ const lanUiUrl = getPreferredLanUiUrl(actualPort);
198
+ if (lanUiUrl) {
199
+ logger.info(`Controller: Web UI available on LAN at ${lanUiUrl}`);
200
+ }
201
+ deps.onActualPort?.(actualPort);
202
+ openBrowser(uiUrl, logger);
203
+ resolve();
204
+ });
205
+ };
206
+ tryListen(listenPort);
140
207
  });
141
208
 
142
- if (localWorkerManager?.hasLocalWorkers()) {
143
- await localWorkerManager.start();
209
+ // Propagate the actual port to worker provisioning (created earlier, before port was known)
210
+ if (workerProvisioningManager.isEnabled()) {
211
+ workerProvisioningManager.setActualPort(actualPort);
144
212
  }
145
213
 
214
+ // Start mDNS advertising with the actual port
215
+ await mdnsAdvertiser.start(actualPort, config.teamName);
216
+
146
217
  if (workerProvisioningManager.isEnabled()) {
147
- void workerProvisioningManager.requestReconcile("controller startup");
218
+ void workerProvisioningManager.runStartupReadinessCheck();
148
219
  }
149
220
 
221
+ // ── Kickoff handler ───────────────────────────────────────────────
222
+ const kickoffHandler: KickoffHandler = async (candidateRoles, complexity, requirement) => {
223
+ const result = await runKickoffMeeting(
224
+ { requirement, candidateRoles, complexity },
225
+ {
226
+ logger,
227
+ getTeamState: () => teamState,
228
+ ensureRoleProvisioned: async (role) => {
229
+ if (workerProvisioningManager?.isEnabled()) {
230
+ await workerProvisioningManager.requestReconcile(`kickoff-provision-${role}`);
231
+ }
232
+ },
233
+ requestWorkerAssessment: async (worker, req) => {
234
+ return await requestKickoffAssessment(worker, req);
235
+ },
236
+ },
237
+ );
238
+ return { assessments: result.plan.assessments, summary: result.summary };
239
+ };
240
+ serviceKickoffHandler = kickoffHandler;
241
+ deps.onKickoffHandlerAvailable?.(kickoffHandler);
242
+
243
+ logger.info(`Controller: starting preview restoration...`);
244
+ void previewManager.restorePreviewsOnStartup().then(() => {
245
+ logger.info(`Controller: preview restoration completed`);
246
+ }).catch((err) => {
247
+ logger.warn(`Controller: failed to restore previews on startup: ${String(err)}`);
248
+ });
249
+
150
250
  // Start timeout monitoring
151
251
  timeoutTimer = setInterval(() => {
152
252
  if (!teamState) return;
@@ -156,10 +256,6 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
156
256
 
157
257
  for (const [workerId, worker] of Object.entries(teamState.workers)) {
158
258
  if (worker.status === "offline") continue;
159
- if (localWorkerManager?.isLocalWorker(worker)) {
160
- worker.lastHeartbeat = now;
161
- continue;
162
- }
163
259
  if (now - worker.lastHeartbeat > WORKER_TIMEOUT_MS) {
164
260
  logger.info(`Controller: worker ${workerId} timed out`);
165
261
  const activeTaskId = worker.currentTaskId;
@@ -210,15 +306,65 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
210
306
  clearInterval(timeoutTimer);
211
307
  timeoutTimer = null;
212
308
  }
213
- if (localWorkerManager?.hasLocalWorkers()) {
214
- await localWorkerManager.stop();
215
- }
216
309
  if (workerProvisioningManager) {
217
310
  await workerProvisioningManager.stop();
218
311
  }
312
+ await previewManager.stopAll();
219
313
  wsServer.close();
220
314
  mdnsAdvertiser.stop();
221
315
  logger.info("Controller: stopped");
222
316
  },
223
317
  };
224
318
  }
319
+
320
+ function openBrowser(url: string, logger: PluginLogger): void {
321
+ const cmd = process.platform === "darwin"
322
+ ? `open "${url}"`
323
+ : process.platform === "win32"
324
+ ? `start "" "${url}"`
325
+ : `xdg-open "${url}"`;
326
+ exec(cmd, (err) => {
327
+ if (err) {
328
+ logger.warn(`Controller: failed to open browser: ${err.message}`);
329
+ }
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Request a kickoff assessment from a worker.
335
+ *
336
+ * Request kickoff assessment from a worker over HTTP.
337
+ */
338
+ async function requestKickoffAssessment(
339
+ worker: import("../types.js").WorkerInfo,
340
+ requirement: string,
341
+ ): Promise<import("../types.js").KickoffAssessment> {
342
+ const role = worker.role;
343
+ const prompt = buildKickoffAssessmentPrompt(role, requirement);
344
+
345
+ // External worker — POST to kickoff assess endpoint
346
+ if (!worker.url) {
347
+ throw new Error(`Worker ${worker.id} has no URL for kickoff assessment`);
348
+ }
349
+
350
+ const controller = new AbortController();
351
+ const timeout = setTimeout(() => controller.abort(), ASSESSMENT_TIMEOUT_MS);
352
+
353
+ try {
354
+ const res = await fetch(`${worker.url}/api/v1/kickoff/assess`, {
355
+ method: "POST",
356
+ headers: { "Content-Type": "application/json" },
357
+ body: JSON.stringify({ requirement, role }),
358
+ signal: controller.signal,
359
+ });
360
+
361
+ if (!res.ok) {
362
+ throw new Error(`Worker ${worker.id} returned ${res.status} for kickoff assessment`);
363
+ }
364
+
365
+ const data = await res.json() as { assessment: import("../types.js").KickoffAssessment };
366
+ return data.assessment;
367
+ } finally {
368
+ clearTimeout(timeout);
369
+ }
370
+ }
@@ -1,12 +1,15 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type {
3
3
  ControllerOrchestrationManifest,
4
+ KickoffAssessment,
4
5
  PluginConfig,
6
+ RoleId,
5
7
  TaskInfo,
6
8
  TeamState,
7
9
  } from "../types.js";
8
10
  import { buildControllerNoWorkersMessage, hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
9
11
  import {
12
+ normalizeClarificationQuestionSchemas,
10
13
  normalizeManifestCreatedTasks,
11
14
  normalizeManifestDeferredTasks,
12
15
  normalizeManifestRoleList,
@@ -24,6 +27,8 @@ export type ControllerToolsDeps = {
24
27
  controllerUrl: string;
25
28
  getTeamState: () => TeamState | null;
26
29
  sessionKey?: string | null;
30
+ /** Handler for kickoff meeting requests. Use a getter for late-binding (service may start after tool registration). */
31
+ getKickoffHandler?: () => ((candidateRoles: RoleId[], complexity: "simple" | "medium" | "complex", requirement: string) => Promise<{ assessments: KickoffAssessment[]; summary: string }>) | undefined;
27
32
  };
28
33
 
29
34
  const EXECUTION_READY_BLOCKERS: Array<{ pattern: RegExp; reason: string }> = [
@@ -34,6 +39,11 @@ const EXECUTION_READY_BLOCKERS: Array<{ pattern: RegExp; reason: string }> = [
34
39
  { pattern: /待.*完成|等待.*完成/u, reason: "it is described as work for a later phase" },
35
40
  ];
36
41
 
42
+ const VALID_ROLE_IDS = new Set([
43
+ "pm", "architect", "developer", "qa", "release-engineer",
44
+ "infra-engineer", "devops", "security-engineer", "designer", "marketing",
45
+ ]);
46
+
37
47
  const ENGLISH_LATER_PHASE_CLAUSE_RE = /\b(?:after|once)\b(.+?)\b(complete|completed|ready|available|exists?)\b/i;
38
48
  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
49
  const CHINESE_LATER_PHASE_CLAUSE_RE = /(.+?)(完成后|就绪后)/u;
@@ -49,6 +59,68 @@ export function createControllerTools(deps: ControllerToolsDeps) {
49
59
  const baseUrl = controllerUrl;
50
60
 
51
61
  return [
62
+ {
63
+ name: "teamclaw_request_kickoff",
64
+ label: "Request Team Kickoff Meeting",
65
+ description: "Provision candidate role workers and collect structured assessments from each before creating execution tasks. Use for medium/complex projects where multiple roles need to collaborate.",
66
+ parameters: Type.Object({
67
+ requirement: Type.String({ description: "The full user requirement to present to the team for assessment" }),
68
+ candidateRoles: Type.Array(
69
+ Type.String({ description: "Role IDs to invite to the kickoff meeting (e.g. architect, developer, qa)" }),
70
+ ),
71
+ complexity: Type.Union([
72
+ Type.Literal("simple"),
73
+ Type.Literal("medium"),
74
+ Type.Literal("complex"),
75
+ ], { description: "Project complexity: simple (skip kickoff, 1-2 roles), medium (partial kickoff, 2-3 roles), complex (full team kickoff, 4+ roles)" }),
76
+ }),
77
+ async execute(_id: string, params: Record<string, unknown>) {
78
+ const kickoffHandler = deps.getKickoffHandler?.();
79
+ if (!kickoffHandler) {
80
+ return {
81
+ content: [{
82
+ type: "text" as const,
83
+ text: "Kickoff meeting is not available in this deployment. Proceed with direct task creation.",
84
+ }],
85
+ };
86
+ }
87
+
88
+ const requirement = String(params.requirement ?? "").trim();
89
+ if (!requirement) {
90
+ return { content: [{ type: "text" as const, text: "requirement is required." }] };
91
+ }
92
+
93
+ const rawRoles = Array.isArray(params.candidateRoles) ? params.candidateRoles : [];
94
+ const candidateRoles = rawRoles
95
+ .map((r) => String(r ?? "").trim().toLowerCase())
96
+ .filter((r): r is RoleId => VALID_ROLE_IDS.has(r));
97
+
98
+ if (candidateRoles.length === 0) {
99
+ return { content: [{ type: "text" as const, text: "At least one valid candidate role is required." }] };
100
+ }
101
+
102
+ const complexity = (params.complexity === "simple" || params.complexity === "medium" || params.complexity === "complex")
103
+ ? params.complexity
104
+ : "medium";
105
+
106
+ try {
107
+ const result = await kickoffHandler(candidateRoles, complexity, requirement);
108
+ return {
109
+ content: [{
110
+ type: "text" as const,
111
+ text: result.summary,
112
+ }],
113
+ };
114
+ } catch (err) {
115
+ return {
116
+ content: [{
117
+ type: "text" as const,
118
+ text: `Kickoff meeting failed: ${err instanceof Error ? err.message : String(err)}. Proceed with controller-only planning.`,
119
+ }],
120
+ };
121
+ }
122
+ },
123
+ },
52
124
  {
53
125
  name: "teamclaw_create_task",
54
126
  label: "Create Team Task",
@@ -152,6 +224,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
152
224
  label: "Submit Controller Manifest",
153
225
  description: "Record the structured orchestration manifest for this intake run after role selection and task creation decisions are complete",
154
226
  parameters: Type.Object({
227
+ projectName: Type.Optional(Type.String({ description: "Short, lowercase, kebab-case project name for the workspace directory (e.g. 'todo-rest-api', 'stripe-payment-integration'). 2-5 words max, no random suffixes." })),
155
228
  requirementSummary: Type.String({ description: "Brief summary of the requirement the controller is orchestrating" }),
156
229
  requiredRoles: Type.Array(
157
230
  Type.String({
@@ -162,6 +235,27 @@ export function createControllerTools(deps: ControllerToolsDeps) {
162
235
  clarificationQuestions: Type.Optional(
163
236
  Type.Array(Type.String({ description: "Concrete clarification questions still waiting on the human" })),
164
237
  ),
238
+ clarificationSchemas: Type.Optional(
239
+ Type.Array(
240
+ Type.Object({
241
+ kind: Type.String({ description: "Question kind: single-select, multi-select, number, or text" }),
242
+ title: Type.String({ description: "Question title shown to the human" }),
243
+ description: Type.Optional(Type.String({ description: "Optional supporting context for the question" })),
244
+ required: Type.Optional(Type.Boolean({ description: "Whether the human must answer this question before continuing" })),
245
+ options: Type.Optional(Type.Array(Type.Object({
246
+ value: Type.String({ description: "Stable option value" }),
247
+ label: Type.String({ description: "Human-visible option label" }),
248
+ hint: Type.Optional(Type.String({ description: "Optional helper text for this option" })),
249
+ }))),
250
+ allowOther: Type.Optional(Type.Boolean({ description: "Whether freeform 'other' text is allowed alongside options" })),
251
+ placeholder: Type.Optional(Type.String({ description: "Optional placeholder or hint for text/number input" })),
252
+ unit: Type.Optional(Type.String({ description: "Optional unit label for number questions" })),
253
+ min: Type.Optional(Type.Number({ description: "Optional minimum numeric value" })),
254
+ max: Type.Optional(Type.Number({ description: "Optional maximum numeric value" })),
255
+ step: Type.Optional(Type.Number({ description: "Optional numeric step size" })),
256
+ }),
257
+ ),
258
+ ),
165
259
  createdTasks: Type.Optional(
166
260
  Type.Array(
167
261
  Type.Object({
@@ -183,6 +277,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
183
277
  ),
184
278
  handoffPlan: Type.Optional(Type.String({ description: "Brief note about how workers should report progress/handoffs across this flow" })),
185
279
  notes: Type.Optional(Type.String({ description: "Additional orchestration notes for the human/controller log" })),
280
+ requirementFullyComplete: Type.Optional(Type.Boolean({ description: "Set to true when the entire human requirement is fully satisfied — all tasks completed, no deferred tasks remaining, no follow-ups needed" })),
186
281
  }),
187
282
  async execute(_id: string, params: Record<string, unknown>) {
188
283
  const normalizedSessionKey = typeof sessionKey === "string" ? sessionKey.trim() : "";
@@ -200,16 +295,21 @@ export function createControllerTools(deps: ControllerToolsDeps) {
200
295
  return { content: [{ type: "text" as const, text: "requirementSummary is required." }] };
201
296
  }
202
297
 
298
+ const clarificationSchemas = normalizeClarificationQuestionSchemas(params.clarificationSchemas);
299
+ const clarificationQuestions = normalizeManifestStringList(params.clarificationQuestions);
203
300
  const manifest: ControllerOrchestrationManifest = {
204
301
  version: "1.0",
302
+ projectName: normalizeOptionalManifestText(params.projectName) || undefined,
205
303
  requirementSummary,
206
304
  requiredRoles: normalizeManifestRoleList(params.requiredRoles),
207
305
  clarificationsNeeded: Boolean(params.clarificationsNeeded),
208
- clarificationQuestions: normalizeManifestStringList(params.clarificationQuestions),
306
+ clarificationQuestions: clarificationQuestions.length > 0 ? clarificationQuestions : clarificationSchemas.map((entry) => entry.title),
307
+ clarificationSchemas,
209
308
  createdTasks: normalizeManifestCreatedTasks(params.createdTasks),
210
309
  deferredTasks: normalizeManifestDeferredTasks(params.deferredTasks),
211
310
  handoffPlan: normalizeOptionalManifestText(params.handoffPlan),
212
311
  notes: normalizeOptionalManifestText(params.notes),
312
+ requirementFullyComplete: Boolean(params.requirementFullyComplete),
213
313
  };
214
314
 
215
315
  try {
@@ -230,7 +330,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
230
330
  return {
231
331
  content: [{
232
332
  type: "text" as const,
233
- text: `Controller manifest recorded: roles=${manifest.requiredRoles.join(", ") || "none"} created=${manifest.createdTasks.length} deferred=${manifest.deferredTasks.length}`,
333
+ text: `Controller manifest recorded: roles=${manifest.requiredRoles.join(", ") || "none"} created=${manifest.createdTasks.length} deferred=${manifest.deferredTasks.length}${manifest.requirementFullyComplete ? " requirementFullyComplete=true" : ""}`,
234
334
  }],
235
335
  };
236
336
  } catch (err) {