@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.24-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +35 -13
  2. package/README_ZH.md +35 -13
  3. package/compatibility.json +6 -2
  4. package/lib/artifacts.js +342 -0
  5. package/lib/capabilities.js +6 -4
  6. package/lib/client/ActivityPanel.js +5 -2
  7. package/lib/client/agent-teams-card-definition.js +100 -6
  8. package/lib/client.js +90 -7
  9. package/lib/client.js.map +1 -1
  10. package/lib/command.js +3 -2
  11. package/lib/git-artifact-stamp.json +2 -2
  12. package/lib/harness-compat.js +139 -24
  13. package/lib/index.js +26 -3
  14. package/lib/lineage.js +198 -0
  15. package/lib/mailbox.js +59 -34
  16. package/lib/members.js +355 -82
  17. package/lib/profiles.js +3 -2
  18. package/lib/quality-gates.js +850 -65
  19. package/lib/scheduler.js +22 -3
  20. package/lib/snapshot.js +22 -1
  21. package/lib/state.js +282 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +382 -109
  24. package/lib/types/artifacts.d.ts +66 -0
  25. package/lib/types/client/activity-monitor.d.ts +6 -0
  26. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  27. package/lib/types/harness-compat.d.ts +78 -15
  28. package/lib/types/index.d.ts +26 -1
  29. package/lib/types/lineage.d.ts +92 -0
  30. package/lib/types/members.d.ts +113 -2
  31. package/lib/types/profiles.d.ts +6 -0
  32. package/lib/types/quality-gates.d.ts +216 -10
  33. package/lib/types/scheduler.d.ts +2 -1
  34. package/lib/types/snapshot.d.ts +20 -0
  35. package/lib/types/state.d.ts +22 -5
  36. package/lib/types/tool-policies.d.ts +119 -0
  37. package/lib/types/tools.d.ts +12 -1
  38. package/lib/types/types.d.ts +100 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +61 -54
  42. package/pnpm-workspace.yaml +304 -273
  43. package/release-notes/v0.1.23.md +34 -0
  44. package/release-notes/v0.1.24-rc.1.md +13 -0
  45. package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/tools.js CHANGED
@@ -1,24 +1,21 @@
1
- /**
2
- * The `agent_teams_*` model-facing tools.
3
- *
4
- * The captain (the agent that created the team) orchestrates: members are
5
- * continuable subagents it spawns and wakes. Members share the same tools and
6
- * drive their own task state, mirroring the Claude Code AgentTeams flow:
7
- * create team → add members → create tasks with dependencies → claim/assign →
8
- * work → report → status → delete.
9
- * @module dsh-agent-teams/tools
10
- */
1
+ import { drainMemberChildren } from "./harness-compat.js";
11
2
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
4
  import { join } from 'node:path';
14
5
  import { appendTeamEvent, captainSessionOf } from "./events.js";
