@teamclaws/teamclaw 2026.4.2-3 → 2026.4.2-5

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 CHANGED
@@ -71,6 +71,15 @@ For maintainers, TeamClaw now uses two release paths:
71
71
  1. **npm package** — published by GitHub Actions via `.github/workflows/teamclaw-plugin-npm-release.yml`
72
72
  2. **ClawHub code plugin + bundled skills** — published manually from the CLI
73
73
 
74
+ The npm publish workflow runs automatically when:
75
+
76
+ - a tag matching `v*` is pushed
77
+ - or relevant package files change on `main` (`src/**`, `.github/workflows/teamclaw-plugin-npm-release.yml`, `scripts/sync-teamclaw-plugin-manifest.mjs`, `scripts/teamclaw-package-check.mjs`, `scripts/teamclaw-npm-publish.sh`)
78
+
79
+ It also supports `workflow_dispatch` for a specific commit SHA on `main`.
80
+
81
+ Before the first real npm publish, GitHub must already be configured for npm trusted publishing with the `npm-release` environment and this workflow file.
82
+
74
83
  Before either release path, sync and validate the generated plugin manifest:
75
84
 
76
85
  ```bash
@@ -101,7 +110,7 @@ For a first-time setup, the safest path is:
101
110
  2. Validate the workflow with a small smoke-test task
102
111
  3. Expand to external workers, Docker, or Kubernetes after the basics are working
103
112
 
104
- When on-demand provisioning is enabled, TeamClaw now treats controller startup as a readiness phase, not just a process-up check. During that warm-up, `/api/v1/health` returns a non-OK status until the controller has verified writable runtime paths and successfully brought the configured startup worker roles online.
113
+ When on-demand provisioning is enabled, TeamClaw now treats controller startup as a readiness phase, not just a process-up check. During that warm-up, `/api/v1/health` returns a non-OK status until the controller has verified writable runtime paths and successfully brought the configured startup worker roles online. If `workerProvisioningRoles` is empty, startup readiness defaults to waiting for a warm `developer` worker.
105
114
 
106
115
  ## Documentation
107
116
 
package/cli.mjs CHANGED
@@ -64,7 +64,7 @@ const INSTALL_MODE_OPTIONS = [
64
64
  {
65
65
  value: "controller-manual",
66
66
  label: "Local controller + default on-demand workers",
67
- hint: "Lean same-host setup; TeamClaw launches local worker processes on demand with controller-decided defaults.",
67
+ hint: "Lean same-host setup; writes workerProvisioningRoles=[] and workerProvisioningMaxPerRole=1.",
68
68
  },
69
69
  {
70
70
  value: "controller-docker",
@@ -1363,7 +1363,7 @@ async function collectInstallChoices(configPath, config, prompter, options) {
1363
1363
 
1364
1364
  const provisioningRoles = await promptOptionalRoleList(
1365
1365
  prompter,
1366
- "Preferred on-demand roles (comma-separated, leave empty for controller-decided defaults)",
1366
+ "Preferred on-demand roles (comma-separated, leave empty to keep workerProvisioningRoles empty and let startup readiness default to a warm developer worker)",
1367
1367
  resolveDefaultProvisioningRoles(existingTeamClaw),
1368
1368
  );
1369
1369
  const maxPerRole = await prompter.number({
@@ -1371,7 +1371,7 @@ async function collectInstallChoices(configPath, config, prompter, options) {
1371
1371
  defaultValue:
1372
1372
  typeof existingTeamClaw.workerProvisioningMaxPerRole === "number" && existingTeamClaw.workerProvisioningMaxPerRole >= 1
1373
1373
  ? existingTeamClaw.workerProvisioningMaxPerRole
1374
- : 2,
1374
+ : 10,
1375
1375
  min: 1,
1376
1376
  max: 50,
1377
1377
  });
@@ -1677,7 +1677,7 @@ function applyInstallerChoices(config, choices, configPath) {
1677
1677
  teamclawConfig.workerProvisioningDisabled = true;
1678
1678
  teamclawConfig.workerProvisioningControllerUrl = "";
1679
1679
  teamclawConfig.workerProvisioningRoles = [];
1680
- teamclawConfig.workerProvisioningMaxPerRole = 1;
1680
+ teamclawConfig.workerProvisioningMaxPerRole = 10;
1681
1681
  teamclawConfig.workerProvisioningImage = "";
1682
1682
  teamclawConfig.workerProvisioningPassEnv = [];
1683
1683
  teamclawConfig.workerProvisioningExtraEnv = {};
@@ -1697,7 +1697,7 @@ function applyInstallerChoices(config, choices, configPath) {
1697
1697
  teamclawConfig.workerProvisioningDisabled = false;
1698
1698
  teamclawConfig.workerProvisioningControllerUrl = "";
1699
1699
  teamclawConfig.workerProvisioningRoles = [];
1700
- teamclawConfig.workerProvisioningMaxPerRole = 1;
1700
+ teamclawConfig.workerProvisioningMaxPerRole = 10;
1701
1701
  teamclawConfig.workerProvisioningImage = "";
1702
1702
  teamclawConfig.workerProvisioningPassEnv = [];
1703
1703
  teamclawConfig.workerProvisioningExtraEnv = {};
@@ -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.4.2-3",
5
+ "version": "2026.4.2-5",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -260,7 +260,7 @@
260
260
  "workerProvisioningRoles": {
261
261
  "type": "array",
262
262
  "default": [],
263
- "description": "Preferred on-demand roles; task-required roles can still launch automatically. Empty means controller-decided defaults across all roles",
263
+ "description": "Preferred on-demand roles; task-required roles can still launch automatically. Empty means no preferred startup role list, so startup readiness falls back to a warm developer worker",
264
264
  "items": {
265
265
  "type": "string",
266
266
  "enum": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamclaws/teamclaw",
3
- "version": "2026.4.2-3",
3
+ "version": "2026.4.2-5",
4
4
  "description": "OpenClaw virtual software team orchestration plugin",
5
5
  "private": false,
6
6
  "keywords": [
@@ -10,7 +10,7 @@ Default: `http://127.0.0.1:9527`
10
10
 
11
11
  | Method | Path | Description |
12
12
  |--------|------|-------------|
13
- | GET | `/api/v1/health` | Health check `{"status":"ok"}` |
13
+ | GET | `/api/v1/health` | Health/readiness check; may report a non-OK startup readiness state until required warm workers are online |
14
14
  | GET | `/api/v1/team/status` | Full team snapshot (workers, tasks, runs, clarifications) |
15
15
  | GET | `/api/v1/roles` | List all available roles |
16
16
 
@@ -48,7 +48,7 @@ Minimal TeamClaw plugin block:
48
48
  "workerProvisioningType": "process",
49
49
  "workerProvisioningRoles": [],
50
50
  "workerProvisioningMinPerRole": 0,
51
- "workerProvisioningMaxPerRole": 2,
51
+ "workerProvisioningMaxPerRole": 10,
52
52
  "workerProvisioningIdleTtlMs": 120000,
53
53
  "workerProvisioningStartupTimeoutMs": 120000
54
54
  }
@@ -56,6 +56,12 @@ Minimal TeamClaw plugin block:
56
56
 
57
57
  Use this first because it avoids multi-machine networking while still exercising the real controller, provisioned workers, UI, messages, clarifications, and git-backed workspace flow.
58
58
 
59
+ If the user is specifically choosing install modes through the guided installer, remember the resulting config differs by mode:
60
+
61
+ - `controller-manual` writes `workerProvisioningType: "process"` and `workerProvisioningRoles: []`, so startup readiness falls back to a warm `developer` worker
62
+ - `controller-process` writes `workerProvisioningType: "process"` and uses the chosen roles/max-per-role
63
+ - `worker` disables provisioning on that node
64
+
59
65
  ## 4. Worker-only topology
