@nanmicoder/dsh-agent-teams 0.1.13 → 0.1.14

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/lib/tools.js CHANGED
@@ -12,10 +12,11 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { join } from 'node:path';
14
14
  import { appendTeamEvent, captainSessionOf } from "./events.js";
15
- import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
- import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
15
+ import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, } from "./state.js";
16
+ import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, validateMemberLlmSelections, } from "./members.js";
17
17
  import { TERMINAL_TASK_STATUSES } from "./types.js";
18
18
  import { installTeamScheduler } from "./scheduler.js";
19
+ import { resolveTeamProfile } from "./profiles.js";
19
20
  /** The caller agent, or a loud failure for non-agent callers. */
20
21
  function requireCaptain(exec) {
21
22
  if (!exec.agent) {
@@ -101,11 +102,67 @@ function requireTask(team, taskId) {
101
102
  }
102
103
  return task;
103
104
  }
105
+ function requireStagedTeam(team) {
106
+ if (team.phase !== 'staged') {
107
+ throw new Error(`team "${team.name}" is already running; its plan can no longer be edited`);
108
+ }
109
+ if (team.halted === true)
110
+ throw new Error(`team "${team.name}" is halted, not awaiting plan approval`);
111
+ }
112
+ function trimmedOptional(value) {
113
+ const trimmed = value?.trim();
114
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed;
115
+ }
116
+ /** Validate references and cycles before a staged graph can be saved or run. */
117
+ function validateStagedGraph(team, requireRunnable) {
118
+ const members = team.members.filter((member) => member.status !== 'removed');
119
+ if (requireRunnable && members.length === 0)
120
+ throw new Error('add at least one member before approving the plan');
121
+ if (requireRunnable && team.tasks.length === 0)
122
+ throw new Error('add at least one task before approving the plan');
123
+ const memberNames = new Set(members.map((member) => member.name));
124
+ const taskIds = new Set(team.tasks.map((task) => task.id));
125
+ for (const task of team.tasks) {
126
+ if (task.subject.trim() === '')
127
+ throw new Error(`task "${task.id}" must have a subject`);
128
+ if (task.assignee !== undefined && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
129
+ throw new Error(`task "${task.id}" assignee "${task.assignee}" is not an active member`);
130
+ }
131
+ for (const dependency of task.dependencies) {
132
+ if (dependency === task.id)
133
+ throw new Error(`task "${task.id}" cannot depend on itself`);
134
+ if (!taskIds.has(dependency))
135
+ throw new Error(`task "${task.id}" depends on unknown task "${dependency}"`);
136
+ }
137
+ }
138
+ const visiting = new Set();
139
+ const visited = new Set();
140
+ const byId = new Map(team.tasks.map((task) => [task.id, task]));
141
+ const visit = (taskId) => {
142
+ if (visiting.has(taskId))
143
+ throw new Error(`task dependency graph contains a cycle at "${taskId}"`);
144
+ if (visited.has(taskId))
145
+ return;
146
+ visiting.add(taskId);
147
+ for (const dependency of byId.get(taskId)?.dependencies ?? [])
148
+ visit(dependency);
149
+ visiting.delete(taskId);
150
+ visited.add(taskId);
151
+ };
152
+ for (const task of team.tasks)
153
+ visit(task.id);
154
+ }
104
155
  function memberOpenTask(team, memberName, exceptTaskId) {
105
156
  return team.tasks.find(task => task.id !== exceptTaskId
106
157
  && task.assignee === memberName
107
158
  && (task.status === 'claimed' || task.status === 'in_progress'));
108
159
  }
160
+ /** Captain work is immediate, not a durable scheduler lane: allow one unfinished takeover at a time. */
161
+ function captainOpenTask(team, exceptTaskId) {
162
+ return team.tasks.find(task => task.id !== exceptTaskId
163
+ && task.assignee === CAPTAIN_KEY
164
+ && !TERMINAL_TASK_STATUSES.includes(task.status));
165
+ }
109
166
  async function waitForMemberIdle(ctx, member, signal) {
110
167
  if (member.id === '')
111
168
  return;
@@ -126,14 +183,98 @@ async function waitForMemberIdle(ctx, member, signal) {
126
183
  signal.removeEventListener('abort', onAbort);
127
184
  }
128
185
  }
129
- /**
130
- * Deliver a durable member report at the captain's nearest model boundary.
186
+ /** Stop every currently-resident member activation for one halted team.
131
187
  *
132
- * `Agent.steer()` targets the next step while the captain is running, wakes a
133
- * new turn when it is idle, and lets the Agent runtime reclassify an aborted
134
- * activity to `next-turn`. This prevents reports from waiting behind the
135
- * captain's entire orchestration turn.
188
+ * Interrupt requests only cancel the member's current model turn and retain its
189
+ * activation. Draining the selected direct children is the stronger lifecycle
190
+ * boundary: it waits for the activation handles to release, so a child cannot
191
+ * keep executing after the captain-chat Stop control has reported success.
136
192
  */
193
+ async function stopTeamMemberActivations(ctx, captain, members, signal) {
194
+ const activeMembers = members.filter((member) => member.id !== '' && member.status !== 'removed');
195
+ const memberIds = activeMembers.map((member) => member.id);
196
+ if (memberIds.length === 0)
197
+ return;
198
+ for (const memberId of memberIds)
199
+ interruptMember(ctx, captain, memberId);
200
+ // `drainContinuableChildren` is available in the current runtime and releases
201
+ // the selected activation handles. Keep the quiescence fallback for pre-rc.8
202
+ // hosts, where interrupt is the strongest available lifecycle operation.
203
+ const runtime = ctx.subagents;
204
+ if (runtime.drainContinuableChildren !== undefined) {
205
+ try {
206
+ await runtime.drainContinuableChildren(captain, memberIds);
207
+ return;
208
+ }
209
+ catch (error) {
210
+ // Do not claim the browser action stopped work when the runtime could not
211
+ // release all selected child activations. The HTTP route surfaces this
212
+ // failure instead of returning a false successful stop.
213
+ ctx.logger.warn(`agent-teams: failed to drain halted members: ${String(error)}`);
214
+ throw error;
215
+ }
216
+ }
217
+ const fallbackSignal = signal ?? new AbortController().signal;
218
+ const results = await Promise.allSettled(activeMembers.map((member) => waitForMemberIdle(ctx, member, fallbackSignal)));
219
+ const failed = results.find((result) => result.status === 'rejected');
220
+ if (failed?.status === 'rejected')
221
+ throw failed.reason;
222
+ }
223
+ export async function haltTeamWork(input) {
224
+ const halted = await withTeamLock(teamLockKey(input.stateRoot, input.teamId), async () => {
225
+ const fresh = await requireFreshCaptainTeam(input.stateRoot, input.teamId, input.captain.id);
226
+ if (fresh.halted === true) {
227
+ return {
228
+ teamName: fresh.name,
229
+ cancelledTasks: fresh.tasks.filter((task) => task.status === 'cancelled').length,
230
+ alreadyHalted: true,
231
+ members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
232
+ };
233
+ }
234
+ const now = Date.now();
235
+ let cancelledTasks = 0;
236
+ for (const task of fresh.tasks) {
237
+ if (TERMINAL_TASK_STATUSES.includes(task.status))
238
+ continue;
239
+ cancelUnfinishedTask(task, 'Stopped from the captain chat.');
240
+ cancelledTasks += 1;
241
+ }
242
+ for (const member of fresh.members) {
243
+ if (member.status === 'removed')
244
+ continue;
245
+ member.status = 'idle';
246
+ }
247
+ fresh.halted = true;
248
+ fresh.haltedAt = now;
249
+ await writeTeam(input.stateRoot, fresh);
250
+ appendTeamEvent(input.ctx, captainSessionOf(input.ctx, fresh.captainSessionId, input.captain.session), 'agent-teams/team-halted', {
251
+ teamId: fresh.id,
252
+ cancelledTasks,
253
+ });
254
+ return {
255
+ teamName: fresh.name,
256
+ cancelledTasks,
257
+ alreadyHalted: false,
258
+ members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
259
+ };
260
+ });
261
+ // Persist the stop boundary first, then abort the Captain before draining
262
+ // children. Otherwise its current model turn can observe `halted`, call
263
+ // resume, and race the still-running HTTP stop request.
264
+ input.captain.cancel({ kind: 'user' }, { keepInbox: true });
265
+ await stopTeamMemberActivations(input.ctx, input.captain, halted.members, input.signal);
266
+ // Interrupting a child emits a trailing subagent-settled notification. That
267
+ // notification can start a fresh Captain turn after the first cancellation,
268
+ // so close the stop boundary again once every child activation has drained.
269
+ // Queued user input is preserved both times; only runtime-generated work is
270
+ // prevented from silently resuming the halted team.
271
+ input.captain.cancel({ kind: 'user' }, { keepInbox: true });
272
+ return {
273
+ teamName: halted.teamName,
274
+ cancelledTasks: halted.cancelledTasks,
275
+ alreadyHalted: halted.alreadyHalted,
276
+ };
277
+ }
137
278
  export function steerCaptainReport(captain, from, content) {
138
279
  try {
139
280
  captain.steer(createUserMessage({
@@ -147,6 +288,24 @@ export function steerCaptainReport(captain, from, content) {
147
288
  return false;
148
289
  }
149
290
  }
291
+ /** Context queued after the human rejects a staged plan. */
292
+ export function stagedPlanDiscardContext(teamName) {
293
+ return [
294
+ `The user discarded the staged AgentTeams plan "${teamName}" from the pre-run review UI.`,
295
+ 'That decision is final for this draft: it has been archived, no members were created, and no tasks may run.',
296
+ 'Do not call agent_teams_create, agent_teams_approve, or recreate a replacement team merely because the old team is no longer active.',
297
+ 'Wait for a later explicit user request. If the next user message is unrelated to AgentTeams, answer it normally and do not start a team.',
298
+ ].join('\n');
299
+ }
300
+ /** Model-facing continuation that turns the review UI back into a conversation. */
301
+ export function stagedPlanFeedbackContext(teamName) {
302
+ return [
303
+ `The user selected "Return to chat and revise" for the staged AgentTeams plan "${teamName}".`,
304
+ 'The existing staged plan is still the only draft. Do not create a replacement team, approve it, spawn members, edit the plan, or start work in this turn.',
305
+ 'Ask the user one concise, concrete question about what they want changed, then stop and wait for their answer.',
306
+ 'After the user answers, revise this same staged roster and DAG with one atomic agent_teams_edit_plan call, summarize the changes, and ask the user to review the updated plan again.',
307
+ ].join('\n');
308
+ }
150
309
  /**
151
310
  * Register every `agent_teams_*` tool into the shared tools registry.
152
311
  * @param ctx - the plugin context (injects `tools`).
@@ -155,13 +314,246 @@ export function steerCaptainReport(captain, from, content) {
155
314
  export function registerAgentTeamsTools(ctx, config) {
156
315
  installRetiredMemberGuard(ctx, config.stateDir);
157
316
  const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
158
- const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir });
317
+ const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt });
318
+ const updateStagedPlanBatch = async (captain, teamId, mutations, signal) => {
319
+ if (mutations.length === 0)
320
+ throw new Error('at least one staged plan operation is required');
321
+ const workspace = workspaceOf(captain);
322
+ const stateRoot = stateRootOf(workspace, config);
323
+ return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
324
+ const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
325
+ requireStagedTeam(fresh);
326
+ for (const mutation of mutations) {
327
+ if (mutation.action === 'update_member') {
328
+ const member = requireMember(fresh, mutation.memberName);
329
+ if (member.id !== '')
330
+ throw new Error(`staged member "${member.name}" was already spawned`);
331
+ const selection = await resolveMemberLlmSelection(ctx, captain, {
332
+ provider: mutation.provider,
333
+ model: mutation.model,
334
+ reasoningEffort: trimmedOptional(mutation.reasoningEffort),
335
+ fallback: member.fallback,
336
+ }, signal);
337
+ member.role = trimmedOptional(mutation.role);
338
+ member.provider = selection.provider;
339
+ member.model = selection.model;
340
+ member.reasoningEffort = selection.reasoningEffort;
341
+ member.executionPrompt = trimmedOptional(mutation.executionPrompt);
342
+ }
343
+ else if (mutation.action === 'update_task') {
344
+ const task = requireTask(fresh, mutation.taskId);
345
+ if (task.status !== 'pending' || (task.attempt ?? 0) !== 0) {
346
+ throw new Error(`task "${task.id}" has already started and cannot be edited`);
347
+ }
348
+ const subject = mutation.subject.trim();
349
+ if (subject === '')
350
+ throw new Error('task subject must not be empty');
351
+ task.subject = subject;
352
+ task.description = trimmedOptional(mutation.description);
353
+ task.assignee = trimmedOptional(mutation.assignee);
354
+ task.dependencies = [...new Set(mutation.dependencies.map((item) => item.trim()).filter(Boolean))];
355
+ task.updatedAt = Date.now();
356
+ }
357
+ else if (mutation.action === 'add_task') {
358
+ const subject = mutation.subject.trim();
359
+ if (subject === '')
360
+ throw new Error('task subject must not be empty');
361
+ fresh.taskSeq += 1;
362
+ const now = Date.now();
363
+ fresh.tasks.push({
364
+ id: `t${fresh.taskSeq}`,
365
+ subject,
366
+ description: trimmedOptional(mutation.description),
367
+ status: 'pending',
368
+ assignee: trimmedOptional(mutation.assignee),
369
+ dependencies: [...new Set(mutation.dependencies.map((item) => item.trim()).filter(Boolean))],
370
+ attempt: 0,
371
+ kind: 'work',
372
+ createdAt: now,
373
+ updatedAt: now,
374
+ });
375
+ }
376
+ else if (mutation.action === 'remove_task') {
377
+ const task = requireTask(fresh, mutation.taskId);
378
+ const dependent = fresh.tasks.find((candidate) => candidate.dependencies.includes(task.id));
379
+ if (dependent !== undefined) {
380
+ throw new Error(`task "${task.id}" is still required by "${dependent.id}"; update that dependency before removing the task`);
381
+ }
382
+ fresh.tasks = fresh.tasks.filter((candidate) => candidate.id !== task.id);
383
+ }
384
+ else {
385
+ const member = requireMember(fresh, mutation.memberName);
386
+ if (member.id !== '')
387
+ throw new Error(`staged member "${member.name}" was already spawned`);
388
+ const owned = fresh.tasks.filter((task) => task.assignee === member.name);
389
+ if (owned.length > 0) {
390
+ throw new Error(`member "${member.name}" still owns planned tasks: ${owned.map((task) => task.id).join(', ')}; update or remove those tasks first`);
391
+ }
392
+ fresh.members = fresh.members.filter((candidate) => candidate !== member);
393
+ }
394
+ }
395
+ validateStagedGraph(fresh, false);
396
+ fresh.planReviewState = 'awaiting_review';
397
+ await writeTeam(stateRoot, fresh);
398
+ return fresh;
399
+ });
400
+ };
401
+ const updateStagedPlan = async (captain, teamId, mutation, signal) => (updateStagedPlanBatch(captain, teamId, [mutation], signal));
402
+ const approveStagedTeam = async (captain, teamId, signal) => {
403
+ const workspace = workspaceOf(captain);
404
+ const stateRoot = stateRootOf(workspace, config);
405
+ const runSignal = signal ?? new AbortController().signal;
406
+ const approved = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
407
+ const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
408
+ requireStagedTeam(fresh);
409
+ // A staged removal has no child session to retain in history. Drop those
410
+ // placeholders before transitioning to the stricter running shape.
411
+ fresh.members = fresh.members.filter((member) => member.status !== 'removed');
412
+ validateStagedGraph(fresh, true);
413
+ const spawned = [];
414
+ try {
415
+ const selections = new Map();
416
+ for (const member of fresh.members) {
417
+ if (member.id !== '')
418
+ continue;
419
+ const selection = await resolveMemberLlmSelection(ctx, captain, {
420
+ provider: member.provider,
421
+ model: member.model,
422
+ reasoningEffort: member.reasoningEffort,
423
+ fallback: member.fallback,
424
+ }, runSignal);
425
+ selections.set(member, selection);
426
+ member.provider = selection.provider;
427
+ member.model = selection.model;
428
+ member.reasoningEffort = selection.reasoningEffort;
429
+ }
430
+ // This is the approval commit barrier: resolve and validate the whole
431
+ // final roster before spawning even the first durable child.
432
+ await validateMemberLlmSelections(ctx, [...selections.values()], runSignal);
433
+ for (const [member, selection] of selections) {
434
+ await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, runSignal);
435
+ spawned.push(member);
436
+ }
437
+ if (fresh.members.some((member) => member.id === '')) {
438
+ throw new Error('one or more staged members could not be spawned');
439
+ }
440
+ fresh.phase = 'running';
441
+ delete fresh.planReviewState;
442
+ fresh.approvedAt = Date.now();
443
+ await writeTeam(stateRoot, fresh);
444
+ return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
445
+ }
446
+ catch (error) {
447
+ await recordRetiredMemberIds(stateRoot, spawned.map((member) => member.id)).catch(() => undefined);
448
+ for (const member of spawned) {
449
+ if (member.id !== '')
450
+ interruptMember(ctx, captain, member.id);
451
+ }
452
+ throw error;
453
+ }
454
+ });
455
+ try {
456
+ await scheduler.kickTeam(workspace, teamId, captain);
457
+ }
458
+ catch (error) {
459
+ // Approval is already durably committed. A transient wake-up failure is
460
+ // recoverable by the next status/member lifecycle kick and must not make
461
+ // the UI report that an already-running team failed to approve.
462
+ ctx.logger.warn(`agent-teams: post-approval kick failed for "${teamId}": ${String(error)}`);
463
+ }
464
+ return approved;
465
+ };
466
+ const continueStagedPlanning = async (captain, teamId) => {
467
+ const workspace = workspaceOf(captain);
468
+ const stateRoot = stateRootOf(workspace, config);
469
+ const prepared = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
470
+ const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
471
+ requireStagedTeam(fresh);
472
+ if (fresh.planReviewState === 'awaiting_feedback') {
473
+ return { teamName: fresh.name, alreadyWaiting: true };
474
+ }
475
+ fresh.planReviewState = 'awaiting_feedback';
476
+ await writeTeam(stateRoot, fresh);
477
+ return { teamName: fresh.name, alreadyWaiting: false };
478
+ });
479
+ if (prepared.alreadyWaiting)
480
+ return { teamId, alreadyWaiting: true };
481
+ // End any planning turn that is still producing tool calls. A plugin
482
+ // follow-up submitted after cancellation is queued as the next turn by the
483
+ // Harness Agent contract, so it cannot race ahead and recreate the team.
484
+ captain.cancel({ kind: 'user' }, { keepInbox: true });
485
+ try {
486
+ captain.followup(createUserMessage({
487
+ content: [{ type: 'text', text: stagedPlanFeedbackContext(prepared.teamName) }],
488
+ source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
489
+ }));
490
+ }
491
+ catch (error) {
492
+ // Do not leave the durable UI in a false waiting state when the live
493
+ // Captain disappeared between lookup and delivery.
494
+ await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
495
+ const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
496
+ requireStagedTeam(fresh);
497
+ if (fresh.planReviewState === 'awaiting_feedback') {
498
+ fresh.planReviewState = 'awaiting_review';
499
+ await writeTeam(stateRoot, fresh);
500
+ }
501
+ });
502
+ throw error;
503
+ }
504
+ return { teamId, alreadyWaiting: false };
505
+ };
506
+ const discardStagedTeam = async (captain, teamId) => {
507
+ const workspace = workspaceOf(captain);
508
+ const stateRoot = stateRootOf(workspace, config);
509
+ const discarded = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
510
+ const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
511
+ requireStagedTeam(fresh);
512
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/plan-discarded', {
513
+ teamId: fresh.id,
514
+ });
515
+ // A staged plan owns no child sessions. Archiving releases the captain
516
+ // immediately while retaining the rejected graph for later inspection.
517
+ await archiveTeamDir(stateRoot, fresh.id);
518
+ return { teamId: fresh.id, teamName: fresh.name };
519
+ });
520
+ // Preserve this control fact for the next genuine user turn, then abort the
521
+ // still-running Captain turn. Without both operations a late model step can
522
+ // observe the missing active team and incorrectly create it again.
523
+ try {
524
+ captain.inject(createUserMessage({
525
+ content: [{ type: 'text', text: stagedPlanDiscardContext(discarded.teamName) }],
526
+ source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
527
+ }));
528
+ }
529
+ catch (error) {
530
+ // The archive is already authoritative. Cancellation still prevents a
531
+ // late step from recreating work; failure to park extra context is only a
532
+ // live-delivery warning and must not turn a successful discard into 409.
533
+ ctx.logger.warn(`agent-teams: failed to inject discard context for "${discarded.teamId}": ${String(error)}`);
534
+ }
535
+ captain.cancel({ kind: 'user' }, { keepInbox: true });
536
+ return { teamId: discarded.teamId };
537
+ };
538
+ const runtime = {
539
+ updateStagedPlan,
540
+ updateStagedPlanBatch,
541
+ approveStagedTeam,
542
+ continueStagedPlanning,
543
+ discardStagedTeam,
544
+ };
159
545
  ctx.tools.register(defineTool({
160
546
  name: 'agent_teams_create',
161
- description: 'Create a new AgentTeams team: you (the calling agent) become the captain. A captain leads one team at a time; create tasks and members afterwards with agent_teams_add_member and agent_teams_create_task.',
547
+ description: 'Create a team. Use approval=required for a two-phase plan: members and tasks remain unspawned/unclaimed until the user reviews the Web plan and explicitly approves it. Optional profiles expand their configured roster; seed profiles also expand template tasks, while captain profiles leave the graph for the Captain to design. approval=automatic preserves the legacy immediate-execution path.',
162
548
  parameters: {
163
549
  name: { type: 'string', required: true, description: 'Name for the new team (used as its stable id).' },
164
550
  description: { type: 'string', description: 'Team purpose / the goal the team will work on.' },
551
+ profile: { type: 'string', description: 'Optional configured profile name.' },
552
+ approval: {
553
+ type: 'string',
554
+ enum: ['required', 'automatic'],
555
+ description: 'required stages the plan for explicit user review; automatic starts immediately. Defaults to automatic for API compatibility.',
556
+ },
165
557
  },
166
558
  output: {
167
559
  schema: {
@@ -171,11 +563,18 @@ export function registerAgentTeamsTools(ctx, config) {
171
563
  team_id: { type: 'string', required: true },
172
564
  team_name: { type: 'string', required: true },
173
565
  state_dir: { type: 'string', required: true },
566
+ phase: { type: 'string', required: true },
567
+ profile: { type: 'string' },
568
+ task_planning: { type: 'string' },
569
+ members: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { member_name: { type: 'string', required: true }, member_id: { type: 'string', required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, reasoning_effort: { type: 'string' }, status: { type: 'string', required: true } } } },
570
+ tasks: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { task_id: { type: 'string', required: true }, seed_id: { type: 'string', required: true }, subject: { type: 'string', required: true }, status: { type: 'string', required: true }, kind: { type: 'string' }, assignee: { type: 'string' }, dependencies: { type: 'array', items: { type: 'string' }, required: true } } } },
174
571
  },
175
572
  },
176
573
  render: (args, value) => [{
177
574
  type: 'text',
178
- text: `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`,
575
+ text: value.phase === 'staged'
576
+ ? `Team "${value.team_name}" plan created under ${value.state_dir}. It is staged: finish the roster and DAG, then wait for the user to edit and approve it. Do not start or approve it yourself.`
577
+ : `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`,
179
578
  }],
180
579
  },
181
580
  async execute(args, exec) {
@@ -186,7 +585,12 @@ export function registerAgentTeamsTools(ctx, config) {
186
585
  if (teamName === '')
187
586
  throw new Error('team name must not be empty');
188
587
  const teamId = sanitizeKey(teamName);
189
- return withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
588
+ const staged = args.approval === 'required';
589
+ const profileName = args.profile?.trim();
590
+ if (args.profile !== undefined && profileName === '') {
591
+ throw new Error('AgentTeams profile name must not be empty');
592
+ }
593
+ const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
190
594
  const current = await findTeamByParticipant(stateRoot, captain.id);
191
595
  if (current !== undefined) {
192
596
  const relationship = current.captainSessionId === captain.id ? 'lead' : 'belong to';
@@ -197,37 +601,276 @@ export function registerAgentTeamsTools(ctx, config) {
197
601
  if (existing !== undefined) {
198
602
  throw new Error(`team id "${teamId}" is taken by another captain — pick a different team name`);
199
603
  }
200
- const state = {
201
- name: teamName,
202
- id: teamId,
604
+ if (profileName === undefined) {
605
+ const state = {
606
+ name: teamName,
607
+ id: teamId,
608
+ description: args.description,
609
+ captainSessionId: captain.id,
610
+ createdAt: Date.now(),
611
+ members: [],
612
+ tasks: [],
613
+ taskSeq: 0,
614
+ ...staged ? { phase: 'staged', planReviewState: 'awaiting_review' } : {},
615
+ };
616
+ await createTeamDir(stateRoot, state);
617
+ return { committed: true, state };
618
+ }
619
+ return initializeProfileTeam({
620
+ ctx,
621
+ config,
622
+ memberSelections,
623
+ captain,
624
+ exec,
625
+ stateRoot,
626
+ teamName,
627
+ teamId,
628
+ profileName,
203
629
  description: args.description,
204
- captainSessionId: captain.id,
205
- createdAt: Date.now(),
206
- members: [],
207
- tasks: [],
208
- taskSeq: 0,
209
- };
210
- await createTeamDir(stateRoot, state);
630
+ staged,
631
+ });
632
+ });
633
+ });
634
+ if (created.committed) {
635
+ try {
636
+ await scheduler.kickTeam(workspace, created.state.id, captain);
637
+ }
638
+ catch (error) {
639
+ ctx.logger.warn(`agent-teams: post-create kick failed for "${created.state.id}": ${String(error)}`);
640
+ }
641
+ try {
211
642
  appendTeamEvent(ctx, captain.session, 'agent-teams/team-created', {
212
- teamId: state.id,
643
+ teamId: created.state.id,
213
644
  captainSessionId: captain.id,
214
- name: state.name,
215
- ...state.description !== undefined ? { description: state.description } : {},
645
+ name: created.state.name,
646
+ ...created.state.description !== undefined ? { description: created.state.description } : {},
647
+ ...created.state.profile?.name === undefined ? {} : { profile: created.state.profile.name },
216
648
  });
217
- return { team_id: state.id, team_name: state.name, state_dir: join(stateRoot, state.id) };
218
- });
649
+ for (const member of created.state.members) {
650
+ appendTeamEvent(ctx, captain.session, 'agent-teams/member-added', {
651
+ teamId: created.state.id,
652
+ memberId: member.id,
653
+ name: member.name,
654
+ ...member.role === undefined ? {} : { role: member.role },
655
+ });
656
+ }
657
+ for (const task of created.state.tasks) {
658
+ appendTeamEvent(ctx, captain.session, 'agent-teams/task-created', {
659
+ teamId: created.state.id,
660
+ taskId: task.id,
661
+ subject: task.subject,
662
+ dependencies: task.dependencies,
663
+ ...task.assignee === undefined ? {} : { assignee: task.assignee },
664
+ });
665
+ }
666
+ }
667
+ catch (error) {
668
+ ctx.logger.warn(`agent-teams: post-create events failed for "${created.state.id}": ${String(error)}`);
669
+ }
670
+ }
671
+ const persisted = await readTeam(stateRoot, created.state.id).catch(() => undefined);
672
+ const snapshot = persisted ?? created.state;
673
+ if (snapshot.profile === undefined) {
674
+ return {
675
+ team_id: snapshot.id,
676
+ team_name: snapshot.name,
677
+ state_dir: join(stateRoot, snapshot.id),
678
+ phase: snapshot.phase ?? 'running',
679
+ };
680
+ }
681
+ return {
682
+ team_id: snapshot.id,
683
+ team_name: snapshot.name,
684
+ state_dir: join(stateRoot, snapshot.id),
685
+ phase: snapshot.phase ?? 'running',
686
+ profile: snapshot.profile.name,
687
+ task_planning: snapshot.profile.taskPlanning ?? 'seed',
688
+ members: snapshot.members.map((member) => ({
689
+ member_name: member.name,
690
+ member_id: member.id,
691
+ provider: member.provider ?? '',
692
+ model: member.model ?? '',
693
+ ...member.reasoningEffort === undefined ? {} : { reasoning_effort: member.reasoningEffort },
694
+ status: member.status,
695
+ })),
696
+ tasks: snapshot.tasks.map((task) => ({
697
+ task_id: task.id,
698
+ seed_id: task.profileSeedId ?? '',
699
+ subject: task.subject,
700
+ status: task.status,
701
+ ...task.kind === undefined ? {} : { kind: task.kind },
702
+ ...task.assignee === undefined ? {} : { assignee: task.assignee },
703
+ dependencies: task.dependencies,
704
+ })),
705
+ };
706
+ },
707
+ }));
708
+ ctx.tools.register(defineTool({
709
+ name: 'agent_teams_edit_plan',
710
+ description: 'Atomically revise the current staged AgentTeams plan without spawning members or scheduling tasks. Use this when the user continues chatting to change a plan that is waiting for approval. Submit dependent edits in order (update downstream dependencies or assignees, then remove tasks, then remove unused members). Never inspect or edit .agent-teams state files or plugin source code to revise a plan.',
711
+ parameters: {
712
+ operations: {
713
+ type: 'array',
714
+ required: true,
715
+ description: 'One atomic, ordered batch of staged-plan edits. If any operation is invalid, none of the edits are saved.',
716
+ items: {
717
+ type: 'object',
718
+ additionalProperties: false,
719
+ properties: {
720
+ action: {
721
+ type: 'string',
722
+ required: true,
723
+ enum: ['update_member', 'update_task', 'add_task', 'remove_task', 'remove_member'],
724
+ },
725
+ member_name: { type: 'string', description: 'Member name for update_member or remove_member.' },
726
+ task_id: { type: 'string', description: 'Task id for update_task or remove_task.' },
727
+ subject: { type: 'string', description: 'Required for add_task; optional replacement for update_task.' },
728
+ description: { type: 'string', description: 'Optional task description.' },
729
+ assignee: { type: 'string', description: 'Optional task assignee; an empty string moves it to the shared pool.' },
730
+ dependencies: { type: 'array', items: { type: 'string' }, description: 'Complete replacement dependency list for a task.' },
731
+ role: { type: 'string', description: 'Optional member role.' },
732
+ provider: { type: 'string', description: 'Optional member provider; defaults to the current staged route.' },
733
+ model: { type: 'string', description: 'Optional member model; defaults to the current staged route.' },
734
+ reasoning_effort: { type: 'string', description: 'Optional member reasoning effort.' },
735
+ execution_prompt: { type: 'string', description: 'Optional member-specific execution prompt.' },
736
+ },
737
+ },
738
+ },
739
+ },
740
+ output: {
741
+ schema: {
742
+ type: 'object',
743
+ additionalProperties: false,
744
+ properties: {
745
+ status: { type: 'string', required: true },
746
+ team_id: { type: 'string', required: true },
747
+ members: { type: 'number', required: true },
748
+ tasks: { type: 'number', required: true },
749
+ dependencies: { type: 'number', required: true },
750
+ roster: { type: 'array', items: { type: 'string' }, required: true },
751
+ graph: { type: 'array', items: { type: 'string' }, required: true },
752
+ },
753
+ },
754
+ render: (_args, value) => [{
755
+ type: 'text',
756
+ text: `Staged plan updated atomically (${value.members} members, ${value.tasks} tasks, ${value.dependencies} dependencies). No members were spawned and no tasks were scheduled.\n${value.graph.join('\n')}`,
757
+ }],
758
+ },
759
+ async execute(args, exec) {
760
+ const captain = requireCaptain(exec);
761
+ const workspace = workspaceOf(captain);
762
+ const team = await requireCaptainTeam(workspace, config, captain);
763
+ requireStagedTeam(team);
764
+ if (args.operations.length === 0)
765
+ throw new Error('at least one staged plan operation is required');
766
+ const mutations = args.operations.map((operation, index) => {
767
+ const label = `operation ${index + 1} (${operation.action})`;
768
+ if (operation.action === 'update_member') {
769
+ const memberName = operation.member_name?.trim() ?? '';
770
+ if (memberName === '')
771
+ throw new Error(`${label} requires member_name`);
772
+ const member = requireMember(team, memberName);
773
+ return {
774
+ action: 'update_member',
775
+ memberName,
776
+ role: operation.role ?? member.role,
777
+ provider: operation.provider?.trim() || member.provider || '',
778
+ model: operation.model?.trim() || member.model || '',
779
+ reasoningEffort: operation.reasoning_effort ?? member.reasoningEffort,
780
+ executionPrompt: operation.execution_prompt ?? member.executionPrompt,
781
+ };
782
+ }
783
+ if (operation.action === 'update_task') {
784
+ const taskId = operation.task_id?.trim() ?? '';
785
+ if (taskId === '')
786
+ throw new Error(`${label} requires task_id`);
787
+ const task = requireTask(team, taskId);
788
+ return {
789
+ action: 'update_task',
790
+ taskId,
791
+ subject: operation.subject ?? task.subject,
792
+ description: operation.description ?? task.description,
793
+ assignee: operation.assignee ?? task.assignee,
794
+ dependencies: operation.dependencies ?? task.dependencies,
795
+ };
796
+ }
797
+ if (operation.action === 'add_task') {
798
+ const subject = operation.subject?.trim() ?? '';
799
+ if (subject === '')
800
+ throw new Error(`${label} requires a non-empty subject`);
801
+ return {
802
+ action: 'add_task',
803
+ subject,
804
+ description: operation.description,
805
+ assignee: operation.assignee,
806
+ dependencies: operation.dependencies ?? [],
807
+ };
808
+ }
809
+ if (operation.action === 'remove_task') {
810
+ const taskId = operation.task_id?.trim() ?? '';
811
+ if (taskId === '')
812
+ throw new Error(`${label} requires task_id`);
813
+ return { action: 'remove_task', taskId };
814
+ }
815
+ const memberName = operation.member_name?.trim() ?? '';
816
+ if (memberName === '')
817
+ throw new Error(`${label} requires member_name`);
818
+ return { action: 'remove_member', memberName };
219
819
  });
820
+ const updated = await updateStagedPlanBatch(captain, team.id, mutations, exec.signal);
821
+ return {
822
+ status: 'staged',
823
+ team_id: updated.id,
824
+ members: updated.members.length,
825
+ tasks: updated.tasks.length,
826
+ dependencies: updated.tasks.reduce((sum, task) => sum + task.dependencies.length, 0),
827
+ roster: updated.members.map((member) => `${member.name} (${member.role || 'member'}; ${member.provider ?? ''}/${member.model ?? ''})`),
828
+ graph: updated.tasks.map((task) => `${task.id}: ${task.subject} -> ${task.assignee || 'shared'}${task.dependencies.length === 0 ? '' : `; depends on ${task.dependencies.join(', ')}`}`),
829
+ };
830
+ },
831
+ }));
832
+ ctx.tools.register(defineTool({
833
+ name: 'agent_teams_approve',
834
+ description: 'Approve and start a staged team plan. Call this only in response to an explicit user approval in a new user turn; never call it during the turn that created or edited the plan. The Web Approve & Run button uses the same runtime directly.',
835
+ parameters: {
836
+ confirmation: { type: 'string', required: true, description: 'The user\'s explicit approval statement.' },
837
+ },
838
+ output: {
839
+ schema: {
840
+ type: 'object',
841
+ additionalProperties: false,
842
+ properties: {
843
+ status: { type: 'string', required: true },
844
+ team_id: { type: 'string', required: true },
845
+ members: { type: 'number', required: true },
846
+ tasks: { type: 'number', required: true },
847
+ },
848
+ },
849
+ render: (_args, value) => [{
850
+ type: 'text',
851
+ text: `Team ${value.team_id} approved and running (${value.members} members, ${value.tasks} tasks).`,
852
+ }],
853
+ },
854
+ async execute(args, exec) {
855
+ if (args.confirmation.trim() === '')
856
+ throw new Error('explicit user approval text is required');
857
+ const captain = requireCaptain(exec);
858
+ const workspace = workspaceOf(captain);
859
+ const team = await requireCaptainTeam(workspace, config, captain);
860
+ const approved = await approveStagedTeam(captain, team.id, exec.signal);
861
+ return { status: 'running', team_id: approved.teamId, members: approved.members, tasks: approved.tasks };
220
862
  },
221
863
  }));
222
864
  ctx.tools.register(defineTool({
223
865
  name: 'agent_teams_add_member',
224
- description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM route and effort. Supply provider/model only for an explicitly requested role-specific route; a changed provider or model automatically uses the target model\'s default effort. Set reasoning_effort only to request one of the target model\'s supported ids explicitly (or "default" to force its default). The member waits for messages, works on assigned tasks, and can message the team.',
866
+ description: 'Add a member to the team roster. In a staged team this only adds an editable plan row and does not spawn a child; approval spawns the final configuration. In a running team it creates the durable continuable member immediately.',
225
867
  parameters: {
226
868
  name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
227
869
  role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
228
870
  provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
229
871
  model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
230
872
  reasoning_effort: { type: 'string', description: 'Optional reasoning effort override: one of the target model\'s supported effort ids, or "default" to force its default. When omitted, the captain\'s effort is inherited only for the same provider/model; a changed route uses the target default.' },
873
+ executionPrompt: { type: 'string', description: 'Optional member-specific execution prompt. It remains editable while staged.' },
231
874
  },
232
875
  output: {
233
876
  schema: {
@@ -240,11 +883,14 @@ export function registerAgentTeamsTools(ctx, config) {
240
883
  model: { type: 'string', required: true },
241
884
  reasoning_effort: { type: 'string' },
242
885
  status: { type: 'string', required: true },
886
+ phase: { type: 'string', required: true },
243
887
  },
244
888
  },
245
889
  render: (args, value) => [{
246
890
  type: 'text',
247
- text: `Member "${value.member_name}" added (subagent id ${value.member_id}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
891
+ text: value.phase === 'staged'
892
+ ? `Member "${value.member_name}" added to the staged roster (${value.provider}/${value.model}); no child was spawned.`
893
+ : `Member "${value.member_name}" added (subagent id ${value.member_id}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
248
894
  }],
249
895
  },
250
896
  async execute(args, exec) {
@@ -272,6 +918,7 @@ export function registerAgentTeamsTools(ctx, config) {
272
918
  model: args.model,
273
919
  defaultModel: config.memberModel,
274
920
  reasoningEffort: args.reasoning_effort,
921
+ fallback: config.fallback,
275
922
  }, exec.signal);
276
923
  const member = {
277
924
  id: '',
@@ -280,10 +927,13 @@ export function registerAgentTeamsTools(ctx, config) {
280
927
  provider: selection.provider,
281
928
  model: selection.model,
282
929
  reasoningEffort: selection.reasoningEffort,
930
+ executionPrompt: trimmedOptional(args.executionPrompt),
283
931
  joinedAt: Date.now(),
284
932
  status: 'idle',
285
933
  };
286
- await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
934
+ if (fresh.phase !== 'staged') {
935
+ await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
936
+ }
287
937
  fresh.members.push(member);
288
938
  try {
289
939
  await writeTeam(stateRoot, fresh);
@@ -313,6 +963,7 @@ export function registerAgentTeamsTools(ctx, config) {
313
963
  ? {}
314
964
  : { reasoning_effort: selection.reasoningEffort },
315
965
  status: member.status,
966
+ phase: fresh.phase ?? 'running',
316
967
  };
317
968
  });
318
969
  await scheduler.kickMember(workspace, team.id, created.member_name, captain);
@@ -379,9 +1030,9 @@ export function registerAgentTeamsTools(ctx, config) {
379
1030
  }));
380
1031
  ctx.tools.register(defineTool({
381
1032
  name: 'agent_teams_create_task',
382
- description: 'Create a task in your team\'s task list. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
1033
+ description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
383
1034
  parameters: {
384
- subject: { type: 'string', required: true, description: 'Brief title for the task.' },
1035
+ subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
385
1036
  description: { type: 'string', description: 'What needs to be done, in detail.' },
386
1037
  dependencies: {
387
1038
  type: 'array',
@@ -389,6 +1040,25 @@ export function registerAgentTeamsTools(ctx, config) {
389
1040
  description: 'Task ids this task depends on (must be completed before this task can be claimed).',
390
1041
  },
391
1042
  assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
1043
+ kind: {
1044
+ type: 'string',
1045
+ enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
1046
+ description: 'Task kind. Defaults to work (legacy, no quality gates). Quality kinds require a contract.',
1047
+ },
1048
+ round: { type: 'number', description: '1-based review / requirements / repair round.' },
1049
+ objective: { type: 'string', description: 'Required non-empty objective for quality kinds.' },
1050
+ inScope: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative POSIX paths this task may change.' },
1051
+ outOfScope: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative POSIX paths this task must not change.' },
1052
+ acceptance: { type: 'array', items: { type: 'string' }, description: 'Acceptance criteria. Required for quality kinds.' },
1053
+ verify: { type: 'array', items: { type: 'string' }, description: 'Verification commands. Required for implementation/repair.' },
1054
+ deliverables: { type: 'array', items: { type: 'string' }, description: 'Expected deliverable paths or names.' },
1055
+ nonGoals: { type: 'array', items: { type: 'string' }, description: 'Explicit non-goals.' },
1056
+ reviewedTaskId: { type: 'string', description: 'Task being reviewed. Required for kind=review.' },
1057
+ sourceTaskId: { type: 'string', description: 'Source implementation/artifact. Required for kind=repair.' },
1058
+ sourceFindingIds: { type: 'array', items: { type: 'string' }, description: 'Finding ids this repair must close.' },
1059
+ coverageOf: { type: 'array', items: { type: 'string' }, description: 'User-constraint / goal items this task covers.' },
1060
+ resume: { type: 'boolean', description: 'If true, clear halted in the same lock before creating the task.' },
1061
+ resumeReason: { type: 'string', description: 'Required non-empty reason when resume=true.' },
392
1062
  },
393
1063
  output: {
394
1064
  schema: {
@@ -413,6 +1083,41 @@ export function registerAgentTeamsTools(ctx, config) {
413
1083
  const team = await requireCaptainTeam(workspace, config, captain);
414
1084
  const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
415
1085
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1086
+ const gate = validateCreateTask(fresh, {
1087
+ subject: args.subject,
1088
+ description: args.description,
1089
+ dependencies: args.dependencies,
1090
+ assignee: args.assignee,
1091
+ kind: args.kind,
1092
+ round: args.round,
1093
+ objective: args.objective,
1094
+ inScope: args.inScope,
1095
+ outOfScope: args.outOfScope,
1096
+ acceptance: args.acceptance,
1097
+ verify: args.verify,
1098
+ deliverables: args.deliverables,
1099
+ nonGoals: args.nonGoals,
1100
+ reviewedTaskId: args.reviewedTaskId,
1101
+ sourceTaskId: args.sourceTaskId,
1102
+ sourceFindingIds: args.sourceFindingIds,
1103
+ coverageOf: args.coverageOf,
1104
+ resume: args.resume,
1105
+ resumeReason: args.resumeReason,
1106
+ });
1107
+ if (!gate.ok)
1108
+ throw new Error(gate.error ?? 'create_task rejected by quality gates');
1109
+ if (fresh.halted === true) {
1110
+ const resumed = resumeTeamState(fresh, args.resumeReason ?? '');
1111
+ if (resumed.status !== 'resumed' || resumed.team === undefined) {
1112
+ throw new Error(resumed.error ?? 'team is halted; call agent_teams_resume or pass resume=true with resumeReason');
1113
+ }
1114
+ fresh.halted = false;
1115
+ fresh.haltedAt = undefined;
1116
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-resumed', {
1117
+ teamId: fresh.id,
1118
+ reason: args.resumeReason ?? '',
1119
+ });
1120
+ }
416
1121
  const dependencies = args.dependencies ?? [];
417
1122
  for (const dependency of dependencies) {
418
1123
  if (!fresh.tasks.some((task) => task.id === dependency)) {
@@ -421,6 +1126,13 @@ export function registerAgentTeamsTools(ctx, config) {
421
1126
  }
422
1127
  if (args.assignee !== undefined)
423
1128
  requireMember(fresh, args.assignee);
1129
+ const kind = gate.kind ?? 'work';
1130
+ const objective = kind === 'review' || kind === 'requirements'
1131
+ ? sanitizeReviewObjective(args.objective)
1132
+ : args.objective;
1133
+ const acceptance = kind === 'review' || kind === 'requirements'
1134
+ ? sanitizeReviewAcceptance(args.acceptance)
1135
+ : args.acceptance;
424
1136
  const task = {
425
1137
  id: `t${fresh.taskSeq + 1}`,
426
1138
  subject: args.subject,
@@ -431,6 +1143,19 @@ export function registerAgentTeamsTools(ctx, config) {
431
1143
  attempt: 0,
432
1144
  createdAt: Date.now(),
433
1145
  updatedAt: Date.now(),
1146
+ kind,
1147
+ ...args.round === undefined ? {} : { round: args.round },
1148
+ ...objective === undefined ? {} : { objective },
1149
+ ...args.inScope === undefined ? {} : { inScope: args.inScope },
1150
+ ...args.outOfScope === undefined ? {} : { outOfScope: args.outOfScope },
1151
+ ...acceptance === undefined ? {} : { acceptance },
1152
+ ...args.verify === undefined ? {} : { verify: args.verify },
1153
+ ...args.deliverables === undefined ? {} : { deliverables: args.deliverables },
1154
+ ...args.nonGoals === undefined ? {} : { nonGoals: args.nonGoals },
1155
+ ...args.reviewedTaskId === undefined ? {} : { reviewedTaskId: args.reviewedTaskId },
1156
+ ...args.sourceTaskId === undefined ? {} : { sourceTaskId: args.sourceTaskId },
1157
+ ...args.sourceFindingIds === undefined ? {} : { sourceFindingIds: args.sourceFindingIds },
1158
+ ...args.coverageOf === undefined ? {} : { coverageOf: args.coverageOf },
434
1159
  };
435
1160
  fresh.taskSeq += 1;
436
1161
  fresh.tasks.push(task);
@@ -441,6 +1166,8 @@ export function registerAgentTeamsTools(ctx, config) {
441
1166
  subject: task.subject,
442
1167
  dependencies: task.dependencies,
443
1168
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
1169
+ ...task.kind === undefined ? {} : { kind: task.kind },
1170
+ ...task.round === undefined ? {} : { round: task.round },
444
1171
  });
445
1172
  return {
446
1173
  task_id: task.id,
@@ -455,7 +1182,7 @@ export function registerAgentTeamsTools(ctx, config) {
455
1182
  }));
456
1183
  ctx.tools.register(defineTool({
457
1184
  name: 'agent_teams_reassign_task',
458
- description: 'Atomically retry, reassign, or let the captain take over any unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" for captain takeover.',
1185
+ description: 'Atomically retry, reassign, or let the captain take over one ready unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" only when you will finish that task in this turn; a captain can own only one unfinished takeover at a time, and an unfinished takeover returns to the member pool when the captain becomes idle.',
459
1186
  parameters: {
460
1187
  task_id: { type: 'string', required: true, description: 'Task to retry/reassign.' },
461
1188
  assignee: { type: 'string', required: true, description: 'Active member name, or "captain" for captain takeover.' },
@@ -495,7 +1222,17 @@ export function registerAgentTeamsTools(ctx, config) {
495
1222
  if (task.reassigning === true)
496
1223
  throw new Error(`task ${task.id} is already being reassigned`);
497
1224
  const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
498
- if (targetMember !== undefined) {
1225
+ if (target === CAPTAIN_KEY) {
1226
+ const busy = captainOpenTask(fresh, task.id);
1227
+ if (busy !== undefined) {
1228
+ throw new Error(`captain is busy with ${busy.id}; complete or reassign it before taking over ${task.id}`);
1229
+ }
1230
+ const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
1231
+ if (pending.length > 0) {
1232
+ throw new Error(`task ${task.id} is blocked by unfinished dependencies: ${pending.join(', ')} — complete them before captain takeover`);
1233
+ }
1234
+ }
1235
+ else if (targetMember !== undefined) {
499
1236
  const busy = memberOpenTask(fresh, targetMember.name, task.id);
500
1237
  if (busy !== undefined) {
501
1238
  throw new Error(`member "${targetMember.name}" is busy with ${busy.id}; finish or reassign it first`);
@@ -531,8 +1268,13 @@ export function registerAgentTeamsTools(ctx, config) {
531
1268
  throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
532
1269
  }
533
1270
  task.reassigning = false;
534
- if (quiescenceError === undefined && target === CAPTAIN_KEY)
1271
+ if (quiescenceError === undefined && target === CAPTAIN_KEY) {
535
1272
  beginTaskAttempt(task, CAPTAIN_KEY);
1273
+ // The captain is already in the turn that requested takeover; there
1274
+ // is no later member claim handshake to move claimed -> in_progress.
1275
+ task.status = 'in_progress';
1276
+ task.updatedAt = Date.now();
1277
+ }
536
1278
  await writeTeam(stateRoot, fresh);
537
1279
  appendTeamEvent(ctx, captain.session, 'agent-teams/task-updated', {
538
1280
  teamId: fresh.id,
@@ -669,6 +1411,60 @@ export function registerAgentTeamsTools(ctx, config) {
669
1411
  },
670
1412
  output: { type: 'string', description: 'Result summary; set when completing or failing.' },
671
1413
  attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
1414
+ verdict: {
1415
+ type: 'string',
1416
+ enum: ['pass', 'needs_revision', 'reject'],
1417
+ description: 'Required for completing requirements/review. needs_revision and reject must fail the task.',
1418
+ },
1419
+ findings: {
1420
+ type: 'array',
1421
+ items: {
1422
+ type: 'object',
1423
+ additionalProperties: false,
1424
+ properties: {
1425
+ id: { type: 'string', required: true },
1426
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'blocker'], required: true },
1427
+ problem: { type: 'string', required: true },
1428
+ requiredFix: { type: 'string', required: true },
1429
+ file: { type: 'string' },
1430
+ line: { type: 'number' },
1431
+ resolved: { type: 'boolean' },
1432
+ },
1433
+ },
1434
+ description: 'Structured review findings. Required when verdict is needs_revision or reject; each item needs id, severity, problem, and requiredFix.',
1435
+ },
1436
+ changedPaths: {
1437
+ type: 'array',
1438
+ items: { type: 'string' },
1439
+ description: 'Workspace-relative POSIX paths changed by this implementation/repair.',
1440
+ },
1441
+ acceptanceResults: {
1442
+ type: 'array',
1443
+ items: {
1444
+ type: 'object',
1445
+ additionalProperties: false,
1446
+ properties: {
1447
+ criterion: { type: 'string', required: true },
1448
+ status: { type: 'string', enum: ['passed', 'failed'], required: true },
1449
+ evidence: { type: 'string' },
1450
+ },
1451
+ },
1452
+ description: 'Acceptance evidence in contract order: {criterion, status:"passed"|"failed", evidence?}. Supply one item per acceptance criterion.',
1453
+ },
1454
+ commandsRun: {
1455
+ type: 'array',
1456
+ items: {
1457
+ type: 'object',
1458
+ additionalProperties: false,
1459
+ properties: {
1460
+ command: { type: 'string', required: true },
1461
+ status: { type: 'string', enum: ['passed', 'failed'], required: true },
1462
+ exitCode: { type: 'number' },
1463
+ evidence: { type: 'string' },
1464
+ },
1465
+ },
1466
+ description: 'Verification evidence in contract order: {command, status:"passed"|"failed", exitCode?, evidence?}. Supply one item per verify command.',
1467
+ },
672
1468
  },
673
1469
  output: {
674
1470
  schema: {
@@ -722,6 +1518,20 @@ export function registerAgentTeamsTools(ctx, config) {
722
1518
  ...task.output !== undefined ? { output: task.output } : {},
723
1519
  };
724
1520
  }
1521
+ const findings = parseFindings(args.findings);
1522
+ const acceptanceResults = parseAcceptanceResults(args.acceptanceResults);
1523
+ const commandsRun = parseCommandResults(args.commandsRun);
1524
+ const gate = evaluateQualityCompletion(task, {
1525
+ status: args.status,
1526
+ output: args.output,
1527
+ verdict: args.verdict,
1528
+ findings,
1529
+ changedPaths: args.changedPaths,
1530
+ acceptanceResults,
1531
+ commandsRun,
1532
+ });
1533
+ if (!gate.ok)
1534
+ throw new Error(gate.error ?? 'update_task rejected by quality gates');
725
1535
  if (args.status !== undefined) {
726
1536
  const transition = transitionError(task.status, args.status);
727
1537
  if (transition !== undefined)
@@ -730,7 +1540,23 @@ export function registerAgentTeamsTools(ctx, config) {
730
1540
  }
731
1541
  if (args.output !== undefined)
732
1542
  task.output = args.output;
1543
+ if (args.verdict !== undefined)
1544
+ task.verdict = args.verdict;
1545
+ if (findings !== undefined)
1546
+ task.findings = findings;
1547
+ if (args.changedPaths !== undefined)
1548
+ task.changedPaths = args.changedPaths;
1549
+ if (acceptanceResults !== undefined)
1550
+ task.acceptanceResults = acceptanceResults;
1551
+ if (commandsRun !== undefined)
1552
+ task.commandsRun = commandsRun;
733
1553
  task.updatedAt = Date.now();
1554
+ const followUp = (task.status === 'failed' && (task.verdict === 'needs_revision' || task.verdict === 'reject'))
1555
+ ? applyQualityFollowUp(fresh, task)
1556
+ : undefined;
1557
+ if (followUp?.escalated === true) {
1558
+ await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, createMessage(CAPTAIN_KEY, CAPTAIN_KEY, `Quality-gate loop escalated after ${task.id} (${task.kind ?? 'review'} verdict=${task.verdict}). Automatic repair/review stopped.`));
1559
+ }
734
1560
  await writeTeam(stateRoot, fresh);
735
1561
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
736
1562
  teamId: fresh.id,
@@ -738,7 +1564,20 @@ export function registerAgentTeamsTools(ctx, config) {
738
1564
  status: task.status,
739
1565
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
740
1566
  ...task.output !== undefined ? { output: task.output } : {},
1567
+ ...task.verdict === undefined ? {} : { verdict: task.verdict },
1568
+ ...task.round === undefined ? {} : { round: task.round },
741
1569
  });
1570
+ for (const created of followUp?.created ?? []) {
1571
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-created', {
1572
+ teamId: fresh.id,
1573
+ taskId: created.id,
1574
+ subject: created.subject,
1575
+ dependencies: created.dependencies,
1576
+ ...created.assignee === undefined ? {} : { assignee: created.assignee },
1577
+ ...created.kind === undefined ? {} : { kind: created.kind },
1578
+ ...created.round === undefined ? {} : { round: created.round },
1579
+ });
1580
+ }
742
1581
  return {
743
1582
  task_id: task.id,
744
1583
  status: task.status,
@@ -802,6 +1641,9 @@ export function registerAgentTeamsTools(ctx, config) {
802
1641
  });
803
1642
  return { kind: 'captain', fresh, identity, message, from };
804
1643
  }
1644
+ if (fresh.halted === true) {
1645
+ throw new Error(`team "${fresh.name}" is halted; call agent_teams_resume before waking a member`);
1646
+ }
805
1647
  const recipient = requireMember(fresh, to);
806
1648
  const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
807
1649
  await appendMailbox(stateRoot, fresh.id, recipient.name, message);
@@ -892,6 +1734,11 @@ export function registerAgentTeamsTools(ctx, config) {
892
1734
  attempt: task.attempt ?? 0,
893
1735
  attempt_id: task.attemptId ?? '',
894
1736
  reassigning: task.reassigning === true,
1737
+ kind: taskKindOf(task),
1738
+ ...task.round === undefined ? {} : { round: task.round },
1739
+ ...task.verdict === undefined ? {} : { verdict: task.verdict },
1740
+ findings_open: (task.findings ?? []).filter((finding) => finding.resolved !== true).length,
1741
+ ...task.profileSeedId === undefined ? {} : { seed_id: task.profileSeedId },
895
1742
  ...task.output !== undefined ? { output: task.output } : {},
896
1743
  }));
897
1744
  const mailboxWarnings = [];
@@ -918,10 +1765,36 @@ export function registerAgentTeamsTools(ctx, config) {
918
1765
  };
919
1766
  }
920
1767
  }
1768
+ const coverage = buildCoverageMatrix([...new Set(team.tasks.flatMap((item) => item.coverageOf ?? []))], team.tasks).map((row) => ({
1769
+ goal_item: row.goal_item,
1770
+ task_ids: [...row.task_ids],
1771
+ status: row.status,
1772
+ ...row.evidence === undefined ? {} : { evidence: row.evidence },
1773
+ }));
1774
+ const deliveryCheck = canDeclareDelivery(team);
1775
+ const delivery = { ok: deliveryCheck.ok, blockers: [...deliveryCheck.blockers] };
1776
+ const loop = describeQualityLoop(team);
921
1777
  const result = {
922
1778
  team_id: team.id,
923
1779
  team_name: team.name,
924
1780
  description: team.description ?? '',
1781
+ phase: team.phase ?? 'running',
1782
+ halted: loop.halted,
1783
+ escalated: loop.escalated,
1784
+ loop_state: loop.state,
1785
+ loop_summary: loop.summary,
1786
+ deliverable: loop.deliverable,
1787
+ coverage,
1788
+ delivery,
1789
+ ...team.profile === undefined ? {} : {
1790
+ profile: {
1791
+ name: team.profile.name,
1792
+ ...team.profile.protocol === undefined
1793
+ ? {}
1794
+ : { protocol: team.profile.protocol.slice(0, 240) },
1795
+ ...team.profile.taskPlanning === undefined ? {} : { task_planning: team.profile.taskPlanning },
1796
+ },
1797
+ },
925
1798
  viewer: identity.name,
926
1799
  members,
927
1800
  tasks,
@@ -943,6 +1816,59 @@ export function registerAgentTeamsTools(ctx, config) {
943
1816
  return result;
944
1817
  },
945
1818
  }));
1819
+ ctx.tools.register(defineTool({
1820
+ name: 'agent_teams_resume',
1821
+ description: 'Explicitly resume a halted team. Requires a non-empty reason. Does not recreate cancelled tasks; only still-pending work is scheduled.',
1822
+ parameters: {
1823
+ reason: { type: 'string', required: true, description: 'Why the team is being resumed.' },
1824
+ },
1825
+ output: {
1826
+ schema: {
1827
+ type: 'object',
1828
+ additionalProperties: false,
1829
+ properties: {
1830
+ status: { type: 'string', required: true },
1831
+ team_id: { type: 'string', required: true },
1832
+ reason: { type: 'string', required: true },
1833
+ },
1834
+ },
1835
+ render: (_args, value) => [{
1836
+ type: 'text',
1837
+ text: value.status === 'already_running'
1838
+ ? `Team ${value.team_id} is already running.`
1839
+ : `Team ${value.team_id} resumed (${value.reason}).`,
1840
+ }],
1841
+ },
1842
+ async execute(args, exec) {
1843
+ const captain = requireCaptain(exec);
1844
+ const workspace = workspaceOf(captain);
1845
+ const stateRoot = stateRootOf(workspace, config);
1846
+ const team = await requireCaptainTeam(workspace, config, captain);
1847
+ const result = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1848
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1849
+ const resumed = resumeTeamState(fresh, args.reason);
1850
+ if (resumed.status === 'rejected')
1851
+ throw new Error(resumed.error ?? 'resume rejected');
1852
+ if (resumed.status === 'resumed') {
1853
+ fresh.halted = false;
1854
+ fresh.haltedAt = undefined;
1855
+ await writeTeam(stateRoot, fresh);
1856
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-resumed', {
1857
+ teamId: fresh.id,
1858
+ reason: args.reason,
1859
+ });
1860
+ }
1861
+ return {
1862
+ status: resumed.status,
1863
+ team_id: fresh.id,
1864
+ reason: args.reason,
1865
+ };
1866
+ });
1867
+ if (result.status === 'resumed')
1868
+ await scheduler.kickTeam(workspace, team.id, captain);
1869
+ return result;
1870
+ },
1871
+ }));
946
1872
  ctx.tools.register(defineTool({
947
1873
  name: 'agent_teams_delete',
948
1874
  description: 'End your team: interrupts all members (best effort) and deletes the team\'s state directory (team file, tasks, mailboxes). Use when the team\'s work is done or abandoned.',
@@ -976,7 +1902,7 @@ export function registerAgentTeamsTools(ctx, config) {
976
1902
  continue;
977
1903
  member.status = 'removed';
978
1904
  for (const task of fresh.tasks) {
979
- if (task.assignee === member.name && task.status !== 'completed')
1905
+ if (task.assignee === member.name && !TERMINAL_TASK_STATUSES.includes(task.status))
980
1906
  invalidateTaskAttempt(task);
981
1907
  }
982
1908
  }
@@ -1007,19 +1933,274 @@ export function registerAgentTeamsTools(ctx, config) {
1007
1933
  return { deleted: true, team_name: team.name };
1008
1934
  },
1009
1935
  }));
1936
+ return runtime;
1937
+ }
1938
+ async function initializeProfileTeam(input) {
1939
+ const profile = resolveTeamProfile(input.config.profiles, input.profileName, input.config.maxMembers);
1940
+ const selections = [];
1941
+ for (const template of profile.members) {
1942
+ selections.push(await resolveMemberLlmSelection(input.ctx, input.captain, {
1943
+ provider: template.provider,
1944
+ model: template.model,
1945
+ defaultModel: input.config.memberModel,
1946
+ reasoningEffort: template.reasoningEffort,
1947
+ fallback: template.fallback ?? profile.fallback ?? input.config.fallback,
1948
+ }, input.exec.signal));
1949
+ }
1950
+ await validateMemberLlmSelections(input.ctx, selections, input.exec.signal);
1951
+ const now = Date.now();
1952
+ const seedToActual = new Map(profile.tasks.map((template, index) => [template.id, `t${index + 1}`]));
1953
+ const draft = {
1954
+ name: input.teamName,
1955
+ id: input.teamId,
1956
+ description: input.description,
1957
+ profile: {
1958
+ name: profile.name,
1959
+ ...profile.description === undefined ? {} : { description: profile.description },
1960
+ ...profile.protocol === undefined ? {} : { protocol: profile.protocol },
1961
+ ...profile.executionPrompt === undefined ? {} : { executionPrompt: profile.executionPrompt },
1962
+ ...profile.fallback === undefined ? {} : { fallback: profile.fallback },
1963
+ taskPlanning: profile.taskPlanning,
1964
+ ...profile.reviewPolicy === undefined ? {} : { reviewPolicy: profile.reviewPolicy },
1965
+ },
1966
+ ...profile.reviewPolicy === undefined ? {} : { reviewPolicy: profile.reviewPolicy },
1967
+ captainSessionId: input.captain.id,
1968
+ createdAt: now,
1969
+ ...input.staged ? { phase: 'staged', planReviewState: 'awaiting_review' } : {},
1970
+ members: profile.members.map((template, index) => {
1971
+ const selection = selections[index];
1972
+ return {
1973
+ id: '',
1974
+ name: template.name,
1975
+ role: template.role,
1976
+ provider: selection.provider,
1977
+ model: selection.model,
1978
+ reasoningEffort: selection.reasoningEffort,
1979
+ executionPrompt: template.executionPrompt ?? profile.executionPrompt ?? input.config.executionPrompt,
1980
+ ...selection.fallback === undefined ? {} : { fallback: selection.fallback },
1981
+ joinedAt: now,
1982
+ status: 'idle',
1983
+ };
1984
+ }),
1985
+ tasks: profile.tasks.map((template, index) => ({
1986
+ id: `t${index + 1}`,
1987
+ profileSeedId: template.id,
1988
+ subject: template.subject,
1989
+ description: template.description,
1990
+ status: 'pending',
1991
+ assignee: template.assignee,
1992
+ dependencies: template.dependencies.map((dependency) => seedToActual.get(dependency) ?? dependency),
1993
+ attempt: 0,
1994
+ createdAt: now,
1995
+ updatedAt: now,
1996
+ })),
1997
+ taskSeq: profile.tasks.length,
1998
+ };
1999
+ if (input.staged) {
2000
+ await createTeamDir(input.stateRoot, draft);
2001
+ return { committed: true, state: draft };
2002
+ }
2003
+ const spawned = [];
2004
+ try {
2005
+ for (const member of draft.members) {
2006
+ const selection = selections[spawned.length];
2007
+ await spawnMember(input.ctx, memberRuntime(input.config), input.memberSelections, selection, input.captain, draft, member, input.config.stateDir, input.exec.signal);
2008
+ spawned.push(member);
2009
+ }
2010
+ if (draft.members.some((member) => member.id === '')) {
2011
+ throw new Error(`failed to initialize profile "${profile.name}": a spawned member is missing its child id`);
2012
+ }
2013
+ await createTeamDir(input.stateRoot, draft);
2014
+ return { committed: true, state: draft };
2015
+ }
2016
+ catch (error) {
2017
+ const cleanupErrors = [];
2018
+ try {
2019
+ await removeTeamDir(input.stateRoot, draft.id);
2020
+ }
2021
+ catch (cleanupError) {
2022
+ cleanupErrors.push(cleanupError);
2023
+ }
2024
+ try {
2025
+ await recordRetiredMemberIds(input.stateRoot, spawned.map((member) => member.id));
2026
+ }
2027
+ catch (cleanupError) {
2028
+ cleanupErrors.push(cleanupError);
2029
+ }
2030
+ for (const member of spawned) {
2031
+ try {
2032
+ interruptMember(input.ctx, input.captain, member.id);
2033
+ }
2034
+ catch (cleanupError) {
2035
+ cleanupErrors.push(cleanupError);
2036
+ }
2037
+ }
2038
+ if (cleanupErrors.length > 0) {
2039
+ throw new AggregateError([error, ...cleanupErrors], `failed to initialize profile "${profile.name}"`);
2040
+ }
2041
+ throw error;
2042
+ }
2043
+ }
2044
+ function parseFindings(value) {
2045
+ if (value === undefined)
2046
+ return undefined;
2047
+ if (!Array.isArray(value))
2048
+ throw new Error('findings must be an array');
2049
+ return value.map((item, index) => {
2050
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
2051
+ throw new Error(`findings[${index}] must be an object`);
2052
+ }
2053
+ const raw = item;
2054
+ if (typeof raw['id'] !== 'string' || raw['id'].trim() === '')
2055
+ throw new Error(`findings[${index}].id is required`);
2056
+ if (raw['severity'] !== 'low' && raw['severity'] !== 'medium' && raw['severity'] !== 'high' && raw['severity'] !== 'blocker') {
2057
+ throw new Error(`findings[${index}].severity is invalid`);
2058
+ }
2059
+ if (typeof raw['problem'] !== 'string' || raw['problem'].trim() === '')
2060
+ throw new Error(`findings[${index}].problem is required`);
2061
+ if (typeof raw['requiredFix'] !== 'string' || raw['requiredFix'].trim() === '')
2062
+ throw new Error(`findings[${index}].requiredFix is required`);
2063
+ return {
2064
+ id: raw['id'].trim(),
2065
+ severity: raw['severity'],
2066
+ problem: raw['problem'],
2067
+ requiredFix: raw['requiredFix'],
2068
+ ...typeof raw['file'] === 'string' ? { file: raw['file'] } : {},
2069
+ ...typeof raw['line'] === 'number' ? { line: raw['line'] } : {},
2070
+ ...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
2071
+ };
2072
+ });
2073
+ }
2074
+ function parseAcceptanceResults(value) {
2075
+ if (value === undefined)
2076
+ return undefined;
2077
+ if (!Array.isArray(value))
2078
+ throw new Error('acceptanceResults must be an array');
2079
+ return value.map((item, index) => {
2080
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
2081
+ throw new Error(`acceptanceResults[${index}] must be an object`);
2082
+ }
2083
+ const raw = item;
2084
+ if (typeof raw['criterion'] !== 'string' || raw['criterion'].trim() === '') {
2085
+ throw new Error(`acceptanceResults[${index}].criterion is required`);
2086
+ }
2087
+ if (raw['status'] !== 'passed' && raw['status'] !== 'failed') {
2088
+ throw new Error(`acceptanceResults[${index}].status must be passed or failed`);
2089
+ }
2090
+ return {
2091
+ criterion: raw['criterion'],
2092
+ status: raw['status'],
2093
+ ...typeof raw['evidence'] === 'string' ? { evidence: raw['evidence'] } : {},
2094
+ };
2095
+ });
2096
+ }
2097
+ function parseCommandResults(value) {
2098
+ if (value === undefined)
2099
+ return undefined;
2100
+ if (!Array.isArray(value))
2101
+ throw new Error('commandsRun must be an array');
2102
+ return value.map((item, index) => {
2103
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
2104
+ throw new Error(`commandsRun[${index}] must be an object`);
2105
+ }
2106
+ const raw = item;
2107
+ if (typeof raw['command'] !== 'string' || raw['command'].trim() === '') {
2108
+ throw new Error(`commandsRun[${index}].command is required`);
2109
+ }
2110
+ if (raw['status'] !== 'passed' && raw['status'] !== 'failed') {
2111
+ throw new Error(`commandsRun[${index}].status must be passed or failed`);
2112
+ }
2113
+ return {
2114
+ command: raw['command'],
2115
+ status: raw['status'],
2116
+ ...typeof raw['exitCode'] === 'number' ? { exitCode: raw['exitCode'] } : {},
2117
+ ...typeof raw['evidence'] === 'string' ? { evidence: raw['evidence'] } : {},
2118
+ };
2119
+ });
2120
+ }
2121
+ export function applyQualityFollowUp(team, closed) {
2122
+ const planned = planQualityFollowUp(team, closed);
2123
+ if (planned.escalated === true)
2124
+ team.escalated = true;
2125
+ const created = [];
2126
+ const existing = [...team.tasks];
2127
+ const now = Date.now();
2128
+ const idBySubject = new Map();
2129
+ for (const draft of planned.created) {
2130
+ team.taskSeq += 1;
2131
+ const id = `t${team.taskSeq}`;
2132
+ if (draft.id !== undefined)
2133
+ idBySubject.set(draft.id, id);
2134
+ if (draft.subject !== undefined)
2135
+ idBySubject.set(draft.subject, id);
2136
+ const dependencies = (draft.dependencies ?? []).map((dependency) => {
2137
+ if (team.tasks.some((item) => item.id === dependency))
2138
+ return dependency;
2139
+ return idBySubject.get(dependency) ?? dependency;
2140
+ });
2141
+ const next = {
2142
+ id,
2143
+ subject: draft.subject ?? `${draft.kind}-round-${draft.round ?? 1}`,
2144
+ status: 'pending',
2145
+ assignee: draft.assignee,
2146
+ dependencies,
2147
+ attempt: 0,
2148
+ createdAt: now,
2149
+ updatedAt: now,
2150
+ kind: draft.kind,
2151
+ ...draft.round === undefined ? {} : { round: draft.round },
2152
+ ...draft.objective === undefined ? {} : { objective: draft.objective },
2153
+ ...draft.inScope === undefined ? {} : { inScope: draft.inScope },
2154
+ ...draft.outOfScope === undefined ? {} : { outOfScope: draft.outOfScope },
2155
+ ...draft.acceptance === undefined ? {} : { acceptance: draft.acceptance },
2156
+ ...draft.verify === undefined ? {} : { verify: draft.verify },
2157
+ ...draft.sourceTaskId === undefined ? {} : { sourceTaskId: draft.sourceTaskId },
2158
+ ...draft.sourceFindingIds === undefined ? {} : { sourceFindingIds: draft.sourceFindingIds },
2159
+ ...draft.reviewedTaskId === undefined ? {} : { reviewedTaskId: idBySubject.get(draft.reviewedTaskId) ?? draft.reviewedTaskId },
2160
+ };
2161
+ team.tasks.push(next);
2162
+ created.push(next);
2163
+ }
2164
+ // A staged full delivery plan may already contain downstream integration
2165
+ // work that points at the first requirements/review gate. When that gate
2166
+ // opens an automatic revision loop, move only still-pending downstream
2167
+ // edges to the new terminal gate so the approved plan can continue after
2168
+ // the repair instead of waiting forever on an intentionally failed task.
2169
+ const replacement = created.at(-1);
2170
+ if (replacement !== undefined) {
2171
+ for (const task of existing) {
2172
+ if (task.status !== 'pending' || !task.dependencies.includes(closed.id))
2173
+ continue;
2174
+ task.dependencies = task.dependencies.map((dependency) => (dependency === closed.id ? replacement.id : dependency));
2175
+ task.updatedAt = now;
2176
+ }
2177
+ }
2178
+ return { created, escalated: planned.escalated === true };
1010
2179
  }
1011
2180
  /** Build the `memberRuntime` config handed to member helpers. */
1012
2181
  function memberRuntime(config) {
1013
2182
  return {
1014
2183
  provider: config.memberProvider,
1015
2184
  maxDepth: config.memberMaxDepth,
2185
+ executionPrompt: config.executionPrompt,
2186
+ fallback: config.fallback,
1016
2187
  };
1017
2188
  }
1018
2189
  /** Render the status snapshot as compact text for the model. */
1019
2190
  function renderStatus(value) {
1020
2191
  const team = value;
2192
+ const flags = [
2193
+ team.halted ? 'halted' : undefined,
2194
+ team.escalated ? 'escalated' : undefined,
2195
+ team.deliverable ? 'deliverable' : undefined,
2196
+ team.loop_state && team.loop_state !== 'running' && team.loop_state !== 'halted' && team.loop_state !== 'escalated'
2197
+ ? team.loop_state
2198
+ : undefined,
2199
+ ].filter((item) => item !== undefined);
1021
2200
  const lines = [
1022
- `Team "${team.team_name}"${team.description ? ` — ${team.description}` : ''}`,
2201
+ `Team "${team.team_name}"${team.description ? ` — ${team.description}` : ''}${flags.length > 0 ? ` [${flags.join(', ')}]` : ''}`,
2202
+ ...team.profile === undefined ? [] : [`Profile: ${team.profile.name}${team.profile.task_planning ? ` [${team.profile.task_planning}]` : ''}${team.profile.protocol ? ` — ${team.profile.protocol}` : ''}`],
2203
+ ...team.loop_summary ? [`Loop: ${team.loop_state ?? ''} — ${team.loop_summary}`.replace(/^Loop: — /u, 'Loop: ')] : [],
1023
2204
  `Viewing as: ${team.viewer}`,
1024
2205
  `Members (${team.members.length}):`,
1025
2206
  ...team.members.map((member) => {
@@ -1032,8 +2213,19 @@ function renderStatus(value) {
1032
2213
  const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
1033
2214
  const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
1034
2215
  const handoff = task.reassigning ? ' (reassigning)' : '';
1035
- return ` - ${task.id} [${task.status}] attempt ${task.attempt}${handoff} ${task.subject} ${task.assignee || 'unassigned'}${deps}${output}`;
2216
+ const seed = task.seed_id === undefined || task.seed_id === '' ? '' : ` seed ${task.seed_id}`;
2217
+ const kind = task.kind ? ` ${task.kind}` : '';
2218
+ const round = task.round === undefined ? '' : ` r${task.round}`;
2219
+ const verdict = task.verdict === undefined ? '' : ` verdict ${task.verdict}`;
2220
+ return ` - ${task.id} [${task.status}]${kind}${round}${verdict} attempt ${task.attempt}${handoff}${seed} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
1036
2221
  }),
2222
+ ...team.coverage === undefined || team.coverage.length === 0 ? [] : [
2223
+ 'Coverage:',
2224
+ ...team.coverage.map((row) => ` - ${row.goal_item}: ${row.status} (${row.task_ids.join(',') || 'none'})`),
2225
+ ],
2226
+ ...team.delivery === undefined ? [] : [
2227
+ `Delivery: ${team.delivery.ok ? 'ok' : `blocked (${team.delivery.blockers.join('; ')})`}`,
2228
+ ],
1037
2229
  `Captain inbox (${team.captain_inbox.length}):`,
1038
2230
  ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
1039
2231
  ];