15
- import { amendTaskContract, acknowledgeMailbox, markMailboxDelivered, discardMailboxMessages, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, readMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
6
+ import { amendTaskContract, acknowledgeMailbox, markMailboxDelivered, discardMailboxMessages, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, readMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, isValidFindingLine, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
16
7
  import { appendTaskEvidence } from "./quality-gates.js";
8
+ import { artifactDrift, bindReviewToRevision, isQualityKind, verdictScopePatterns, } from "./quality-gates.js";
9
+ import { auditTeamWorkspace } from "./workspace-audit.js";
10
+ import { readArtifactScope, snapshotWorkspacePaths, workspacePathExists } from "./artifacts.js";
11
+ import { restrictableToolNames } from "./harness-compat.js";
12
+ import { memberSpawnRelaxableDeny, optionalToolPolicy, requireToolPolicy, resolveMemberToolFilter } from "./tool-policies.js";
17
13
  import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, installMemberDelegationGuard, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
18
14
  import { TERMINAL_TASK_STATUSES } from "./types.js";
19
15
  import { collectCompletedDependencyOutputs, formatDependencyOutputs, installTeamScheduler } from "./scheduler.js";
20
16
  import { installMailboxAdmission, isCurrentMail, mailboxContent, mailboxPrompt, readCurrentMailbox } from "./mailbox.js";
21
17
  import { resolveTeamProfile } from "./profiles.js";
18
+ import { forkLineageNote, leadIdentityOfAsync, nearestLiveLead, sessionLeadsTeamAsync } from "./lineage.js";
22
19
  export { steerCaptainReport } from "./members.js";
23
20
  /** The caller agent, or a loud failure for non-agent callers. */
24
21
  function requireCaptain(exec) {
@@ -39,29 +36,64 @@ function stateRootOf(workspace, config) {
39
36
  function teamLockKey(stateRoot, teamId) {
40
37
  return `team:${stateRoot}:${teamId}`;
41
38
  }
39
+ /**
40
+ * Bind a closing verdict to the revision it judged, using the snapshot taken
41
+ * when its attempt started (if any) to notice that the workspace changed while
42
+ * the gate was being formed. A verdict that ran over a moving workspace binds
43
+ * the drift explicitly instead of claiming the current bytes.
44
+ */
45
+ function bindClosingVerdict(workspace, team, task) {
46
+ if (task.status !== 'completed')
47
+ return;
48
+ const kind = taskKindOf(task);
49
+ if (kind !== 'review' && kind !== 'requirements' && kind !== 'verification')
50
+ return;
51
+ if ((kind === 'review' || kind === 'requirements') && task.verdict !== 'pass')
52
+ return;
53
+ const scope = verdictScopePatterns(team, task);
54
+ if (scope.length === 0)
55
+ return;
56
+ const read = readArtifactScope(workspace, scope);
57
+ const duringDrift = task.verdictStartSnapshot === undefined
58
+ ? []
59
+ : artifactDrift(task.verdictStartSnapshot.artifacts, read.artifacts);
60
+ const bound = bindReviewToRevision(team, task, {
61
+ artifacts: read.artifacts,
62
+ gaps: read.gaps,
63
+ scope,
64
+ duringDrift,
65
+ });
66
+ task.reviewedTaskId = bound.reviewedTaskId;
67
+ task.reviewedAttempt = bound.reviewedAttempt;
68
+ task.reviewedArtifacts = bound.reviewedArtifacts;
69
+ task.reviewedGaps = bound.reviewedGaps;
70
+ task.reviewedScope = bound.reviewedScope;
71
+ task.reviewedDuringDrift = bound.reviewedDuringDrift;
72
+ task.verdictStartSnapshot = undefined;
73
+ }
42
74
  /** Process-local lock key enforcing one active team per captain session. */
43
75
  function captainLockKey(stateRoot, captainId) {
44
76
  return `captain:${stateRoot}:${captainId}`;
45
77
  }
46
78
  /** The team this captain currently leads, or a loud failure. */
47
- async function requireCaptainTeam(workspace, config, captain) {
48
- const team = await findTeamByCaptain(stateRootOf(workspace, config), captain.id);
79
+ async function requireCaptainTeam(ctx, workspace, config, captain) {
80
+ const team = await findTeamByCaptain(stateRootOf(workspace, config), captain.id, await leadIdentityOfAsync(ctx, captain.session));
49
81
  if (team === undefined) {
50
- throw new Error('you are not leading any team yet — call agent_teams_create first');
82
+ throw new Error(`you are not leading any team yet — call agent_teams_create first${forkLineageNote(captain.session)}`);
51
83
  }
52
84
  return team;
53
85
  }
54
86
  /** The team this captain or active member currently participates in. */
55
- async function requireParticipantTeam(workspace, config, caller) {
56
- const team = await findTeamByParticipant(stateRootOf(workspace, config), caller.id);
87
+ async function requireParticipantTeam(ctx, workspace, config, caller) {
88
+ const team = await findTeamByParticipant(stateRootOf(workspace, config), caller.id, await leadIdentityOfAsync(ctx, caller.session));
57
89
  if (team === undefined) {
58
- throw new Error('you do not lead or belong to any active team yet');
90
+ throw new Error(`you do not lead or belong to any active team yet${forkLineageNote(caller.session)}`);
59
91
  }
60
92
  return team;
61
93
  }
62
94
  /** Re-derive a caller's role from fresh state while holding the team lock. */
63
- function participantIdentityOf(team, agentId) {
64
- if (team.captainSessionId === agentId)
95
+ async function participantIdentityOf(ctx, team, agentId) {
96
+ if (await sessionLeadsTeamAsync(ctx, team, agentId))
65
97
  return { kind: 'captain', name: CAPTAIN_KEY };
66
98
  const member = team.members.find((candidate) => candidate.id === agentId && candidate.status !== 'removed');
67
99
  return member === undefined ? undefined : { kind: 'member', name: member.name };
@@ -74,17 +106,17 @@ async function requireFreshTeam(stateRoot, teamId) {
74
106
  return fresh;
75
107
  }
76
108
  /** Fresh state with captain authorization rechecked inside the lock. */
77
- async function requireFreshCaptainTeam(stateRoot, teamId, captainId) {
109
+ async function requireFreshCaptainTeam(ctx, stateRoot, teamId, captainId) {
78
110
  const fresh = await requireFreshTeam(stateRoot, teamId);
79
- if (fresh.captainSessionId !== captainId) {
80
- throw new Error(`only the captain of team "${fresh.name}" may perform this operation`);
111
+ if (!(await sessionLeadsTeamAsync(ctx, fresh, captainId))) {
112
+ throw new Error(`only the captain of team "${fresh.name}" (session ${fresh.captainSessionId}) or one of its fork descendants may perform this operation`);
81
113
  }
82
114
  return fresh;
83
115
  }
84
116
  /** Fresh state and caller identity rechecked inside the lock. */
85
- async function requireFreshParticipant(stateRoot, teamId, callerId) {
117
+ async function requireFreshParticipant(ctx, stateRoot, teamId, callerId) {
86
118
  const fresh = await requireFreshTeam(stateRoot, teamId);
87
- const identity = participantIdentityOf(fresh, callerId);
119
+ const identity = await participantIdentityOf(ctx, fresh, callerId);
88
120
  if (identity === undefined)
89
121
  throw new Error(`you are no longer an active participant in team "${fresh.name}"`);
90
122
  return { team: fresh, identity };
@@ -116,6 +148,48 @@ function trimmedOptional(value) {
116
148
  const trimmed = value?.trim();
117
149
  return trimmed === undefined || trimmed === '' ? undefined : trimmed;
118
150
  }
151
+ /**
152
+ * Resolve one member's selected tool policy into the persisted policy id and
153
+ * the immutable effective filter (#59).
154
+ *
155
+ * An unknown id fails here — before the member is written and before any child
156
+ * is created — so a typo can never persist a half-configured member or leave a
157
+ * resumable orphan behind. A member without a policy resolves to `{}`: the
158
+ * previous behavior and record shape stay untouched.
159
+ * @param config - resolved plugin config (declared policies + depth cap).
160
+ * @param captain - the live captain, whose registry view names the host tools.
161
+ * @param policyId - the selected policy id, or undefined for no policy.
162
+ * @returns the member fields to persist (`toolPolicy` + `toolFilter`), or `{}`.
163
+ */
164
+ function memberToolSelection(config, captain, policyId) {
165
+ if (policyId === undefined)
166
+ return {};
167
+ const policy = requireToolPolicy(config.toolPolicies, policyId);
168
+ return {
169
+ toolPolicy: policyId,
170
+ toolFilter: resolveMemberToolFilter(policy, {
171
+ maxDepth: config.memberMaxDepth,
172
+ knownTools: restrictableToolNames(captain),
173
+ }),
174
+ };
175
+ }
176
+ /**
177
+ * Deny names the lenient spawn retry may drop for one member.
178
+ *
179
+ * A member without a policy keeps the built-in #163/#164 recovery path. A
180
+ * policy member may only relax built-in entries the owner did not declare
181
+ * itself (`memberSpawnRelaxableDeny`), and a selected policy id this
182
+ * deployment no longer declares relaxes nothing: the member then fails loud
183
+ * instead of spawning under a different filter.
184
+ * @param config - resolved plugin config.
185
+ * @param member - the member being spawned.
186
+ * @returns the relaxable deny names, or undefined for the legacy default.
187
+ */
188
+ function memberRelaxableDeny(config, member) {
189
+ if (member.toolPolicy === undefined)
190
+ return undefined;
191
+ return memberSpawnRelaxableDeny(optionalToolPolicy(config.toolPolicies, member.toolPolicy), true);
192
+ }
119
193
  /** Validate references and cycles before a staged graph can be saved or run. */
120
194
  function validateStagedGraph(team, requireRunnable) {
121
195
  const members = team.members.filter((member) => member.status !== 'removed');
@@ -183,6 +257,106 @@ function captainOpenTask(team, exceptTaskId) {
183
257
  * boundary: it waits for the activation handles to release, so a child cannot
184
258
  * keep executing after the captain-chat Stop control has reported success.
185
259
  */
260
+ /**
261
+ * The live Agent the host admits member operations from.
262
+ *
263
+ * Harness continuable delivery is authorized against the member's durable
264
+ * direct parent (`subagents.authorizeLineage`), which is the session that
265
+ * spawned it — the team's recorded captain. A captain acting from its own fork
266
+ * therefore commands members *through* that session:
267
+ *
268
+ * - while the recorded captain is resident, its Agent is used directly;
269
+ * - while it is cold, this resolves it through the public session controller
270
+ * (`workspaceRegistry.create` + `sessionController.create` with the existing
271
+ * session id — attach/resume only: no prompt, no model request, and the
272
+ * durable `captainSessionId` is never rewritten);
273
+ * - when the composition exposes neither service, or the resume fails, the
274
+ * operation fails with an explicit bounded error instead of migrating team
275
+ * ownership to a session the host would reject at delivery time.
276
+ *
277
+ * @param ctx - the plugin context (injects `agents`).
278
+ * @param team - current team state.
279
+ * @param caller - the captain-side session performing the operation.
280
+ * @returns the caller itself, or the live recorded captain session.
281
+ */
282
+ async function memberOperationParent(ctx, team, caller) {
283
+ if (caller.id === team.captainSessionId)
284
+ return caller;
285
+ const resident = ctx.agents.get(team.captainSessionId);
286
+ if (resident !== undefined)
287
+ return resident;
288
+ const resumed = await resumeLeadAnchor(ctx, team, caller);
289
+ if (resumed !== undefined)
290
+ return resumed;
291
+ throw new Error(`member operations require the live captain session ${team.captainSessionId} that owns team "${team.name}"; this session is its fork and ${resumeUnavailableReason(ctx, team)}`);
292
+ }
293
+ /**
294
+ * Best-effort live anchor for scheduler calls: the recorded captain when
295
+ * resident, the resumed one when this composition can attach it, and
296
+ * `undefined` otherwise (the scheduler then keeps its existing
297
+ * registry-only behavior). Never throws, so mutating tools stay usable on
298
+ * compositions without a session controller.
299
+ * @param ctx - the plugin context (injects `agents`).
300
+ * @param team - the team whose recorded captain is the delivery parent.
301
+ * @param caller - the captain-side session performing the operation.
302
+ * @returns the anchor agent when one is live, else undefined.
303
+ */
304
+ async function liveLeadAnchorAgent(ctx, team, caller) {
305
+ if (caller.id === team.captainSessionId)
306
+ return caller;
307
+ const resident = ctx.agents.get(team.captainSessionId);
308
+ if (resident !== undefined)
309
+ return resident;
310
+ return resumeLeadAnchor(ctx, team, caller);
311
+ }
312
+ /** Why the recorded captain could not be resumed, for one bounded error message. */
313
+ function resumeUnavailableReason(ctx, team) {
314
+ const services = optionalTeamServices(ctx);
315
+ if (services.controller === undefined || services.registry === undefined) {
316
+ return 'this composition exposes no session controller to resume it, so add_member, member delivery and member drain are unavailable from the fork';
317
+ }
318
+ return `resuming it through the session controller failed (its workspace is "${team.name}"'s workspace)`;
319
+ }
320
+ /** Public optional services used only to attach the recorded captain session. */
321
+ function optionalTeamServices(ctx) {
322
+ const get = ctx.get;
323
+ if (typeof get !== 'function')
324
+ return {};
325
+ const read = (name) => {
326
+ try {
327
+ return get.call(ctx, name);
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ };
333
+ return {
334
+ controller: read('sessionController'),
335
+ registry: read('workspaceRegistry'),
336
+ };
337
+ }
338
+ /**
339
+ * Attach the team's recorded captain session in this process without sending a
340
+ * prompt: the Harness controller's create-with-existing-id path resumes the
341
+ * session and leaves every durable field (including `captainSessionId`)
342
+ * untouched.
343
+ * @returns the resumed Agent, or undefined when this composition cannot resume it.
344
+ */
345
+ async function resumeLeadAnchor(ctx, team, caller) {
346
+ const { controller, registry } = optionalTeamServices(ctx);
347
+ if (controller?.create === undefined || registry?.create === undefined)
348
+ return undefined;
349
+ const cwd = caller.session.header.cwd ?? process.cwd();
350
+ try {
351
+ const workspace = await registry.create(cwd, cwd);
352
+ await controller.create({ sessionId: team.captainSessionId, workspaceId: workspace.id, agentPreset: 'standard' });
353
+ return ctx.agents.get(team.captainSessionId);
354
+ }
355
+ catch (error) {
356
+ ctx.logger.warn(`agent-teams: could not resume the recorded captain session ${team.captainSessionId}: ${String(error)}`);
357
+ return undefined;
358
+ }
359
+ }
186
360
  async function stopTeamMemberActivations(ctx, captain, members, signal) {
187
361
  const memberIds = members.filter(member => member.id !== '').map(member => member.id);
188
362
  if (memberIds.length === 0)
@@ -190,14 +364,15 @@ async function stopTeamMemberActivations(ctx, captain, members, signal) {
190
364
  // Every supported exact host has targeted recursive drain. Unlike interrupt,
191
365
  // it closes admission and clears queued work before awaiting descendants.
192
366
  signal?.throwIfAborted();
193
- await ctx.subagents.drainContinuableChildren(captain, memberIds);
367
+ await drainMemberChildren(ctx.subagents, captain, memberIds);
194
368
  }
195
369
  export async function haltTeamWork(input) {
196
370
  const halted = await withTeamLock(teamLockKey(input.stateRoot, input.teamId), async () => {
197
- const fresh = await requireFreshCaptainTeam(input.stateRoot, input.teamId, input.captain.id);
371
+ const fresh = await requireFreshCaptainTeam(input.ctx, input.stateRoot, input.teamId, input.captain.id);
198
372
  if (fresh.halted === true) {
199
373
  return {
200
374
  teamName: fresh.name,
375
+ captainSessionId: fresh.captainSessionId,
201
376
  cancelledTasks: fresh.tasks.filter((task) => task.status === 'cancelled').length,
202
377
  alreadyHalted: true,
203
378
  members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
@@ -225,6 +400,7 @@ export async function haltTeamWork(input) {
225
400
  });
226
401
  return {
227
402
  teamName: fresh.name,
403
+ captainSessionId: fresh.captainSessionId,
228
404
  cancelledTasks,
229
405
  alreadyHalted: false,
230
406
  members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
@@ -234,7 +410,17 @@ export async function haltTeamWork(input) {
234
410
  // children. Otherwise its current model turn can observe `halted`, call
235
411
  // resume, and race the still-running HTTP stop request.
236
412
  input.captain.cancel({ kind: 'user' }, { keepInbox: true });
237
- await stopTeamMemberActivations(input.ctx, input.captain, halted.members, input.signal);
413
+ // Members are children of the recorded captain session, so the parent-bound
414
+ // drain must run as that session when a fork requested the stop.
415
+ const haltAnchor = input.captain.id === halted.captainSessionId
416
+ ? input.captain
417
+ : input.ctx.agents.get(halted.captainSessionId);
418
+ if (haltAnchor === undefined) {
419
+ input.ctx.logger.warn(`agent-teams: captain session ${halted.captainSessionId} is not live; member activations of "${halted.teamName}" were not drained from this session`);
420
+ }
421
+ else {
422
+ await stopTeamMemberActivations(input.ctx, haltAnchor, halted.members, input.signal);
423
+ }
238
424
  // Interrupting a child emits a trailing subagent-settled notification. That
239
425
  // notification can start a fresh Captain turn after the first cancellation,
240
426
  // so close the stop boundary again once every child activation has drained.
@@ -243,6 +429,7 @@ export async function haltTeamWork(input) {
243
429
  input.captain.cancel({ kind: 'user' }, { keepInbox: true });
244
430
  return {
245
431
  teamName: halted.teamName,
432
+ captainSessionId: halted.captainSessionId,
246
433
  cancelledTasks: halted.cancelledTasks,
247
434
  alreadyHalted: halted.alreadyHalted,
248
435
  };
@@ -284,7 +471,6 @@ export function registerAgentTeamsTools(ctx, config) {
284
471
  installMemberDelegationGuard(ctx, config.stateDir, config.memberMaxDepth ?? 0);
285
472
  installMailboxAdmission(ctx, config.stateDir);
286
473
  const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt, dispatch: dispatchMember });
287
- const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
288
474
  async function dispatchMember(captain, teamId, memberName, text, signal, mode, attemptId) {
289
475
  const root = stateRootOf(workspaceOf(captain), config);
290
476
  // Record why a member never started. The scheduler treats a failed dispatch as
@@ -293,7 +479,7 @@ export function registerAgentTeamsTools(ctx, config) {
293
479
  const recordSpawnError = async (reason) => {
294
480
  try {
295
481
  await withTeamLock(teamLockKey(root, teamId), async () => {
296
- const fresh = await requireFreshCaptainTeam(root, teamId, captain.id);
482
+ const fresh = await requireFreshCaptainTeam(ctx, root, teamId, captain.id);
297
483
  const failed = fresh.members.find(item => item.name === memberName && item.status !== 'removed');
298
484
  if (failed === undefined || failed.id !== '')
299
485
  return;
@@ -309,7 +495,7 @@ export function registerAgentTeamsTools(ctx, config) {
309
495
  try {
310
496
  return await withTeamLock(teamLockKey(root, teamId), async () => {
311
497
  const team = await readTeam(root, teamId);
312
- if (team?.captainSessionId !== captain.id || team.halted === true || team.phase === 'staged')
498
+ if (team === undefined || !(await sessionLeadsTeamAsync(ctx, team, captain.id)) || team.halted === true || team.phase === 'staged')
313
499
  return false;
314
500
  const member = team.members.find(item => item.name === memberName && item.status !== 'removed');
315
501
  if (member === undefined || member.stopping === true || team.tasks.some(task => task.reassigning === true && task.assignee === memberName))
@@ -321,7 +507,7 @@ export function registerAgentTeamsTools(ctx, config) {
321
507
  const selection = await resolveMemberLlmSelection(ctx, captain, {
322
508
  provider: member.provider, model: member.model, reasoningEffort: member.reasoningEffort, fallback: member.fallback,
323
509
  }, signal);
324
- await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, team, member, config.stateDir, signal, text);
510
+ await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, team, member, config.stateDir, signal, text, member.toolFilter, memberRelaxableDeny(config, member));
325
511
  orphan = { ...member };
326
512
  delete member.spawnError;
327
513
  await writeTeam(root, team);
@@ -349,7 +535,7 @@ export function registerAgentTeamsTools(ctx, config) {
349
535
  const workspace = workspaceOf(captain);
350
536
  const stateRoot = stateRootOf(workspace, config);
351
537
  return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
352
- const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
538
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, teamId, captain.id);
353
539
  const staged = fresh.phase === 'staged';
354
540
  if (!staged && !allowPendingEdits)
355
541
  requireStagedTeam(fresh);
@@ -374,6 +560,14 @@ export function registerAgentTeamsTools(ctx, config) {
374
560
  member.model = selection.model;
375
561
  member.reasoningEffort = selection.reasoningEffort;
376
562
  member.executionPrompt = trimmedOptional(mutation.executionPrompt);
563
+ // The roster editor replaces route/prompt fields; the tool policy is
564
+ // only touched when the mutation names one, so a plan revision cannot
565
+ // silently widen a member's tool set.
566
+ if (mutation.toolPolicy !== undefined) {
567
+ const selected = memberToolSelection(config, captain, trimmedOptional(mutation.toolPolicy));
568
+ member.toolPolicy = selected.toolPolicy;
569
+ member.toolFilter = selected.toolFilter;
570
+ }
377
571
  }
378
572
  else if (mutation.action === 'update_task') {
379
573
  const task = requireTask(fresh, mutation.taskId);
@@ -455,7 +649,7 @@ export function registerAgentTeamsTools(ctx, config) {
455
649
  const stateRoot = stateRootOf(workspace, config);
456
650
  const runSignal = signal ?? new AbortController().signal;
457
651
  const approved = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
458
- const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
652
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, teamId, captain.id);
459
653
  requireStagedTeam(fresh);
460
654
  // A staged removal has no child session to retain in history. Drop those
461
655
  // placeholders before transitioning to the stricter running shape.
@@ -479,7 +673,7 @@ export function registerAgentTeamsTools(ctx, config) {
479
673
  return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
480
674
  });
481
675
  try {
482
- await scheduler.kickTeam(workspace, teamId, captain);
676
+ await scheduler.kickTeam(workspace, teamId, await liveLeadAnchorAgent(ctx, { captainSessionId: captain.id, name: teamId }, captain));
483
677
  }
484
678
  catch (error) {
485
679
  // Approval is already durably committed. A transient wake-up failure is
@@ -493,7 +687,7 @@ export function registerAgentTeamsTools(ctx, config) {
493
687
  const workspace = workspaceOf(captain);
494
688
  const stateRoot = stateRootOf(workspace, config);
495
689
  const prepared = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
496
- const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
690
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, teamId, captain.id);
497
691
  requireStagedTeam(fresh);
498
692
  if (fresh.planReviewState === 'awaiting_feedback') {
499
693
  return { teamName: fresh.name, alreadyWaiting: true };
@@ -518,7 +712,7 @@ export function registerAgentTeamsTools(ctx, config) {
518
712
  // Do not leave the durable UI in a false waiting state when the live
519
713
  // Captain disappeared between lookup and delivery.
520
714
  await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
521
- const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
715
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, teamId, captain.id);
522
716
  requireStagedTeam(fresh);
523
717
  if (fresh.planReviewState === 'awaiting_feedback') {
524
718
  fresh.planReviewState = 'awaiting_review';
@@ -533,7 +727,7 @@ export function registerAgentTeamsTools(ctx, config) {
533
727
  const workspace = workspaceOf(captain);
534
728
  const stateRoot = stateRootOf(workspace, config);
535
729
  const discarded = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
536
- const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
730
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, teamId, captain.id);
537
731
  requireStagedTeam(fresh);
538
732
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/plan-discarded', {
539
733
  teamId: fresh.id,
@@ -561,14 +755,6 @@ export function registerAgentTeamsTools(ctx, config) {
561
755
  captain.cancel({ kind: 'user' }, { keepInbox: true });
562
756
  return { teamId: discarded.teamId };
563
757
  };
564
- const runtime = {
565
- isPendingMember: memberSelections.isPendingMember,
566
- updateStagedPlan,
567
- updateStagedPlanBatch,
568
- approveStagedTeam,
569
- continueStagedPlanning,
570
- discardStagedTeam,
571
- };
572
758
  ctx.tools.register(defineTool({
573
759
  name: 'agent_teams_create',
574
760
  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.',
@@ -583,6 +769,7 @@ export function registerAgentTeamsTools(ctx, config) {
583
769
  members: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
584
770
  name: { type: 'string', required: true }, role: { type: 'string' },
585
771
  provider: { type: 'string' }, model: { type: 'string' }, reasoning_effort: { type: 'string' },
772
+ tool_policy: { type: 'string', description: 'Optional owner-declared tool-policy id for this member.' },
586
773
  } } },
587
774
  tasks: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
588
775
  id: { type: 'string', required: true, description: 'Local reference used by dependencies in this plan; the result maps it to a durable task id.' },
@@ -604,11 +791,12 @@ export function registerAgentTeamsTools(ctx, config) {
604
791
  properties: {
605
792
  team_id: { type: 'string', required: true },
606
793
  team_name: { type: 'string', required: true },
794
+ captain_session_id: { type: 'string', required: true },
607
795
  state_dir: { type: 'string', required: true },
608
796
  phase: { type: 'string', required: true },
609
797
  profile: { type: 'string' },
610
798
  task_planning: { type: 'string' },
611
- 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 } } } },
799
+ members: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { member_name: { type: 'string', required: true }, member_id: { type: 'string', required: true }, member_role: { type: 'string' }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, reasoning_effort: { type: 'string' }, tool_policy: { type: 'string' }, status: { type: 'string', required: true } } } },
612
800
  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 } } } },
613
801
  },
614
802
  },
@@ -619,6 +807,23 @@ export function registerAgentTeamsTools(ctx, config) {
619
807
  : `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`)
620
808
  + (value.tasks === undefined ? '' : '\n' + value.tasks.map(task => `${task.task_id} [${task.seed_id}]: ${task.subject}; assignee=${task.assignee ?? 'unassigned'}; dependencies=${task.dependencies.join(',')}`).join('\n')),
621
809
  }],
810
+ // The conversation card folds this durable result, and the card cannot
811
+ // reproduce the host's key cleaning for every name (issue #203). Publish
812
+ // the canonical identity instead of letting a client re-derive it.
813
+ presentationMeta: (_args, value) => ({
814
+ team_id: value.team_id,
815
+ team_name: value.team_name,
816
+ captain_session_id: value.captain_session_id,
817
+ phase: value.phase,
818
+ ...value.members === undefined ? {} : {
819
+ members: value.members.map(member => ({
820
+ id: member.member_id,
821
+ name: member.member_name,
822
+ role: member.member_role ?? '',
823
+ status: member.status,
824
+ })),
825
+ },
826
+ }),
622
827
  },
623
828
  async execute(args, exec) {
624
829
  const captain = requireCaptain(exec);
@@ -638,10 +843,11 @@ export function registerAgentTeamsTools(ctx, config) {
638
843
  if (profileName !== undefined && args.plan !== undefined)
639
844
  throw new Error('choose either a configured profile or an inline plan');
640
845
  const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
641
- const current = await findTeamByParticipant(stateRoot, captain.id);
846
+ const current = await findTeamByParticipant(stateRoot, captain.id, await leadIdentityOfAsync(ctx, captain.session));
642
847
  if (current !== undefined) {
643
- const relationship = current.captainSessionId === captain.id ? 'lead' : 'belong to';
644
- const guidance = current.captainSessionId === captain.id
848
+ const leads = await sessionLeadsTeamAsync(ctx, current, captain.id);
849
+ const relationship = leads ? 'lead' : 'belong to';
850
+ const guidance = leads
645
851
  ? 'Use agent_teams_status and continue the existing team. Do not delete and recreate it merely to continue work. End it only when the user explicitly wants a separate new team.'
646
852
  : 'Continue your assigned member work and report to your captain; do not create a separate team.';
647
853
  throw new Error(`you already ${relationship} team "${current.name}" (id ${current.id}). ${guidance}`);
@@ -684,7 +890,7 @@ export function registerAgentTeamsTools(ctx, config) {
684
890
  });
685
891
  if (created.committed) {
686
892
  try {
687
- await scheduler.kickTeam(workspace, created.state.id, captain);
893
+ await scheduler.kickTeam(workspace, created.state.id, await liveLeadAnchorAgent(ctx, created.state, captain));
688
894
  }
689
895
  catch (error) {
690
896
  ctx.logger.warn(`agent-teams: post-create kick failed for "${created.state.id}": ${String(error)}`);
@@ -725,6 +931,7 @@ export function registerAgentTeamsTools(ctx, config) {
725
931
  return {
726
932
  team_id: snapshot.id,
727
933
  team_name: snapshot.name,
934
+ captain_session_id: snapshot.captainSessionId,
728
935
  state_dir: join(stateRoot, snapshot.id),
729
936
  phase: snapshot.phase ?? 'running',
730
937
  };
@@ -732,6 +939,7 @@ export function registerAgentTeamsTools(ctx, config) {
732
939
  return {
733
940
  team_id: snapshot.id,
734
941
  team_name: snapshot.name,
942
+ captain_session_id: snapshot.captainSessionId,
735
943
  state_dir: join(stateRoot, snapshot.id),
736
944
  phase: snapshot.phase ?? 'running',
737
945
  ...snapshot.profile === undefined ? {} : { profile: snapshot.profile.name },
@@ -739,9 +947,11 @@ export function registerAgentTeamsTools(ctx, config) {
739
947
  members: snapshot.members.map((member) => ({
740
948
  member_name: member.name,
741
949
  member_id: member.id,
950
+ ...member.role === undefined ? {} : { member_role: member.role },
742
951
  provider: member.provider ?? '',
743
952
  model: member.model ?? '',
744
953
  ...member.reasoningEffort === undefined ? {} : { reasoning_effort: member.reasoningEffort },
954
+ ...member.toolPolicy === undefined ? {} : { tool_policy: member.toolPolicy },
745
955
  status: member.status,
746
956
  })),
747
957
  tasks: snapshot.tasks.map((task) => ({
@@ -784,6 +994,7 @@ export function registerAgentTeamsTools(ctx, config) {
784
994
  model: { type: 'string', description: 'Optional member model; defaults to the current staged route.' },
785
995
  reasoning_effort: { type: 'string', description: 'Optional member reasoning effort.' },
786
996
  execution_prompt: { type: 'string', description: 'Optional member-specific execution prompt.' },
997
+ tool_policy: { type: 'string', description: 'Optional owner-declared tool-policy id for this member; an empty string clears it back to the unchanged default tool set.' },
787
998
  },
788
999
  },
789
1000
  },
@@ -810,7 +1021,7 @@ export function registerAgentTeamsTools(ctx, config) {
810
1021
  async execute(args, exec) {
811
1022
  const captain = requireCaptain(exec);
812
1023
  const workspace = workspaceOf(captain);
813
- const team = await requireCaptainTeam(workspace, config, captain);
1024
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
814
1025
  if (args.operations.length === 0)
815
1026
  throw new Error('at least one staged plan operation is required');
816
1027
  const mutations = args.operations.map((operation, index) => {
@@ -828,6 +1039,9 @@ export function registerAgentTeamsTools(ctx, config) {
828
1039
  model: operation.model?.trim() || member.model || '',
829
1040
  reasoningEffort: operation.reasoning_effort ?? member.reasoningEffort,
830
1041
  executionPrompt: operation.execution_prompt ?? member.executionPrompt,
1042
+ // Omitted means "keep the current policy": a route/prompt revision
1043
+ // must not silently change what the member may do.
1044
+ ...operation.tool_policy === undefined ? {} : { toolPolicy: operation.tool_policy },
831
1045
  };
832
1046
  }
833
1047
  if (operation.action === 'update_task') {
@@ -869,7 +1083,7 @@ export function registerAgentTeamsTools(ctx, config) {
869
1083
  });
870
1084
  const updated = await updatePlanBatch(captain, team.id, mutations, exec.signal, true);
871
1085
  if (updated.phase !== 'staged')
872
- await scheduler.kickTeam(workspace, team.id, captain);
1086
+ await scheduler.kickTeam(workspace, team.id, await liveLeadAnchorAgent(ctx, team, captain));
873
1087
  return {
874
1088
  status: updated.phase ?? 'running',
875
1089
  team_id: updated.id,
@@ -908,7 +1122,7 @@ export function registerAgentTeamsTools(ctx, config) {
908
1122
  throw new Error('explicit user approval text is required');
909
1123
  const captain = requireCaptain(exec);
910
1124
  const workspace = workspaceOf(captain);
911
- const team = await requireCaptainTeam(workspace, config, captain);
1125
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
912
1126
  const approved = await approveStagedTeam(captain, team.id, exec.signal);
913
1127
  return { status: 'running', team_id: approved.teamId, members: approved.members, tasks: approved.tasks };
914
1128
  },
@@ -919,10 +1133,11 @@ export function registerAgentTeamsTools(ctx, config) {
919
1133
  parameters: {
920
1134
  name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
921
1135
  role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
922
- provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
923
- model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
1136
+ provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model. Omit to use the configured member route (memberLlmProvider/memberModel) or the captain\'s current route.' },
1137
+ model: { type: 'string', description: 'Optional model override (used verbatim, including any "/" in the id). Omit for the configured memberModel default (paired with memberLlmProvider when set) or the captain\'s current model.' },
924
1138
  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.' },
925
1139
  executionPrompt: { type: 'string', description: 'Optional member-specific execution prompt. It remains editable while staged.' },
1140
+ tool_policy: { type: 'string', description: 'Optional id of a tool policy declared by the deployment owner (config toolPolicies). It restricts which global tools this member sees and may invoke: a policy allow-list also keeps the four coordination tools, and captain-only tools can never be reopened. Omit for the unchanged default tool set.' },
926
1141
  },
927
1142
  output: {
928
1143
  schema: {
@@ -934,24 +1149,26 @@ export function registerAgentTeamsTools(ctx, config) {
934
1149
  provider: { type: 'string', required: true },
935
1150
  model: { type: 'string', required: true },
936
1151
  reasoning_effort: { type: 'string' },
1152
+ tool_policy: { type: 'string' },
937
1153
  status: { type: 'string', required: true },
938
1154
  phase: { type: 'string', required: true },
939
1155
  },
940
1156
  },
941
1157
  render: (args, value) => [{
942
1158
  type: 'text',
943
- text: value.phase === 'staged'
1159
+ text: (value.phase === 'staged'
944
1160
  ? `Member "${value.member_name}" added to the staged roster (${value.provider}/${value.model}); no child was spawned.`
945
- : `Member "${value.member_name}" added (session ${value.member_id || 'starts with first ready task'}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
1161
+ : `Member "${value.member_name}" added (session ${value.member_id || 'starts with first ready task'}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`)
1162
+ + (value.tool_policy === undefined ? '' : ` Tool policy "${value.tool_policy}" applies to this member.`),
946
1163
  }],
947
1164
  },
948
1165
  async execute(args, exec) {
949
1166
  const captain = requireCaptain(exec);
950
1167
  const workspace = workspaceOf(captain);
951
1168
  const stateRoot = stateRootOf(workspace, config);
952
- const team = await requireCaptainTeam(workspace, config, captain);
1169
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
953
1170
  const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
954
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1171
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
955
1172
  const memberName = args.name.trim();
956
1173
  if (memberName === '')
957
1174
  throw new Error('member name must not be empty');
@@ -969,9 +1186,11 @@ export function registerAgentTeamsTools(ctx, config) {
969
1186
  provider: args.provider,
970
1187
  model: args.model,
971
1188
  defaultModel: config.memberModel,
1189
+ defaultProvider: config.memberLlmProvider,
972
1190
  reasoningEffort: args.reasoning_effort,
973
1191
  fallback: config.fallback,
974
1192
  }, exec.signal);
1193
+ const toolSelection = memberToolSelection(config, captain, trimmedOptional(args.tool_policy));
975
1194
  const member = {
976
1195
  id: '',
977
1196
  name: memberName,
@@ -981,6 +1200,7 @@ export function registerAgentTeamsTools(ctx, config) {
981
1200
  reasoningEffort: selection.reasoningEffort,
982
1201
  ...selection.fallback === undefined ? {} : { fallback: selection.fallback },
983
1202
  executionPrompt: trimmedOptional(args.executionPrompt),
1203
+ ...toolSelection,
984
1204
  joinedAt: Date.now(),
985
1205
  status: 'idle',
986
1206
  };
@@ -1001,11 +1221,12 @@ export function registerAgentTeamsTools(ctx, config) {
1001
1221
  ...selection.reasoningEffort === undefined
1002
1222
  ? {}
1003
1223
  : { reasoning_effort: selection.reasoningEffort },
1224
+ ...member.toolPolicy === undefined ? {} : { tool_policy: member.toolPolicy },
1004
1225
  status: member.status,
1005
1226
  phase: fresh.phase ?? 'running',
1006
1227
  };
1007
1228
  });
1008
- await scheduler.kickMember(workspace, team.id, created.member_name, captain);
1229
+ await scheduler.kickMember(workspace, team.id, created.member_name, await liveLeadAnchorAgent(ctx, team, captain));
1009
1230
  const latest = (await readTeam(stateRoot, team.id))?.members.find(member => member.name === created.member_name);
1010
1231
  return latest === undefined ? created : { ...created, member_id: latest.id, status: latest.status };
1011
1232
  },
@@ -1035,9 +1256,9 @@ export function registerAgentTeamsTools(ctx, config) {
1035
1256
  const captain = requireCaptain(exec);
1036
1257
  const workspace = workspaceOf(captain);
1037
1258
  const stateRoot = stateRootOf(workspace, config);
1038
- const team = await requireCaptainTeam(workspace, config, captain);
1259
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
1039
1260
  const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1040
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1261
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
1041
1262
  const member = fresh.members.find(item => item.name === args.name);
1042
1263
  if (member === undefined)
1043
1264
  throw new Error(`no member \"${args.name}\" in team \"${fresh.name}\"`);
@@ -1060,9 +1281,9 @@ export function registerAgentTeamsTools(ctx, config) {
1060
1281
  });
1061
1282
  if (revoked.member.id !== '') {
1062
1283
  await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
1063
- await stopTeamMemberActivations(ctx, captain, [revoked.member], exec.signal);
1284
+ await stopTeamMemberActivations(ctx, await memberOperationParent(ctx, team, captain), [revoked.member], exec.signal);
1064
1285
  }
1065
- await scheduler.kickTeam(workspace, team.id, captain);
1286
+ await scheduler.kickTeam(workspace, team.id, await liveLeadAnchorAgent(ctx, team, captain));
1066
1287
  return {
1067
1288
  member_name: revoked.member.name,
1068
1289
  status: revoked.member.status,
@@ -1123,14 +1344,14 @@ export function registerAgentTeamsTools(ctx, config) {
1123
1344
  const captain = requireCaptain(exec);
1124
1345
  const workspace = workspaceOf(captain);
1125
1346
  const stateRoot = stateRootOf(workspace, config);
1126
- const team = await requireCaptainTeam(workspace, config, captain);
1347
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
1127
1348
  // Some models materialize optional parameters as "" instead of omitting
1128
1349
  // them (issue #105). Normalize blank optional fields to omitted before
1129
1350
  // validation so a blank value can neither be rejected spuriously nor be
1130
1351
  // persisted into team.json, where it would brick the team on reload.
1131
1352
  const input = normalizeBlankOptionalTaskFields(args);
1132
1353
  const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1133
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1354
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
1134
1355
  const gate = validateCreateTask(fresh, {
1135
1356
  subject: input.subject,
1136
1357
  description: input.description,
@@ -1225,7 +1446,7 @@ export function registerAgentTeamsTools(ctx, config) {
1225
1446
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
1226
1447
  };
1227
1448
  });
1228
- await scheduler.kickTeam(workspace, team.id, captain);
1449
+ await scheduler.kickTeam(workspace, team.id, await liveLeadAnchorAgent(ctx, team, captain));
1229
1450
  return created;
1230
1451
  },
1231
1452
  }));
@@ -1259,12 +1480,12 @@ export function registerAgentTeamsTools(ctx, config) {
1259
1480
  const captain = requireCaptain(exec);
1260
1481
  const workspace = workspaceOf(captain);
1261
1482
  const stateRoot = stateRootOf(workspace, config);
1262
- const team = await requireCaptainTeam(workspace, config, captain);
1483
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
1263
1484
  const target = args.assignee.trim();
1264
1485
  if (target === '')
1265
1486
  throw new Error('reassignment assignee must not be empty');
1266
1487
  const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1267
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1488
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
1268
1489
  const task = requireTask(fresh, args.task_id);
1269
1490
  if (task.status === 'completed')
1270
1491
  throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
@@ -1312,14 +1533,14 @@ export function registerAgentTeamsTools(ctx, config) {
1312
1533
  let quiescenceError;
1313
1534
  if (revoked.previousMember !== undefined) {
1314
1535
  try {
1315
- await stopTeamMemberActivations(ctx, captain, [revoked.previousMember], exec.signal);
1536
+ await stopTeamMemberActivations(ctx, await memberOperationParent(ctx, team, captain), [revoked.previousMember], exec.signal);
1316
1537
  }
1317
1538
  catch (error) {
1318
1539
  quiescenceError = error;
1319
1540
  }
1320
1541
  }
1321
1542
  await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1322
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1543
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
1323
1544
  const task = requireTask(fresh, args.task_id);
1324
1545
  if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
1325
1546
  throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
@@ -1350,7 +1571,7 @@ export function registerAgentTeamsTools(ctx, config) {
1350
1571
  if (quiescenceError !== undefined)
1351
1572
  throw quiescenceError;
1352
1573
  if (target !== CAPTAIN_KEY)
1353
- await scheduler.kickMember(workspace, team.id, target, captain);
1574
+ await scheduler.kickMember(workspace, team.id, target, await liveLeadAnchorAgent(ctx, team, captain));
1354
1575
  const current = await readTeam(stateRoot, team.id);
1355
1576
  const task = current === undefined ? undefined : requireTask(current, args.task_id);
1356
1577
  if (task === undefined)
@@ -1394,9 +1615,9 @@ export function registerAgentTeamsTools(ctx, config) {
1394
1615
  const caller = requireCaptain(exec);
1395
1616
  const workspace = workspaceOf(caller);
1396
1617
  const stateRoot = stateRootOf(workspace, config);
1397
- const team = await requireParticipantTeam(workspace, config, caller);
1618
+ const team = await requireParticipantTeam(ctx, workspace, config, caller);
1398
1619
  return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1399
- const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
1620
+ const { team: fresh, identity } = await requireFreshParticipant(ctx, stateRoot, team.id, caller.id);
1400
1621
  const task = requireTask(fresh, args.task_id);
1401
1622
  if (task.reassigning === true) {
1402
1623
  throw new Error(`task ${task.id} is being reassigned; wait for the handoff to finish`);
@@ -1558,10 +1779,10 @@ export function registerAgentTeamsTools(ctx, config) {
1558
1779
  const caller = requireCaptain(exec);
1559
1780
  const workspace = workspaceOf(caller);
1560
1781
  const stateRoot = stateRootOf(workspace, config);
1561
- const team = await requireParticipantTeam(workspace, config, caller);
1782
+ const team = await requireParticipantTeam(ctx, workspace, config, caller);
1562
1783
  let followUpMessage;
1563
1784
  const updated = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1564
- const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
1785
+ const { team: fresh, identity } = await requireFreshParticipant(ctx, stateRoot, team.id, caller.id);
1565
1786
  const task = requireTask(fresh, args.task_id);
1566
1787
  if (identity.kind === 'captain'
1567
1788
  && task.assignee !== undefined
@@ -1635,19 +1856,22 @@ export function registerAgentTeamsTools(ctx, config) {
1635
1856
  task.acceptanceResults = acceptanceResults;
1636
1857
  if (commandsRun !== undefined)
1637
1858
  task.commandsRun = commandsRun;
1859
+ // A passing gate is evidence about a revision: record which one, so a
1860
+ // later edit cannot leave the team advertising an unreviewed revision.
1861
+ bindClosingVerdict(workspace, fresh, task);
1638
1862
  task.updatedAt = Date.now();
1639
1863
  const priorDependencies = new Map(fresh.tasks.map(item => [item.id, [...item.dependencies]]));
1640
- const followUp = (task.status === 'failed' && (task.verdict === 'needs_revision' || task.verdict === 'reject'))
1641
- ? applyQualityFollowUp(fresh, task)
1864
+ const followUp = (task.status === 'failed' && isQualityKind(taskKindOf(task)))
1865
+ ? applyQualityFollowUp(fresh, task, (paths) => snapshotWorkspacePaths(workspace, paths), (path) => workspacePathExists(workspace, path))
1642
1866
  : undefined;
1643
1867
  let followUpSummary;
1644
1868
  if ((followUp?.created.length ?? 0) > 0) {
1645
1869
  const rewired = fresh.tasks.filter(item => priorDependencies.has(item.id) && JSON.stringify(priorDependencies.get(item.id)) !== JSON.stringify(item.dependencies));
1646
- followUpSummary = `Automatic quality follow-up for ${task.id}: ${followUp.created.map(item => `${item.id} (${item.kind}, owner=${item.assignee ?? 'unassigned'}, deps=${item.dependencies.join(',') || 'none'})`).join('; ')}.${rewired.length ? ` Updated dependencies: ${rewired.map(item => `${item.id} -> ${item.dependencies.join(',')}`).join('; ')}.` : ''} Use these tasks; do not create duplicate repair/review work.`;
1870
+ followUpSummary = `Automatic quality follow-up for ${task.id}: ${followUp.created.map(item => `${item.id} (${item.kind}, owner=${item.assignee ?? 'unassigned'}, deps=${item.dependencies.join(',') || 'none'})`).join('; ')}.${rewired.length ? ` Updated dependencies: ${rewired.map(item => `${item.id} -> ${item.dependencies.join(',')}`).join('; ')}.` : ''} Use these tasks; do not create duplicate repair/review work.${followUp.excludedFindings === undefined ? '' : ` Findings on default-excluded paths were not turned into a contract and stay with the user: ${followUp.excludedFindings.join(', ')}.`}`;
1647
1871
  followUpMessage = createMessage(CAPTAIN_KEY, CAPTAIN_KEY, followUpSummary);
1648
1872
  }
1649
1873
  if (followUp?.escalated === true) {
1650
- 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.`));
1874
+ 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.${followUp.reason === undefined ? '' : ` Reason: ${followUp.reason}.`}${followUp.excludedFindings === undefined ? '' : ` Findings left for the user (default-excluded paths, not a member's to edit): ${followUp.excludedFindings.join(', ')}.`}`));
1651
1875
  }
1652
1876
  await writeTeam(stateRoot, fresh);
1653
1877
  if (followUpMessage !== undefined)
@@ -1682,12 +1906,12 @@ export function registerAgentTeamsTools(ctx, config) {
1682
1906
  };
1683
1907
  });
1684
1908
  if (followUpMessage !== undefined) {
1685
- const captain = ctx.agents.get(team.captainSessionId);
1909
+ const captain = nearestLiveLead(ctx, team);
1686
1910
  if (captain !== undefined && steerCaptainReport(captain, CAPTAIN_KEY, followUpMessage.content, mailboxPrompt(team.id, CAPTAIN_KEY, [followUpMessage]))) {
1687
1911
  await withTeamLock(teamLockKey(stateRoot, team.id), () => markMailboxDelivered(stateRoot, team.id, CAPTAIN_KEY, [followUpMessage.id]));
1688
1912
  }
1689
1913
  }
1690
- await scheduler.kickTeam(workspace, team.id, team.captainSessionId === caller.id ? caller : undefined);
1914
+ await scheduler.kickTeam(workspace, team.id, await liveLeadAnchorAgent(ctx, team, caller));
1691
1915
  return updated;
1692
1916
  },
1693
1917
  }));
@@ -1724,9 +1948,9 @@ export function registerAgentTeamsTools(ctx, config) {
1724
1948
  const captain = requireCaptain(exec);
1725
1949
  const workspace = workspaceOf(captain);
1726
1950
  const stateRoot = stateRootOf(workspace, config);
1727
- const team = await requireCaptainTeam(workspace, config, captain);
1951
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
1728
1952
  const amended = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1729
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1953
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
1730
1954
  const task = requireTask(fresh, args.task_id);
1731
1955
  const input = {
1732
1956
  ...args.objective === undefined ? {} : { objective: args.objective },
@@ -1801,10 +2025,10 @@ export function registerAgentTeamsTools(ctx, config) {
1801
2025
  const caller = requireCaptain(exec);
1802
2026
  const workspace = workspaceOf(caller);
1803
2027
  const stateRoot = stateRootOf(workspace, config);
1804
- const team = await requireParticipantTeam(workspace, config, caller);
2028
+ const team = await requireParticipantTeam(ctx, workspace, config, caller);
1805
2029
  const to = args.to.trim();
1806
2030
  const prepared = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1807
- const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
2031
+ const { team: fresh, identity } = await requireFreshParticipant(ctx, stateRoot, team.id, caller.id);
1808
2032
  const from = identity.name;
1809
2033
  // `from` may only be the caller's own identity: impersonating another
1810
2034
  // member (or the captain) would poison the mailbox and event records.
@@ -1862,9 +2086,11 @@ export function registerAgentTeamsTools(ctx, config) {
1862
2086
  });
1863
2087
  if (prepared.kind === 'duplicate')
1864
2088
  return { message_id: prepared.message.id, from: prepared.from, to: prepared.message.to, delivered: 'duplicate' };
1865
- // Resolve the exact live captain only after releasing the state lock.
1866
- // The plugin mailbox is already durable if live delivery cannot proceed.
1867
- const captain = ctx.agents.get(prepared.fresh.captainSessionId);
2089
+ // Resolve a live lead only after releasing the state lock: the recorded
2090
+ // captain while resident, otherwise the nearest live fork descendant, so
2091
+ // a human working in a fork still receives member reports. The plugin
2092
+ // mailbox is already durable if no lead session is live.
2093
+ const captain = nearestLiveLead(ctx, prepared.fresh);
1868
2094
  if (prepared.kind === 'captain') {
1869
2095
  let delivered = 'mailbox';
1870
2096
  if (captain !== undefined && prepared.identity.kind === 'member') {
@@ -1910,12 +2136,13 @@ export function registerAgentTeamsTools(ctx, config) {
1910
2136
  const caller = requireCaptain(exec);
1911
2137
  const workspace = workspaceOf(caller);
1912
2138
  const stateRoot = stateRootOf(workspace, config);
1913
- const located = await requireParticipantTeam(workspace, config, caller);
1914
- if (located.captainSessionId === caller.id) {
1915
- await scheduler.kickTeam(workspace, located.id, caller);
2139
+ const located = await requireParticipantTeam(ctx, workspace, config, caller);
2140
+ if (await sessionLeadsTeamAsync(ctx, located, caller.id)) {
2141
+ await scheduler.kickTeam(workspace, located.id, await memberOperationParent(ctx, located, caller));
1916
2142
  }
1917
- const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
2143
+ const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(ctx, stateRoot, located.id, caller.id));
1918
2144
  const activity = memberActivity(ctx, team.members.map((member) => member.id));
2145
+ const audit = auditTeamWorkspace(workspace, team);
1919
2146
  const members = team.members
1920
2147
  .filter((member) => member.status !== 'removed')
1921
2148
  .map((member) => ({
@@ -1924,6 +2151,7 @@ export function registerAgentTeamsTools(ctx, config) {
1924
2151
  provider: member.provider ?? '',
1925
2152
  model: member.model ?? '',
1926
2153
  reasoning_effort: member.reasoningEffort ?? '',
2154
+ tool_policy: member.toolPolicy ?? '',
1927
2155
  status: member.status,
1928
2156
  activity: member.id !== '' ? (activity.get(member.id) ?? 'unknown') : 'unspawned',
1929
2157
  ...member.spawnError === undefined ? {} : { spawn_error: member.spawnError },
@@ -1941,6 +2169,7 @@ export function registerAgentTeamsTools(ctx, config) {
1941
2169
  ...task.round === undefined ? {} : { round: task.round },
1942
2170
  ...task.verdict === undefined ? {} : { verdict: task.verdict },
1943
2171
  ...task.supplementalEvidence === undefined ? {} : { supplemental_evidence: JSON.stringify(task.supplementalEvidence) },
2172
+ ...task.findingSourceTaskIds === undefined ? {} : { fixes: task.findingSourceTaskIds.join(',') },
1944
2173
  findings_open: (task.findings ?? []).filter((finding) => finding.resolved !== true).length,
1945
2174
  ...task.profileSeedId === undefined ? {} : { seed_id: task.profileSeedId },
1946
2175
  ...task.output !== undefined ? { output: task.output } : {},
@@ -1976,9 +2205,13 @@ export function registerAgentTeamsTools(ctx, config) {
1976
2205
  status: row.status,
1977
2206
  ...row.evidence === undefined ? {} : { evidence: row.evidence },
1978
2207
  }));
1979
- const deliveryCheck = canDeclareDelivery(team);
1980
- const delivery = { ok: deliveryCheck.ok, blockers: [...deliveryCheck.blockers] };
1981
- const loop = describeQualityLoop(team);
2208
+ const deliveryCheck = canDeclareDelivery(team, audit);
2209
+ const delivery = {
2210
+ ok: deliveryCheck.ok,
2211
+ blockers: [...deliveryCheck.blockers],
2212
+ warnings: [...deliveryCheck.warnings],
2213
+ };
2214
+ const loop = describeQualityLoop(team, audit);
1982
2215
  const result = {
1983
2216
  team_id: team.id,
1984
2217
  team_name: team.name,
@@ -2049,9 +2282,9 @@ export function registerAgentTeamsTools(ctx, config) {
2049
2282
  const captain = requireCaptain(exec);
2050
2283
  const workspace = workspaceOf(captain);
2051
2284
  const stateRoot = stateRootOf(workspace, config);
2052
- const team = await requireCaptainTeam(workspace, config, captain);
2285
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
2053
2286
  const result = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
2054
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
2287
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
2055
2288
  const resumed = resumeTeamState(fresh, args.reason);
2056
2289
  if (resumed.status === 'rejected')
2057
2290
  throw new Error(resumed.error ?? 'resume rejected');
@@ -2071,7 +2304,7 @@ export function registerAgentTeamsTools(ctx, config) {
2071
2304
  };
2072
2305
  });
2073
2306
  if (result.status === 'resumed')
2074
- await scheduler.kickTeam(workspace, team.id, captain);
2307
+ await scheduler.kickTeam(workspace, team.id, await liveLeadAnchorAgent(ctx, team, captain));
2075
2308
  return result;
2076
2309
  },
2077
2310
  }));
@@ -2097,9 +2330,9 @@ export function registerAgentTeamsTools(ctx, config) {
2097
2330
  const captain = requireCaptain(exec);
2098
2331
  const workspace = workspaceOf(captain);
2099
2332
  const stateRoot = stateRootOf(workspace, config);
2100
- const team = await requireCaptainTeam(workspace, config, captain);
2333
+ const team = await requireCaptainTeam(ctx, workspace, config, captain);
2101
2334
  const members = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
2102
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
2335
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
2103
2336
  // Include previously removed members so deleting a pre-fix team also
2104
2337
  // retires durable catalog entries left behind by remove_member.
2105
2338
  const roster = fresh.members.map(member => ({ ...member }));
@@ -2117,9 +2350,9 @@ export function registerAgentTeamsTools(ctx, config) {
2117
2350
  return roster;
2118
2351
  });
2119
2352
  await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
2120
- await stopTeamMemberActivations(ctx, captain, members, exec.signal);
2353
+ await stopTeamMemberActivations(ctx, await memberOperationParent(ctx, team, captain), members, exec.signal);
2121
2354
  await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
2122
- const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
2355
+ const fresh = await requireFreshCaptainTeam(ctx, stateRoot, team.id, captain.id);
2123
2356
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
2124
2357
  teamId: fresh.id,
2125
2358
  });
@@ -2130,6 +2363,16 @@ export function registerAgentTeamsTools(ctx, config) {
2130
2363
  return { deleted: true, team_name: team.name };
2131
2364
  },
2132
2365
  }));
2366
+ // Live-member hydration validates saved filters against registered tools.
2367
+ const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
2368
+ const runtime = {
2369
+ isPendingMember: memberSelections.isPendingMember,
2370
+ updateStagedPlan,
2371
+ updateStagedPlanBatch,
2372
+ approveStagedTeam,
2373
+ continueStagedPlanning,
2374
+ discardStagedTeam,
2375
+ };
2133
2376
  return runtime;
2134
2377
  }
2135
2378
  async function initializeProfileTeam(input) {
@@ -2140,6 +2383,7 @@ async function initializeProfileTeam(input) {
2140
2383
  provider: template.provider,
2141
2384
  model: template.model,
2142
2385
  defaultModel: input.config.memberModel,
2386
+ defaultProvider: input.config.memberLlmProvider,
2143
2387
  reasoningEffort: template.reasoningEffort,
2144
2388
  fallback: template.fallback ?? profile.fallback ?? input.config.fallback,
2145
2389
  }, input.exec.signal));
@@ -2166,6 +2410,9 @@ async function initializeProfileTeam(input) {
2166
2410
  ...input.staged ? { phase: 'staged', planReviewState: 'awaiting_review' } : {},
2167
2411
  members: profile.members.map((template, index) => {
2168
2412
  const selection = selections[index];
2413
+ // A profile member selects a policy by id; an unknown id fails here,
2414
+ // before the team directory and any child session exist (#59).
2415
+ const toolSelection = memberToolSelection(input.config, input.captain, template.toolPolicy);
2169
2416
  return {
2170
2417
  id: '',
2171
2418
  name: template.name,
@@ -2175,6 +2422,7 @@ async function initializeProfileTeam(input) {
2175
2422
  reasoningEffort: selection.reasoningEffort,
2176
2423
  executionPrompt: template.executionPrompt ?? profile.executionPrompt ?? input.config.executionPrompt,
2177
2424
  ...selection.fallback === undefined ? {} : { fallback: selection.fallback },
2425
+ ...toolSelection,
2178
2426
  joinedAt: now,
2179
2427
  status: 'idle',
2180
2428
  };
@@ -2227,7 +2475,12 @@ function parseFindings(value) {
2227
2475
  // A blank optional file must be omitted, not persisted: durable-state
2228
2476
  // validation requires non-empty optional strings (issue #105 class).
2229
2477
  ...typeof raw['file'] === 'string' && raw['file'].trim() !== '' ? { file: raw['file'] } : {},
2230
- ...typeof raw['line'] === 'number' ? { line: raw['line'] } : {},
2478
+ // Only a contract-valid line is persisted. A model that materializes
2479
+ // "no line number" as -1 (or sends a non-integer) must not write a record
2480
+ // the durable validator rejects on the next read, which used to brick the
2481
+ // entire team (issue #188). The predicate is shared with
2482
+ // `isReviewFinding`, so the two sides cannot drift apart again.
2483
+ ...isValidFindingLine(raw['line']) ? { line: raw['line'] } : {},
2231
2484
  ...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
2232
2485
  };
2233
2486
  });
@@ -2279,8 +2532,8 @@ function parseCommandResults(value) {
2279
2532
  };
2280
2533
  });
2281
2534
  }
2282
- export function applyQualityFollowUp(team, closed) {
2283
- const planned = planQualityFollowUp(team, closed);
2535
+ export function applyQualityFollowUp(team, closed, snapshotPaths, pathExists) {
2536
+ const planned = planQualityFollowUp(team, closed, pathExists === undefined ? {} : { pathExists });
2284
2537
  if (planned.escalated === true)
2285
2538
  team.escalated = true;
2286
2539
  const created = [];
@@ -2299,6 +2552,12 @@ export function applyQualityFollowUp(team, closed) {
2299
2552
  return dependency;
2300
2553
  return idBySubject.get(dependency) ?? dependency;
2301
2554
  });
2555
+ // Record the workspace the generated work order was derived from, so a
2556
+ // later round that changes a cited file can mark it stale instead of
2557
+ // letting a member read a frozen snapshot as authoritative (issue #174).
2558
+ const contractSnapshot = draft.contractPaths === undefined || draft.contractPaths.length === 0 || snapshotPaths === undefined
2559
+ ? undefined
2560
+ : snapshotPaths(draft.contractPaths);
2302
2561
  const next = {
2303
2562
  id,
2304
2563
  subject: draft.subject ?? `${draft.kind}-round-${draft.round ?? 1}`,
@@ -2309,6 +2568,7 @@ export function applyQualityFollowUp(team, closed) {
2309
2568
  createdAt: now,
2310
2569
  updatedAt: now,
2311
2570
  kind: draft.kind,
2571
+ ...draft.description === undefined ? {} : { description: draft.description },
2312
2572
  ...draft.round === undefined ? {} : { round: draft.round },
2313
2573
  ...draft.objective === undefined ? {} : { objective: draft.objective },
2314
2574
  ...draft.inScope === undefined ? {} : { inScope: draft.inScope },
@@ -2317,7 +2577,9 @@ export function applyQualityFollowUp(team, closed) {
2317
2577
  ...draft.verify === undefined ? {} : { verify: draft.verify },
2318
2578
  ...draft.sourceTaskId === undefined ? {} : { sourceTaskId: draft.sourceTaskId },
2319
2579
  ...draft.sourceFindingIds === undefined ? {} : { sourceFindingIds: draft.sourceFindingIds },
2580
+ ...draft.findingSourceTaskIds === undefined ? {} : { findingSourceTaskIds: draft.findingSourceTaskIds },
2320
2581
  ...draft.reviewedTaskId === undefined ? {} : { reviewedTaskId: idBySubject.get(draft.reviewedTaskId) ?? draft.reviewedTaskId },
2582
+ ...contractSnapshot === undefined ? {} : { contractSnapshot },
2321
2583
  };
2322
2584
  team.tasks.push(next);
2323
2585
  created.push(next);
@@ -2336,7 +2598,12 @@ export function applyQualityFollowUp(team, closed) {
2336
2598
  task.updatedAt = now;
2337
2599
  }
2338
2600
  }
2339
- return { created, escalated: planned.escalated === true };
2601
+ return {
2602
+ created,
2603
+ escalated: planned.escalated === true,
2604
+ ...planned.reason === undefined ? {} : { reason: planned.reason },
2605
+ ...planned.excludedFindings === undefined ? {} : { excludedFindings: planned.excludedFindings },
2606
+ };
2340
2607
  }
2341
2608
  /** Build the `memberRuntime` config handed to member helpers. */
2342
2609
  function memberRuntime(config) {
@@ -2368,7 +2635,8 @@ function renderStatus(value) {
2368
2635
  const route = member.provider && member.model ? ` · ${member.provider}/${member.model}` : '';
2369
2636
  const effort = member.reasoning_effort ? ` · reasoning ${member.reasoning_effort}` : '';
2370
2637
  const failure = member.spawn_error === undefined ? '' : `\n start failed: ${member.spawn_error.slice(0, 400)}`;
2371
- return ` - ${member.name} [${member.role}] ${member.status}/${member.activity}${route}${effort}${failure}`;
2638
+ const policy = member.tool_policy ? ` · policy ${member.tool_policy}` : '';
2639
+ return ` - ${member.name} [${member.role}] ${member.status}/${member.activity}${route}${effort}${policy}${failure}`;
2372
2640
  }),
2373
2641
  `Tasks (${team.tasks.length}):`,
2374
2642
  ...team.tasks.map((task) => {
@@ -2380,7 +2648,8 @@ function renderStatus(value) {
2380
2648
  const kind = task.kind ? ` ${task.kind}` : '';
2381
2649
  const round = task.round === undefined ? '' : ` r${task.round}`;
2382
2650
  const verdict = task.verdict === undefined ? '' : ` verdict ${task.verdict}`;
2383
- return ` - ${task.id} [${task.status}]${kind}${round}${verdict} attempt ${task.attempt}${handoff}${seed} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}${evidence}`;
2651
+ const fixes = task.fixes === undefined ? '' : ` fixes ${task.fixes}`;
2652
+ return ` - ${task.id} [${task.status}]${kind}${round}${verdict}${fixes} attempt ${task.attempt}${handoff}${seed} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}${evidence}`;
2384
2653
  }),
2385
2654
  ...team.coverage === undefined || team.coverage.length === 0 ? [] : [
2386
2655
  'Coverage:',
@@ -2389,6 +2658,10 @@ function renderStatus(value) {
2389
2658
  ...team.delivery === undefined ? [] : [
2390
2659
  `Delivery: ${team.delivery.ok ? 'ok' : `blocked (${team.delivery.blockers.join('; ')})`}`,
2391
2660
  ],
2661
+ ...(team.delivery?.warnings ?? []).length === 0 ? [] : [
2662
+ 'Gate warnings (delivery is not blocked, but these recorded verdicts or generated work orders no longer describe the current bytes):',
2663
+ ...(team.delivery?.warnings ?? []).map((warning) => ` - ${warning}`),
2664
+ ],
2392
2665
  `Captain inbox (${team.captain_inbox.length}):`,
2393
2666
  ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content}`),
2394
2667
  ...(team.member_inbox ?? []).map(message => ` - [${message.from}] ${message.content}`),