60
66
 
61
67
  Use this only when a controller already exists elsewhere:
@@ -83,7 +89,7 @@ Use this when the controller should launch same-machine workers only as needed:
83
89
  "teamName": "my-team",
84
90
  "workerProvisioningType": "process",
85
91
  "workerProvisioningMinPerRole": 0,
86
- "workerProvisioningMaxPerRole": 2,
92
+ "workerProvisioningMaxPerRole": 10,
87
93
  "workerProvisioningIdleTtlMs": 120000,
88
94
  "workerProvisioningStartupTimeoutMs": 120000
89
95
  }
@@ -103,7 +109,7 @@ Use this when the user already has Docker and wants containerized workers:
103
109
  "workerProvisioningImage": "ghcr.io/topcheer/teamclaw-openclaw:latest",
104
110
  "workerProvisioningWorkspaceRoot": "/workspace-root",
105
111
  "workerProvisioningDockerWorkspaceVolume": "teamclaw-workspaces",
106
- "workerProvisioningMaxPerRole": 3
112
+ "workerProvisioningMaxPerRole": 10
107
113
  }
108
114
  ```
109
115
 
@@ -126,7 +132,7 @@ Use this only when the user already runs the controller in or behind a reachable
126
132
  "workerProvisioningImage": "ghcr.io/topcheer/teamclaw-openclaw:latest",
127
133
  "workerProvisioningWorkspaceRoot": "/workspace-root",
128
134
  "workerProvisioningKubernetesWorkspacePersistentVolumeClaim": "teamclaw-workspace",
129
- "workerProvisioningMaxPerRole": 2
135
+ "workerProvisioningMaxPerRole": 10
130
136
  }
131
137
  ```
132
138
 
@@ -14,6 +14,8 @@ Expected shape:
14
14
  {"status":"ok","mode":"controller", ...}
15
15
  ```
16
16
 
17
+ If on-demand provisioning is enabled, `/api/v1/health` can temporarily return a non-OK readiness state during warm-up. When `workerProvisioningRoles` is empty, readiness defaults to waiting for a warm `developer` worker.
18
+
17
19
  Also point them to:
18
20
 
19
21
  ```text
@@ -67,7 +69,7 @@ If OpenClaw times out first, users often think TeamClaw is broken when the real
67
69
 
68
70
  Treat the install as successful only when:
69
71
 
70
- 1. controller health is `ok`
72
+ 1. controller health is `ok` after startup readiness finishes
71
73
  2. expected workers appear in the UI or status view
72
74
  3. the smoke-test task completes
73
75
  4. files appear in the workspace
package/src/config.ts CHANGED
@@ -100,7 +100,7 @@ function buildConfigSchema() {
100
100
  workerProvisioningRoles: {
101
101
  type: "array" as const,
102
102
  default: [],
103
- description: "Preferred on-demand roles; task-required roles can still launch automatically. Empty means controller-decided defaults across all roles",
103
+ description: "Preferred on-demand roles; task-required roles can still launch automatically. Empty means no preferred startup role list, so startup readiness falls back to a warm developer worker",
104
104
  items: {
105
105
  type: "string" as const,
106
106
  enum: ROLE_IDS,
@@ -70,6 +70,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
70
70
  workers: {},
71
71
  tasks: {},
72
72
  controllerRuns: {},
73
+ projects: {},
73
74
  messages: [],
74
75
  clarifications: {},
75
76
  repo: repoState ?? undefined,
@@ -10,6 +10,7 @@ import type {
10
10
  import { buildControllerNoWorkersMessage, hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
11
11
  import {
12
12
  normalizeClarificationQuestionSchemas,
13
+ normalizeManifestCompletionOpportunities,
13
14
  normalizeManifestCreatedTasks,
14
15
  normalizeManifestDeferredTasks,
15
16
  normalizeManifestRoleList,
@@ -130,6 +131,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
130
131
  description: Type.String({ description: "Execution-ready task description with scope, expected deliverable, constraints, resolved clarifications, and no unmet predecessor dependency" }),
131
132
  priority: Type.Optional(Type.String({ description: "Priority: low, medium, high, critical" })),
132
133
  assignedRole: Type.Optional(Type.String({ description: "Exact target role ID (pm, architect, developer, qa, release-engineer, infra-engineer, devops, security-engineer, designer, marketing)" })),
134
+ projectName: Type.Optional(Type.String({ description: "Stable project key to reuse or create for this task (for example: ggcode, todo-rest-api)." })),
133
135
  recommendedSkills: Type.Optional(
134
136
  Type.Array(
135
137
  Type.String({
@@ -184,6 +186,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
184
186
  description,
185
187
  priority: params.priority ?? "medium",
186
188
  assignedRole: params.assignedRole ?? undefined,
189
+ projectName: typeof params.projectName === "string" ? params.projectName : undefined,
187
190
  recommendedSkills: Array.isArray(params.recommendedSkills) ? params.recommendedSkills : undefined,
188
191
  createdBy: "controller",
189
192
  controllerSessionKey: normalizedSessionKey || undefined,
@@ -277,6 +280,15 @@ export function createControllerTools(deps: ControllerToolsDeps) {
277
280
  ),
278
281
  handoffPlan: Type.Optional(Type.String({ description: "Brief note about how workers should report progress/handoffs across this flow" })),
279
282
  notes: Type.Optional(Type.String({ description: "Additional orchestration notes for the human/controller log" })),
283
+ completionOpportunities: Type.Optional(
284
+ Type.Array(
285
+ Type.Object({
286
+ title: Type.String({ description: "Human-facing label for an adjacent optional next step after delivery" }),
287
+ value: Type.String({ description: "Stable option value for this next step" }),
288
+ summary: Type.String({ description: "What TeamClaw could continue doing if selected" }),
289
+ }),
290
+ ),
291
+ ),
280
292
  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" })),
281
293
  }),
282
294
  async execute(_id: string, params: Record<string, unknown>) {
@@ -309,6 +321,7 @@ export function createControllerTools(deps: ControllerToolsDeps) {
309
321
  deferredTasks: normalizeManifestDeferredTasks(params.deferredTasks),
310
322
  handoffPlan: normalizeOptionalManifestText(params.handoffPlan),
311
323
  notes: normalizeOptionalManifestText(params.notes),
324
+ completionOpportunities: normalizeManifestCompletionOpportunities(params.completionOpportunities),
312
325
  requirementFullyComplete: Boolean(params.requirementFullyComplete),
313
326
  };
314
327
 
@@ -63,6 +63,7 @@ import {
63
63
  resolveTeamClawAgentWorkspaceRootDir,
64
64
  resolveTeamClawWorkspaceDir,
65
65
  resolveTeamClawProjectsDir,
66
+ deriveStableProjectKey,
66
67
  deriveProjectSlug,
67
68
  } from "../openclaw-workspace.js";
68
69
  import { resolvePreferredLanAddress } from "../networking.js";
@@ -92,6 +93,7 @@ const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
92
93
  const CONTROLLER_INTAKE_AGENT_SESSION_RE = /^agent:[^:]+:(teamclaw-controller-web:[a-zA-Z0-9:_-]{1,120})$/;
93
94
  const CONTROLLER_RUN_WAIT_SLICE_MS = 30_000;
94
95
  const EXISTING_PROJECT_REUSE_HINT_RE = /\b(existing|optimi(?:s|z)e|optimi(?:s|z)ation|improvement|enhanc(?:e|ement)|follow[- ]?up|extend|update|bugfix|bug fix)\b/iu;
96
+ const GENERIC_PROJECT_FOLLOW_UP_RE = /\b(continue|continuing|next step|what else|what's next|follow[- ]?up|more|remaining)\b|继续|接下来|下一步|还有哪些|还可以做什么/u;
95
97
  const PROJECT_MATCH_STOPWORDS = new Set([
96
98
  "a",
97
99
  "an",
@@ -459,15 +461,19 @@ function createControllerRun(
459
461
  ): ControllerRunInfo {
460
462
  const now = Date.now();
461
463
  const existingState = deps.getTeamState();
462
- const inheritedProjectDir = options?.sourceTaskId
463
- ? existingState?.tasks[options.sourceTaskId]?.projectDir
464
- : resolveProjectDirForSession(sessionKey, existingState)
465
- ?? resolveExistingProjectDirFromMessage(message, existingState);
464
+ const inheritedProject = options?.sourceTaskId
465
+ ? resolveProjectIdentityForTaskId(options.sourceTaskId, existingState)
466
+ : resolveProjectIdentityForSession(sessionKey, existingState)
467
+ ?? resolveExistingProjectIdentityFromMessage(message, existingState);
468
+ const explicitProjectId = extractExplicitProjectNameHint(message);
469
+ const projectId = inheritedProject?.projectId ?? explicitProjectId;
470
+ const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(message);
466
471
  const run: ControllerRunInfo = {
467
472
  id: generateId(),
468
473
  title: buildControllerRunTitle(message, options?.source ?? "human", options?.sourceTaskTitle),
469
474
  sessionKey,
470
- projectDir: inheritedProjectDir ?? deriveProjectSlug(message),
475
+ projectId: projectId || undefined,
476
+ projectDir,
471
477
  source: options?.source ?? "human",
472
478
  sourceTaskId: options?.sourceTaskId,
473
479
  sourceTaskTitle: options?.sourceTaskTitle,
@@ -479,6 +485,13 @@ function createControllerRun(
479
485
  };
480
486
 
481
487
  const state = deps.updateTeamState((teamState) => {
488
+ syncProjectRegistryEntry(teamState, {
489
+ projectId: run.projectId,
490
+ projectDir: run.projectDir,
491
+ aliases: [run.projectId, extractExplicitProjectNameHint(message)],
492
+ summary: run.title,
493
+ updatedAt: now,
494
+ });
482
495
  teamState.controllerRuns[run.id] = run;
483
496
  trimControllerRuns(teamState);
484
497
  });
@@ -487,20 +500,17 @@ function createControllerRun(
487
500
  return createdRun;
488
501
  }
489
502
 
490
- function resolveExistingProjectDirFromMessage(
503
+ function resolveExistingProjectIdentityFromMessage(
491
504
  message: string,
492
505
  state: TeamState | null,
493
- ): string | undefined {
494
- if (!EXISTING_PROJECT_REUSE_HINT_RE.test(message)) {
495
- return undefined;
496
- }
506
+ ): { projectId?: string; projectDir?: string } | undefined {
497
507
  const normalizedMessage = normalizeProjectMatchingText(message);
498
508
  if (!normalizedMessage) {
499
509
  return undefined;
500
510
  }
501
- const explicitAlias = normalizeProjectMatchingText(extractProjectAliasFromText(message) ?? "");
511
+ const explicitAlias = normalizeProjectMatchingText(extractExplicitProjectNameHint(message) ?? "");
502
512
 
503
- let best: { projectDir: string; score: number; updatedAt: number } | null = null;
513
+ let best: { projectId?: string; projectDir: string; score: number; updatedAt: number } | null = null;
504
514
  const candidates = collectExistingProjectCandidates(state);
505
515
  for (const candidate of candidates) {
506
516
  const normalizedAliases = Array.from(candidate.aliases)
@@ -532,27 +542,47 @@ function resolveExistingProjectDirFromMessage(
532
542
  }
533
543
  if (!best || totalScore > best.score || (totalScore === best.score && candidate.updatedAt > best.updatedAt)) {
534
544
  best = {
545
+ projectId: candidate.projectId,
535
546
  projectDir: candidate.projectDir,
536
547
  score: totalScore,
537
548
  updatedAt: candidate.updatedAt,
538
549
  };
539
550
  }
540
551
  }
541
- return best?.projectDir;
552
+ if (best) {
553
+ return { projectId: best.projectId, projectDir: best.projectDir };
554
+ }
555
+ if ((EXISTING_PROJECT_REUSE_HINT_RE.test(message) || GENERIC_PROJECT_FOLLOW_UP_RE.test(message)) && candidates.length === 1) {
556
+ return {
557
+ projectId: candidates[0]?.projectId,
558
+ projectDir: candidates[0]?.projectDir,
559
+ };
560
+ }
561
+ return undefined;
542
562
  }
543
563
 
544
564
  function collectExistingProjectCandidates(state: TeamState | null): Array<{
565
+ projectId?: string;
545
566
  projectDir: string;
546
567
  aliases: Set<string>;
547
568
  searchText: string;
548
569
  updatedAt: number;
549
570
  }> {
550
- const byProjectDir = new Map<string, { aliases: Set<string>; texts: string[]; updatedAt: number }>();
551
- const addCandidate = (projectDir: string | undefined, text: string | undefined, updatedAt: number, alias?: string | null) => {
571
+ const byProjectDir = new Map<string, { projectId?: string; aliases: Set<string>; texts: string[]; updatedAt: number }>();
572
+ const addCandidate = (
573
+ projectDir: string | undefined,
574
+ text: string | undefined,
575
+ updatedAt: number,
576
+ alias?: string | null,
577
+ projectId?: string,
578
+ ) => {
552
579
  if (!projectDir) {
553
580
  return;
554
581
  }
555
- const entry = byProjectDir.get(projectDir) ?? { aliases: new Set<string>(), texts: [], updatedAt: 0 };
582
+ const entry = byProjectDir.get(projectDir) ?? { projectId, aliases: new Set<string>(), texts: [], updatedAt: 0 };
583
+ if (!entry.projectId && projectId) {
584
+ entry.projectId = projectId;
585
+ }
556
586
  if (text && text.trim()) {
557
587
  entry.texts.push(text);
558
588
  }
@@ -563,17 +593,25 @@ function collectExistingProjectCandidates(state: TeamState | null): Array<{
563
593
  byProjectDir.set(projectDir, entry);
564
594
  };
565
595
 
596
+ for (const project of Object.values(state?.projects ?? {})) {
597
+ addCandidate(project.projectDir, project.summary, project.updatedAt, project.id, project.id);
598
+ for (const alias of project.aliases ?? []) {
599
+ addCandidate(project.projectDir, alias, project.updatedAt, alias, project.id);
600
+ }
601
+ }
566
602
  for (const run of Object.values(state?.controllerRuns ?? {})) {
567
- addCandidate(run.projectDir, run.request, run.updatedAt, extractProjectAliasFromText(run.request));
568
- addCandidate(run.projectDir, run.title, run.updatedAt, extractProjectAliasFromText(run.title));
603
+ addCandidate(run.projectDir, run.request, run.updatedAt, extractProjectAliasFromText(run.request), run.projectId);
604
+ addCandidate(run.projectDir, run.title, run.updatedAt, extractProjectAliasFromText(run.title), run.projectId);
605
+ addCandidate(run.projectDir, run.manifest?.projectName, run.updatedAt, run.manifest?.projectName, run.projectId);
569
606
  }
570
607
  for (const task of Object.values(state?.tasks ?? {})) {
571
- addCandidate(task.projectDir, task.title, task.updatedAt, extractProjectAliasFromText(task.title));
572
- addCandidate(task.projectDir, task.description, task.updatedAt, extractProjectAliasFromText(task.description));
573
- addCandidate(task.projectDir, task.resultContract?.summary, task.updatedAt);
608
+ addCandidate(task.projectDir, task.title, task.updatedAt, extractProjectAliasFromText(task.title), task.projectId);
609
+ addCandidate(task.projectDir, task.description, task.updatedAt, extractProjectAliasFromText(task.description), task.projectId);
610
+ addCandidate(task.projectDir, task.resultContract?.summary, task.updatedAt, null, task.projectId);
574
611
  }
575
612
 
576
613
  return Array.from(byProjectDir.entries()).map(([projectDir, entry]) => ({
614
+ projectId: entry.projectId,
577
615
  projectDir,
578
616
  aliases: entry.aliases,
579
617
  searchText: entry.texts.join("\n"),
@@ -581,6 +619,119 @@ function collectExistingProjectCandidates(state: TeamState | null): Array<{
581
619
  }));
582
620
  }
583
621
 
622
+ function extractExplicitProjectNameHint(text: string | undefined): string | undefined {
623
+ const value = String(text ?? "");
624
+ const namedMatch = value.match(/(?:品牌|brand|project\s*name|产品名|名字)\s*[::]?\s*[`"'“”]?([a-z][a-z0-9_-]{1,39})[`"'“”]?/iu);
625
+ if (namedMatch?.[1]) {
626
+ return deriveStableProjectKey(namedMatch[1]);
627
+ }
628
+ const inlineNames = Array.from(value.matchAll(/`([a-z][a-z0-9_-]{1,39})`/giu))
629
+ .map((match) => deriveStableProjectKey(match[1]))
630
+ .filter(Boolean);
631
+ return inlineNames.length === 1 ? inlineNames[0] : undefined;
632
+ }
633
+
634
+ function findProjectRecordByDir(state: TeamState | null, projectDir: string | undefined) {
635
+ if (!state || !projectDir) {
636
+ return undefined;
637
+ }
638
+ return Object.values(state.projects ?? {}).find((project) => project.projectDir === projectDir);
639
+ }
640
+
641
+ function syncProjectRegistryEntry(
642
+ state: TeamState,
643
+ input: {
644
+ projectId?: string;
645
+ projectDir?: string;
646
+ aliases?: Array<string | undefined | null>;
647
+ summary?: string;
648
+ updatedAt: number;
649
+ },
650
+ ): { projectId?: string; projectDir?: string } {
651
+ state.projects = state.projects && typeof state.projects === "object" ? state.projects : {};
652
+ let normalizedProjectId = deriveStableProjectKey(input.projectId ?? "");
653
+ let existing = normalizedProjectId ? state.projects[normalizedProjectId] : undefined;
654
+ const byDir = findProjectRecordByDir(state, input.projectDir);
655
+ if (!existing && byDir) {
656
+ existing = byDir;
657
+ }
658
+ if (!existing && input.projectDir) {
659
+ normalizedProjectId = normalizedProjectId || deriveStableProjectKey(input.projectDir);
660
+ if (normalizedProjectId) {
661
+ existing = {
662
+ id: normalizedProjectId,
663
+ projectDir: input.projectDir,
664
+ aliases: [],
665
+ createdAt: input.updatedAt,
666
+ updatedAt: input.updatedAt,
667
+ lastUsedAt: input.updatedAt,
668
+ };
669
+ state.projects[normalizedProjectId] = existing;
670
+ }
671
+ }
672
+ if (!existing) {
673
+ return { projectId: normalizedProjectId || undefined, projectDir: input.projectDir };
674
+ }
675
+ if (normalizedProjectId && existing.id !== normalizedProjectId && !state.projects[normalizedProjectId]) {
676
+ delete state.projects[existing.id];
677
+ existing.id = normalizedProjectId;
678
+ state.projects[normalizedProjectId] = existing;
679
+ }
680
+ if (!existing.projectDir && input.projectDir) {
681
+ existing.projectDir = input.projectDir;
682
+ }
683
+ const aliasSet = new Set(existing.aliases ?? []);
684
+ aliasSet.add(existing.id);
685
+ if (existing.projectDir) {
686
+ aliasSet.add(existing.projectDir);
687
+ }
688
+ for (const alias of input.aliases ?? []) {
689
+ const normalizedAlias = deriveStableProjectKey(String(alias ?? ""));
690
+ if (normalizedAlias) {
691
+ aliasSet.add(normalizedAlias);
692
+ }
693
+ }
694
+ existing.aliases = Array.from(aliasSet);
695
+ existing.summary = input.summary || existing.summary;
696
+ existing.updatedAt = Math.max(existing.updatedAt || 0, input.updatedAt);
697
+ existing.lastUsedAt = Math.max(existing.lastUsedAt || 0, input.updatedAt);
698
+ return { projectId: existing.id, projectDir: existing.projectDir };
699
+ }
700
+
701
+ function resolveProjectIdentityForSession(
702
+ sessionKey: string,
703
+ state: TeamState | null,
704
+ ): { projectId?: string; projectDir?: string } | undefined {
705
+ const runId = findLatestControllerRunIdForSession(sessionKey, state, { preferActive: true });
706
+ if (!runId) {
707
+ return undefined;
708
+ }
709
+ const run = state?.controllerRuns[runId];
710
+ if (!run) {
711
+ return undefined;
712
+ }
713
+ const project = findProjectRecordByDir(state, run.projectDir);
714
+ return {
715
+ projectId: run.projectId ?? project?.id,
716
+ projectDir: run.projectDir ?? project?.projectDir,
717
+ };
718
+ }
719
+
720
+ function resolveProjectIdentityForTaskId(
721
+ taskId: string,
722
+ state: TeamState | null,
723
+ ): { projectId?: string; projectDir?: string } | undefined {
724
+ const task = state?.tasks?.[taskId];
725
+ if (!task) {
726
+ return undefined;
727
+ }
728
+ const project = findProjectRecordByDir(state, task.projectDir);
729
+ return {
730
+ projectId: task.projectId ?? project?.id,
731
+ projectDir: task.projectDir ?? project?.projectDir,
732
+ };
733
+ }
734
+
584
735
  function extractProjectAliasFromText(text: string | undefined): string | null {
585
736
  const firstMeaningfulLine = String(text ?? "")
586
737
  .split(/\n+/u)
@@ -946,8 +1097,7 @@ function resolveProjectDirForSession(
946
1097
  sessionKey: string,
947
1098
  state: TeamState | null,
948
1099
  ): string | undefined {
949
- const runId = findLatestControllerRunIdForSession(sessionKey, state, { preferActive: true });
950
- return runId ? state?.controllerRuns[runId]?.projectDir : undefined;
1100
+ return resolveProjectIdentityForSession(sessionKey, state)?.projectDir;
951
1101
  }
952
1102
 
953
1103
  function resolveControllerWorkflowSessionKey(task: TaskInfo, state: TeamState | null): string | undefined {
@@ -1121,15 +1271,16 @@ function buildBackfilledControllerManifest(
1121
1271
  for (const roleId of inferManifestRolesFromText(request)) {
1122
1272
  inferredRoles.add(roleId);
1123
1273
  }
1124
- // When no roles could be inferred at all (model didn't call the tool and didn't
1125
- // mention any role names), fall back to "developer" as the most general purpose role
1126
- // so that the intake run still has usable machine-readable state.
1274
+ // When no roles could be inferred at all, default the first-pass requirement analysis
1275
+ // to architect rather than developer. This keeps repository analysis, feasibility
1276
+ // assessment, and technical decomposition out of implementation-only developer tasks.
1127
1277
  if (inferredRoles.size === 0) {
1128
- inferredRoles.add("developer");
1278
+ inferredRoles.add("architect");
1129
1279
  }
1130
1280
  const clarificationQuestions = inferClarificationQuestionsFromReply(rawReply);
1131
1281
  return {
1132
1282
  version: "1.0",
1283
+ projectName: actualCreatedTasks.find((task) => task.projectId)?.projectId,
1133
1284
  requirementSummary: request.replace(/\s+/g, " ").trim() || "Controller requirement summary unavailable.",
1134
1285
  requiredRoles: Array.from(inferredRoles),
1135
1286
  clarificationsNeeded: clarificationQuestions.length > 0 && actualCreatedTasks.length === 0,
@@ -1158,6 +1309,30 @@ function looksLikeSoftwareRequirement(request: string): boolean {
1158
1309
  return /(api|backend|frontend|fastapi|react|vue|node|python|typescript|javascript|sql|database|service|app|web|mobile|docker|kubernetes|deploy|测试|系统|平台|接口|服务|数据库|应用|前端|后端)/.test(normalized);
1159
1310
  }
1160
1311
 
1312
+ function isArchitectureFirstRequirement(request: string): boolean {
1313
+ const normalized = request.toLowerCase();
1314
+ return /(analy[sz]e|analysis|assess|feasibility|architecture|architect|design|plan|decompose|migration|port|rewrite|refactor|audit|review|repo|repository|codebase|golang|go\b|typescript|可行性|评估|架构|设计|分析|拆分|迁移|移植|重写|审计|仓库|代码库)/.test(normalized);
1315
+ }
1316
+
1317
+ function chooseFallbackAssignedRole(manifest: ControllerOrchestrationManifest, request: string): RoleId {
1318
+ const requiredRoles = manifest.requiredRoles;
1319
+ if (requiredRoles.length === 0) {
1320
+ return isArchitectureFirstRequirement(request) ? "architect" : "developer";
1321
+ }
1322
+ if (isArchitectureFirstRequirement(request)) {
1323
+ if (requiredRoles.includes("architect")) {
1324
+ return "architect";
1325
+ }
1326
+ if (requiredRoles.includes("pm")) {
1327
+ return "pm";
1328
+ }
1329
+ }
1330
+ if (requiredRoles.includes("developer") && requiredRoles.length === 1) {
1331
+ return "developer";
1332
+ }
1333
+ return requiredRoles[0] ?? (isArchitectureFirstRequirement(request) ? "architect" : "developer");
1334
+ }
1335
+
1161
1336
  function buildFallbackControllerTaskTitle(request: string, assignedRole?: RoleId): string {
1162
1337
  const firstMeaningfulLine = request
1163
1338
  .split(/\n+/)
@@ -1172,6 +1347,9 @@ function buildFallbackControllerTaskTitle(request: string, assignedRole?: RoleId
1172
1347
  : `Implement ${capped}`;
1173
1348
  }
1174
1349
  }
1350
+ if (assignedRole === "architect") {
1351
+ return "Analyze the repository and produce an architecture/feasibility plan";
1352
+ }
1175
1353
  return assignedRole === "developer"
1176
1354
  ? "Implement the requested software deliverable"
1177
1355
  : `Perform the requested ${assignedRole || "software"} work`;
@@ -1185,6 +1363,7 @@ async function createControllerManagedTask(
1185
1363
  assignedRole?: RoleId;
1186
1364
  createdBy: string;
1187
1365
  controllerSessionKey?: string;
1366
+ projectName?: string;
1188
1367
  recommendedSkills?: string[];
1189
1368
  },
1190
1369
  deps: ControllerHttpDeps,
@@ -1196,15 +1375,12 @@ async function createControllerManagedTask(
1196
1375
  ? normalizeControllerIntakeSessionKey(input.controllerSessionKey)
1197
1376
  : undefined;
1198
1377
 
1199
- let projectDir: string | undefined;
1200
- if (normalizedSessionKey) {
1201
- const runId = findLatestControllerRunIdForSession(normalizedSessionKey, deps.getTeamState(), { preferActive: true });
1202
- const parentRun = runId ? deps.getTeamState()?.controllerRuns[runId] : undefined;
1203
- projectDir = parentRun?.projectDir;
1204
- }
1205
- if (!projectDir) {
1206
- projectDir = deriveProjectSlug(input.title);
1207
- }
1378
+ const inheritedProject = normalizedSessionKey
1379
+ ? resolveProjectIdentityForSession(normalizedSessionKey, deps.getTeamState())
1380
+ : undefined;
1381
+ const explicitProjectId = deriveStableProjectKey(input.projectName ?? "");
1382
+ const projectId = inheritedProject?.projectId ?? explicitProjectId || undefined;
1383
+ const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(input.title);
1208
1384
 
1209
1385
  const normalizedRecommendedSkills = normalizeRecommendedSkills(input.recommendedSkills ?? []);
1210
1386
  const task: TaskInfo = {
@@ -1217,12 +1393,22 @@ async function createControllerManagedTask(
1217
1393
  createdBy: input.createdBy,
1218
1394
  recommendedSkills: normalizedRecommendedSkills.length > 0 ? normalizedRecommendedSkills : undefined,
1219
1395
  controllerSessionKey: normalizedSessionKey,
1396
+ projectId,
1220
1397
  projectDir,
1221
1398
  createdAt: now,
1222
1399
  updatedAt: now,
1223
1400
  };
1224
1401
 
1225
1402
  deps.updateTeamState((state) => {
1403
+ const project = syncProjectRegistryEntry(state, {
1404
+ projectId: task.projectId,
1405
+ projectDir: task.projectDir,
1406
+ aliases: [input.projectName, task.projectId],
1407
+ summary: task.title,
1408
+ updatedAt: now,
1409
+ });
1410
+ task.projectId = project.projectId;
1411
+ task.projectDir = project.projectDir;
1226
1412
  state.tasks[taskId] = task;
1227
1413
  });
1228
1414
  recordTaskExecutionEvent(taskId, {
@@ -1283,9 +1469,7 @@ async function maybeBackfillExecutionReadyTask(
1283
1469
  return [];
1284
1470
  }
1285
1471
 
1286
- const assignedRole = manifest.requiredRoles.includes("developer")
1287
- ? "developer"
1288
- : manifest.requiredRoles[0];
1472
+ const assignedRole = chooseFallbackAssignedRole(manifest, request);
1289
1473
  const task = await createControllerManagedTask({
1290
1474
  title: buildFallbackControllerTaskTitle(request, assignedRole),
1291
1475
  description: request,
@@ -1528,17 +1712,43 @@ function normalizeComparableText(value: string): string {
1528
1712
  function buildManifestClarificationEntries(
1529
1713
  manifest: ControllerOrchestrationManifest,
1530
1714
  ): Array<{ question: string; questionSchema?: ClarificationRequest["questionSchema"] }> {
1715
+ const opportunities = Array.isArray(manifest.completionOpportunities)
1716
+ ? manifest.completionOpportunities.filter((entry) => entry.title && entry.value && entry.summary)
1717
+ : [];
1718
+ const completionEntries = manifest.requirementFullyComplete && opportunities.length > 0
1719
+ ? [{
1720
+ question: "Would you like TeamClaw to continue with one of these adjacent next steps?",
1721
+ questionSchema: {
1722
+ kind: "single-select" as const,
1723
+ title: "What should TeamClaw do next?",
1724
+ description: opportunities.map((entry) => `- ${entry.title}: ${entry.summary}`).join("\n"),
1725
+ required: false,
1726
+ options: opportunities.map((entry) => ({
1727
+ value: entry.value,
1728
+ label: entry.title,
1729
+ hint: entry.summary,
1730
+ })),
1731
+ allowOther: true,
1732
+ },
1733
+ }]
1734
+ : [];
1531
1735
  const normalizedQuestions = manifest.clarificationQuestions
1532
1736
  .map((entry) => String(entry || "").trim())
1533
1737
  .filter(Boolean);
1534
1738
  const schemas = Array.isArray(manifest.clarificationSchemas) ? manifest.clarificationSchemas : [];
1535
1739
  if (schemas.length > 0) {
1536
- return schemas.map((schema, index) => ({
1537
- question: normalizedQuestions[index] || schema.title,
1538
- questionSchema: schema,
1539
- }));
1740
+ return [
1741
+ ...schemas.map((schema, index) => ({
1742
+ question: normalizedQuestions[index] || schema.title,
1743
+ questionSchema: schema,
1744
+ })),
1745
+ ...completionEntries,
1746
+ ];
1540
1747
  }
1541
- return normalizedQuestions.map((question) => ({ question }));
1748
+ return [
1749
+ ...normalizedQuestions.map((question) => ({ question })),
1750
+ ...completionEntries,
1751
+ ];
1542
1752
  }
1543
1753
 
1544
1754
  function syncControllerRunClarifications(
@@ -1548,19 +1758,33 @@ function syncControllerRunClarifications(
1548
1758
  deps: ControllerHttpDeps,
1549
1759
  ): ClarificationRequest[] {
1550
1760
  const entries = buildManifestClarificationEntries(manifest);
1551
- if (entries.length === 0) {
1552
- return [];
1553
- }
1554
-
1555
1761
  const created: ClarificationRequest[] = [];
1762
+ const superseded: ClarificationRequest[] = [];
1556
1763
  const now = Date.now();
1557
1764
  deps.updateTeamState((state) => {
1765
+ const desiredQuestionKeys = new Set(
1766
+ entries.map((entry) => normalizeComparableText(entry.question)),
1767
+ );
1558
1768
  const existingQuestions = new Map(
1559
1769
  Object.values(state.clarifications)
1560
1770
  .filter((item) => item.controllerRunId === controllerRunId)
1561
1771
  .map((item) => [normalizeComparableText(item.question), item]),
1562
1772
  );
1563
1773
 
1774
+ for (const [key, clarification] of existingQuestions.entries()) {
1775
+ if (
1776
+ clarification.status === "pending"
1777
+ && !desiredQuestionKeys.has(key)
1778
+ ) {
1779
+ clarification.status = "answered";
1780
+ clarification.answer = "Automatically superseded by the latest controller clarification set.";
1781
+ clarification.answeredBy = "system";
1782
+ clarification.answeredAt = now;
1783
+ clarification.updatedAt = now;
1784
+ superseded.push({ ...clarification });
1785
+ }
1786
+ }
1787
+
1564
1788
  for (const entry of entries) {
1565
1789
  const key = normalizeComparableText(entry.question);
1566
1790
  if (existingQuestions.has(key)) {
@@ -1588,6 +1812,9 @@ function syncControllerRunClarifications(
1588
1812
  for (const clarification of created) {
1589
1813
  deps.wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
1590
1814
  }
1815
+ for (const clarification of superseded) {
1816
+ deps.wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
1817
+ }
1591
1818
  return created;
1592
1819
  }
1593
1820
 
@@ -1597,6 +1824,7 @@ function reconcileControllerClarifications(deps: ControllerHttpDeps): TeamState
1597
1824
  return null;
1598
1825
  }
1599
1826
 
1827
+ const superseded: ClarificationRequest[] = [];
1600
1828
  deps.updateTeamState((draft) => {
1601
1829
  const runsBySession = new Map<string, ControllerRunInfo[]>();
1602
1830
  for (const run of Object.values(draft.controllerRuns)) {
@@ -1627,9 +1855,14 @@ function reconcileControllerClarifications(deps: ControllerHttpDeps): TeamState
1627
1855
  clar.answeredBy = "system";
1628
1856
  clar.answeredAt = supersedingRun.updatedAt;
1629
1857
  clar.updatedAt = supersedingRun.updatedAt;
1858
+ superseded.push({ ...clar });
1630
1859
  }
1631
1860
  });
1632
1861
 
1862
+ for (const clarification of superseded) {
1863
+ deps.wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
1864
+ }
1865
+
1633
1866
  for (const run of Object.values(state.controllerRuns)) {
1634
1867
  if (!run.manifest?.clarificationsNeeded) {
1635
1868
  continue;
@@ -2263,6 +2496,49 @@ function buildRecommendedSkillsContext(task: TaskInfo): string {
2263
2496
  ].join("\n");
2264
2497
  }
2265
2498
 
2499
+ function buildTaskContextSnapshot(task: TaskInfo, state: TeamState | null): TaskAssignmentPayload["teamContext"] | undefined {
2500
+ if (!state || !task.controllerSessionKey) {
2501
+ return undefined;
2502
+ }
2503
+ const normalizedSessionKey = normalizeControllerIntakeSessionKey(task.controllerSessionKey);
2504
+ const summarize = (candidate: TaskInfo) => ({
2505
+ id: candidate.id,
2506
+ title: candidate.title,
2507
+ assignedRole: candidate.assignedRole,
2508
+ status: candidate.status,
2509
+ summary: summarizeTaskForAssignment(candidate),
2510
+ });
2511
+ const sameSessionTasks = Object.values(state.tasks)
2512
+ .filter((candidate) => candidate.id !== task.id)
2513
+ .filter((candidate) => normalizeControllerIntakeSessionKey(candidate.controllerSessionKey) === normalizedSessionKey);
2514
+ const latestRun = Object.values(state.controllerRuns)
2515
+ .filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizedSessionKey)
2516
+ .sort((a, b) => b.updatedAt - a.updatedAt)[0];
2517
+ return {
2518
+ requirementSummary: latestRun?.manifest?.requirementSummary || latestRun?.request,
2519
+ projectName: latestRun?.manifest?.projectName,
2520
+ requiredRoles: latestRun?.manifest?.requiredRoles,
2521
+ activeTasks: sameSessionTasks.filter((candidate) => candidate.status === "assigned" || candidate.status === "in_progress").slice(0, 8).map(summarize),
2522
+ blockedTasks: sameSessionTasks.filter((candidate) => candidate.status === "blocked").slice(0, 6).map(summarize),
2523
+ recentCompletedTasks: sameSessionTasks
2524
+ .filter((candidate) => candidate.status === "completed")
2525
+ .sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt))
2526
+ .slice(0, 8)
2527
+ .map(summarize),
2528
+ pendingClarifications: Object.values(state.clarifications)
2529
+ .filter((clarification) => clarification.status === "pending")
2530
+ .filter((clarification) => normalizeControllerIntakeSessionKey(clarification.controllerSessionKey) === normalizedSessionKey)
2531
+ .slice(0, 5)
2532
+ .map((clarification) => ({
2533
+ question: clarification.question,
2534
+ blockingReason: clarification.blockingReason,
2535
+ })),
2536
+ handoffPlan: latestRun?.manifest?.handoffPlan,
2537
+ notes: latestRun?.manifest?.notes,
2538
+ kickoffSummary: latestRun?.manifest?.kickoffPlan?.summary,
2539
+ };
2540
+ }
2541
+
2266
2542
  function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
2267
2543
  const parts = [task.description];
2268
2544
  const projectContext = buildProjectDirectoryContext(task.projectDir);
@@ -3326,6 +3602,7 @@ async function dispatchTaskToWorker(
3326
3602
  executionSessionKey: executionIdentity.executionSessionKey,
3327
3603
  executionIdempotencyKey: executionIdentity.executionIdempotencyKey,
3328
3604
  repo: repoInfo,
3605
+ teamContext: buildTaskContextSnapshot(task, state ?? null),
3329
3606
  };
3330
3607
 
3331
3608
  const res = await fetch(`${worker.url}/api/v1/tasks/assign`, {
@@ -3757,6 +4034,9 @@ async function handleRequest(
3757
4034
  const controllerSessionKey = createdBy === "controller" && typeof body.controllerSessionKey === "string" && body.controllerSessionKey.trim()
3758
4035
  ? normalizeControllerIntakeSessionKey(body.controllerSessionKey)
3759
4036
  : undefined;
4037
+ const explicitProjectName = typeof body.projectName === "string" && body.projectName.trim()
4038
+ ? deriveStableProjectKey(body.projectName)
4039
+ : undefined;
3760
4040
  const recommendedSkills = normalizeRecommendedSkills(
3761
4041
  Array.isArray(body.recommendedSkills) ? body.recommendedSkills.map((entry) => String(entry ?? "")) : [],
3762
4042
  );
@@ -3781,16 +4061,11 @@ async function handleRequest(
3781
4061
  const now = Date.now();
3782
4062
  const repoState = await refreshControllerRepoState(deps);
3783
4063
 
3784
- // Resolve project directory: inherit from parent run, or generate from title
3785
- let projectDir: string | undefined;
3786
- if (controllerSessionKey) {
3787
- const runId = findLatestControllerRunIdForSession(controllerSessionKey, getTeamState(), { preferActive: true });
3788
- const parentRun = runId ? getTeamState()?.controllerRuns[runId] : undefined;
3789
- projectDir = parentRun?.projectDir;
3790
- }
3791
- if (!projectDir) {
3792
- projectDir = deriveProjectSlug(title);
3793
- }
4064
+ const inheritedProject = controllerSessionKey
4065
+ ? resolveProjectIdentityForSession(controllerSessionKey, getTeamState())
4066
+ : undefined;
4067
+ const projectId = inheritedProject?.projectId ?? explicitProjectName || undefined;
4068
+ const projectDir = inheritedProject?.projectDir ?? projectId ?? deriveProjectSlug(title);
3794
4069
 
3795
4070
  const task: TaskInfo = {
3796
4071
  id: taskId,
@@ -3802,12 +4077,22 @@ async function handleRequest(
3802
4077
  createdBy,
3803
4078
  recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined,
3804
4079
  controllerSessionKey,
4080
+ projectId,
3805
4081
  projectDir,
3806
4082
  createdAt: now,
3807
4083
  updatedAt: now,
3808
4084
  };
3809
4085
 
3810
4086
  updateTeamState((s) => {
4087
+ const project = syncProjectRegistryEntry(s, {
4088
+ projectId: task.projectId,
4089
+ projectDir: task.projectDir,
4090
+ aliases: [explicitProjectName, task.projectId],
4091
+ summary: task.title,
4092
+ updatedAt: now,
4093
+ });
4094
+ task.projectId = project.projectId;
4095
+ task.projectDir = project.projectDir;
3811
4096
  s.tasks[taskId] = task;
3812
4097
  });
3813
4098
  recordTaskExecutionEvent(taskId, {
@@ -4409,14 +4694,31 @@ async function handleRequest(
4409
4694
 
4410
4695
  const updatedRun = updateControllerRun(runId, deps, (run) => {
4411
4696
  run.manifest = manifest;
4697
+ const state = deps.getTeamState();
4698
+ if (state) {
4699
+ const project = syncProjectRegistryEntry(state, {
4700
+ projectId: manifest.projectName ?? run.projectId,
4701
+ projectDir: run.projectDir ?? manifest.projectName,
4702
+ aliases: [manifest.projectName, run.projectId],
4703
+ summary: manifest.requirementSummary,
4704
+ updatedAt: Date.now(),
4705
+ });
4706
+ run.projectId = project.projectId;
4707
+ run.projectDir = project.projectDir;
4708
+ }
4412
4709
 
4413
4710
  if (run.projectDir) {
4414
4711
  const state = deps.getTeamState();
4415
4712
  if (state) {
4416
4713
  for (const taskId of run.createdTaskIds) {
4417
4714
  const task = state.tasks[taskId];
4418
- if (task && !task.projectDir) {
4419
- task.projectDir = run.projectDir;
4715
+ if (task) {
4716
+ if (!task.projectDir) {
4717
+ task.projectDir = run.projectDir;
4718
+ }
4719
+ if (!task.projectId) {
4720
+ task.projectId = run.projectId;
4721
+ }
4420
4722
  }
4421
4723
  }
4422
4724
  }
@@ -1,11 +1,13 @@
1
1
  import type {
2
2
  ClarificationQuestionOption,
3
3
  ClarificationQuestionSchema,
4
+ ControllerManifestCompletionOpportunity,
4
5
  ControllerManifestCreatedTask,
5
6
  ControllerManifestDeferredTask,
6
7
  ControllerOrchestrationManifest,
7
8
  RoleId,
8
9
  } from "../types.js";
10
+ import { deriveStableProjectKey } from "../openclaw-workspace.js";
9
11
 
10
12
  const TEAMCLAW_ROLE_IDS = new Set<RoleId>([
11
13
  "pm",
@@ -145,6 +147,20 @@ export function normalizeManifestDeferredTasks(raw: unknown): ControllerManifest
145
147
  .filter((entry) => entry.title && entry.blockedBy && entry.whenReady);
146
148
  }
147
149
 
150
+ export function normalizeManifestCompletionOpportunities(raw: unknown): ControllerManifestCompletionOpportunity[] {
151
+ if (!Array.isArray(raw)) {
152
+ return [];
153
+ }
154
+ return raw
155
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
156
+ .map((entry) => ({
157
+ title: typeof entry.title === "string" ? entry.title.trim() : "",
158
+ value: typeof entry.value === "string" ? entry.value.trim() : "",
159
+ summary: typeof entry.summary === "string" ? entry.summary.trim() : "",
160
+ }))
161
+ .filter((entry) => entry.title && entry.value && entry.summary);
162
+ }
163
+
148
164
  export function normalizeControllerManifest(raw: unknown): ControllerOrchestrationManifest | null {
149
165
  if (!raw || typeof raw !== "object") {
150
166
  return null;
@@ -158,7 +174,9 @@ export function normalizeControllerManifest(raw: unknown): ControllerOrchestrati
158
174
  const clarificationQuestions = normalizeManifestStringList(input.clarificationQuestions);
159
175
  return {
160
176
  version: typeof input.version === "string" && input.version.trim() ? input.version.trim() : "1.0",
161
- projectName: typeof input.projectName === "string" && input.projectName.trim() ? input.projectName.trim() : undefined,
177
+ projectName: typeof input.projectName === "string" && input.projectName.trim()
178
+ ? (deriveStableProjectKey(input.projectName.trim()) || input.projectName.trim())
179
+ : undefined,
162
180
  requirementSummary,
163
181
  requiredRoles: normalizeManifestRoleList(input.requiredRoles),
164
182
  clarificationsNeeded: Boolean(input.clarificationsNeeded),
@@ -168,6 +186,7 @@ export function normalizeControllerManifest(raw: unknown): ControllerOrchestrati
168
186
  deferredTasks: normalizeManifestDeferredTasks(input.deferredTasks),
169
187
  handoffPlan: normalizeOptionalManifestText(input.handoffPlan),
170
188
  notes: normalizeOptionalManifestText(input.notes),
189
+ completionOpportunities: normalizeManifestCompletionOpportunities(input.completionOpportunities),
171
190
  requirementFullyComplete: Boolean(input.requirementFullyComplete),
172
191
  };
173
192
  }
@@ -129,6 +129,7 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
129
129
  parts.push("");
130
130
  parts.push("## Out-of-Scope Requests");
131
131
  parts.push("- TeamClaw is a software development team. You handle: coding, architecture, design, testing, deployment, documentation, security review, and related technical work.");
132
+ parts.push("- Do not infer that the user lacks rights to work on a codebase just because it contains binaries, bundles, minified code, generated artifacts, proprietary branding, or unusual repository structure. Unless the user explicitly states a restriction, keep the decision technical rather than ownership-based.");
132
133
  parts.push("- If the human asks for something clearly non-technical (cooking, weather, personal advice, general knowledge, creative writing unrelated to software), politely decline in your reply text AND still call teamclaw_submit_manifest with createdTasks=[], requiredRoles=[], and requirementSummary explaining why you declined.");
133
134
  parts.push("- If the request is borderline (e.g. 'write a blog post about our API'), lean toward accepting it and assigning to the appropriate role (marketing, pm).");
134
135
  parts.push("- REMEMBER: You must ALWAYS call teamclaw_submit_manifest, even when declining. The system cannot record your decision without it.");
@@ -169,9 +170,17 @@ type ExistingProjectInfo = { dir: string; summary: string };
169
170
  function listExistingProjects(state: TeamState | null): ExistingProjectInfo[] {
170
171
  const projects: ExistingProjectInfo[] = [];
171
172
 
172
- // Gather from completed tasks with projectDir
173
173
  const seenDirs = new Set<string>();
174
174
  if (state) {
175
+ for (const project of Object.values(state.projects ?? {})) {
176
+ if (project.projectDir && !seenDirs.has(project.projectDir)) {
177
+ seenDirs.add(project.projectDir);
178
+ const aliases = project.aliases.filter(Boolean);
179
+ const aliasSummary = aliases.length > 0 ? `aliases: ${aliases.join(", ")}` : "";
180
+ const summary = [project.summary, aliasSummary].filter(Boolean).join(" | ") || "(registered project)";
181
+ projects.push({ dir: project.projectDir, summary });
182
+ }
183
+ }
175
184
  for (const task of Object.values(state.tasks)) {
176
185
  if (task.projectDir && !seenDirs.has(task.projectDir)) {
177
186
  seenDirs.add(task.projectDir);
@@ -1416,7 +1416,7 @@ function buildProvisionedWorkerConfig(
1416
1416
  teamclawConfig.workerProvisioningControllerUrl = "";
1417
1417
  teamclawConfig.workerProvisioningRoles = [];
1418
1418
  teamclawConfig.workerProvisioningMinPerRole = 0;
1419
- teamclawConfig.workerProvisioningMaxPerRole = 1;
1419
+ teamclawConfig.workerProvisioningMaxPerRole = 10;
1420
1420
  teamclawConfig.workerProvisioningIdleTtlMs = controllerConfig.workerProvisioningIdleTtlMs;
1421
1421
  teamclawConfig.workerProvisioningStartupTimeoutMs = controllerConfig.workerProvisioningStartupTimeoutMs;
1422
1422
  teamclawConfig.workerProvisioningImage = "";
@@ -438,13 +438,17 @@ export function buildTeamClawProjectAgentRelativePath(
438
438
  *
439
439
  * Example: "Build a payment system with Stripe" → "build-a-payment-system-with-stripe-k3f9m2"
440
440
  */
441
- export function deriveProjectSlug(text: string): string {
442
- const slug = text
441
+ export function deriveStableProjectKey(text: string): string {
442
+ return text
443
443
  .toLowerCase()
444
444
  .replace(/[^a-z0-9]+/g, "-")
445
445
  .replace(/^-+|-+$/g, "")
446
446
  .slice(0, 50)
447
- .replace(/-+$/, "");
447
+ .replace(/-+$/g, "");
448
+ }
449
+
450
+ export function deriveProjectSlug(text: string): string {
451
+ const slug = deriveStableProjectKey(text);
448
452
  const suffix = randomSuffix(6);
449
453
  return slug ? `${slug}-${suffix}` : suffix;
450
454
  }
package/src/state.ts CHANGED
@@ -100,6 +100,9 @@ async function loadTeamState(teamName: string): Promise<TeamState | null> {
100
100
  if (!parsed.controllerRuns || typeof parsed.controllerRuns !== "object") {
101
101
  parsed.controllerRuns = {};
102
102
  }
103
+ if (!parsed.projects || typeof parsed.projects !== "object") {
104
+ parsed.projects = {};
105
+ }
103
106
  if (!Array.isArray(parsed.messages)) {
104
107
  parsed.messages = [];
105
108
  }
@@ -136,6 +139,9 @@ async function saveTeamState(state: TeamState): Promise<void> {
136
139
  state.controllerRuns = state.controllerRuns && typeof state.controllerRuns === "object"
137
140
  ? state.controllerRuns
138
141
  : {};
142
+ state.projects = state.projects && typeof state.projects === "object"
143
+ ? state.projects
144
+ : {};
139
145
  await enqueueAtomicWrite(filePath, `${JSON.stringify(state, null, 2)}\n`);
140
146
  }
141
147
 
package/src/types.ts CHANGED
@@ -110,6 +110,7 @@ export type TaskInfo = {
110
110
  createdBy: string;
111
111
  recommendedSkills?: string[];
112
112
  controllerSessionKey?: string;
113
+ projectId?: string;
113
114
  projectDir?: string;
114
115
  createdAt: number;
115
116
  updatedAt: number;
@@ -147,6 +148,18 @@ export type TaskAssignmentPayload = {
147
148
  executionSessionKey?: string;
148
149
  executionIdempotencyKey?: string;
149
150
  repo?: RepoSyncInfo;
151
+ teamContext?: {
152
+ requirementSummary?: string;
153
+ projectName?: string;
154
+ requiredRoles?: RoleId[];
155
+ activeTasks?: Array<{ id: string; title: string; assignedRole?: RoleId; status: TaskStatus; summary?: string }>;
156
+ blockedTasks?: Array<{ id: string; title: string; assignedRole?: RoleId; status: TaskStatus; summary?: string }>;
157
+ recentCompletedTasks?: Array<{ id: string; title: string; assignedRole?: RoleId; status: TaskStatus; summary?: string }>;
158
+ pendingClarifications?: Array<{ question: string; blockingReason?: string }>;
159
+ handoffPlan?: string;
160
+ notes?: string;
161
+ kickoffSummary?: string;
162
+ };
150
163
  };
151
164
 
152
165
  export type ControllerRunSource = "human" | "task_follow_up";
@@ -274,6 +287,12 @@ export type ControllerManifestDeferredTask = {
274
287
  whenReady: string;
275
288
  };
276
289
 
290
+ export type ControllerManifestCompletionOpportunity = {
291
+ title: string;
292
+ value: string;
293
+ summary: string;
294
+ };
295
+
277
296
  export type ClarificationQuestionKind = "single-select" | "multi-select" | "number" | "text";
278
297
 
279
298
  export type ClarificationQuestionOption = {
@@ -309,6 +328,7 @@ export type ControllerOrchestrationManifest = {
309
328
  deferredTasks: ControllerManifestDeferredTask[];
310
329
  handoffPlan?: string;
311
330
  notes?: string;
331
+ completionOpportunities?: ControllerManifestCompletionOpportunity[];
312
332
  /** Controller signals that the entire human requirement is fully satisfied — no more tasks or follow-ups needed. */
313
333
  requirementFullyComplete?: boolean;
314
334
  /** Team kickoff plan — populated when controller uses collaborative planning. */
@@ -326,6 +346,7 @@ export type ControllerRunInfo = {
326
346
  request: string;
327
347
  reply?: string;
328
348
  error?: string;
349
+ projectId?: string;
329
350
  projectDir?: string;
330
351
  createdTaskIds: string[];
331
352
  manifest?: ControllerOrchestrationManifest;
@@ -492,6 +513,7 @@ export type TeamState = {
492
513
  workers: Record<string, WorkerInfo>;
493
514
  tasks: Record<string, TaskInfo>;
494
515
  controllerRuns: Record<string, ControllerRunInfo>;
516
+ projects: Record<string, TeamProjectInfo>;
495
517
  messages: TeamMessage[];
496
518
  clarifications: Record<string, ClarificationRequest>;
497
519
  repo?: GitRepoState;
@@ -502,6 +524,16 @@ export type TeamState = {
502
524
  updatedAt: number;
503
525
  };
504
526
 
527
+ export type TeamProjectInfo = {
528
+ id: string;
529
+ projectDir: string;
530
+ aliases: string[];
531
+ summary?: string;
532
+ createdAt: number;
533
+ updatedAt: number;
534
+ lastUsedAt: number;
535
+ };
536
+
505
537
  export type DeliveryReportRecord = {
506
538
  id: string;
507
539
  sessionKey: string;