@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/scheduler.js CHANGED
@@ -13,7 +13,81 @@
13
13
  */
14
14
  import { join } from 'node:path';
15
15
  import { deliverToMember } from "./members.js";
16
- import { acknowledgeMailbox, beginTaskAttempt, claimMailboxDelivery, findTeamByParticipant, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
+ import { acknowledgeMailbox, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
17
+ /** Per-dependency output cap in the assignment prompt. */
18
+ export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
19
+ /** Combined dependency-output budget in the assignment prompt. */
20
+ export const DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS = 12_000;
21
+ function taskProfileSeedId(task) {
22
+ const seed = task.profileSeedId?.trim();
23
+ return seed === undefined || seed === '' ? undefined : seed;
24
+ }
25
+ function teamProfileProtocol(team) {
26
+ return team.profile?.protocol;
27
+ }
28
+ /**
29
+ * Recursively collect `status=completed` ancestors of `taskId` in topological
30
+ * order (dependencies before dependents). Cycles stop that branch only.
31
+ */
32
+ export function collectCompletedDependencyOutputs(tasks, taskId, warn) {
33
+ const byId = new Map(tasks.map(task => [task.id, task]));
34
+ const visiting = new Set();
35
+ const visited = new Set();
36
+ const ordered = [];
37
+ const walk = (id) => {
38
+ if (visiting.has(id)) {
39
+ warn?.(`agent-teams: dependency cycle involving "${id}" while collecting outputs; stopping this branch`);
40
+ return;
41
+ }
42
+ if (visited.has(id))
43
+ return;
44
+ visiting.add(id);
45
+ const task = byId.get(id);
46
+ if (task !== undefined) {
47
+ for (const dependency of task.dependencies)
48
+ walk(dependency);
49
+ if (id !== taskId)
50
+ ordered.push(task);
51
+ }
52
+ visiting.delete(id);
53
+ visited.add(id);
54
+ };
55
+ walk(taskId);
56
+ return ordered
57
+ .filter(task => task.status === 'completed')
58
+ .map((task) => {
59
+ const profileSeedId = taskProfileSeedId(task);
60
+ return {
61
+ id: task.id,
62
+ subject: task.subject,
63
+ ...profileSeedId === undefined ? {} : { profileSeedId },
64
+ ...task.output === undefined ? {} : { output: task.output },
65
+ };
66
+ });
67
+ }
68
+ /** Format completed-dependency outputs with per-item and total truncation. */
69
+ export function formatDependencyOutputs(items) {
70
+ if (items.length === 0)
71
+ return '(none)';
72
+ const formatted = items.map((item) => {
73
+ const seed = item.profileSeedId === undefined ? '' : ` [${item.profileSeedId}]`;
74
+ const raw = item.output === undefined || item.output === ''
75
+ ? '(no output recorded)'
76
+ : item.output;
77
+ const truncated = raw.length > DEPENDENCY_OUTPUT_MAX_CHARS;
78
+ const body = truncated ? `${raw.slice(0, DEPENDENCY_OUTPUT_MAX_CHARS)} [truncated]` : raw;
79
+ return `- ${item.id}${seed} ${item.subject}:\n ${body}`;
80
+ });
81
+ let selected = formatted;
82
+ while (selected.length > 1 && selected.join('\n').length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
83
+ selected = selected.slice(1);
84
+ }
85
+ const last = selected[0];
86
+ if (selected.length === 1 && last !== undefined && last.length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
87
+ selected = [`${last.slice(0, DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS)} [truncated]`];
88
+ }
89
+ return selected.join('\n');
90
+ }
17
91
  function stateRootOf(workspace, config) {
18
92
  return join(workspace, config.stateDir);
19
93
  }
@@ -43,15 +117,54 @@ function nextReadyTask(tasks, memberName) {
43
117
  return ready.find(task => task.assignee === memberName)
44
118
  ?? ready.find(task => task.assignee === undefined);
45
119
  }
46
- function assignmentPrompt(ticket, stateDir, teamId) {
120
+ export function assignmentPrompt(ticket, stateDir, teamId) {
47
121
  const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
122
+ const seed = ticket.profileSeedId === undefined ? '' : ` [${ticket.profileSeedId}]`;
123
+ const goal = ticket.teamDescription?.trim() || '(not provided)';
124
+ const protocol = ticket.profileProtocol?.trim() || '(none)';
125
+ const executionPrompt = ticket.executionPrompt?.trim();
126
+ const kind = ticket.kind?.trim() || 'work';
127
+ const contract = [
128
+ `Kind: ${kind}${ticket.round === undefined ? '' : ` (round ${ticket.round})`}`,
129
+ ticket.objective === undefined || ticket.objective === '' ? '' : `Objective: ${ticket.objective}`,
130
+ ticket.inScope === undefined || ticket.inScope.length === 0 ? '' : `In scope: ${ticket.inScope.join(', ')}`,
131
+ ticket.outOfScope === undefined || ticket.outOfScope.length === 0 ? '' : `Out of scope: ${ticket.outOfScope.join(', ')}`,
132
+ ticket.acceptance === undefined || ticket.acceptance.length === 0 ? '' : `Acceptance: ${ticket.acceptance.join('; ')}`,
133
+ ticket.verify === undefined || ticket.verify.length === 0 ? '' : `Verify: ${ticket.verify.join('; ')}`,
134
+ ticket.reviewedTaskId === undefined ? '' : `Reviewed task: ${ticket.reviewedTaskId}`,
135
+ ].filter((line) => line !== '').join('\n');
136
+ const structuredCompletion = ['implementation', 'repair', 'verification', 'integration'].includes(kind)
137
+ ? `
138
+ Structured completion payload (keep these arrays in contract order):
139
+ acceptanceResults: ${JSON.stringify((ticket.acceptance ?? []).map((criterion) => ({ criterion, status: 'passed', evidence: '<what proved it>' })))}
140
+ commandsRun: ${JSON.stringify((ticket.verify ?? []).map((command) => ({ command, status: 'passed', exitCode: 0, evidence: '<observed result>' })))}
141
+ ${kind === 'implementation' || kind === 'repair' ? 'changedPaths: list the actual workspace-relative POSIX paths you changed.\n' : ''}`
142
+ : '';
48
143
  return `AgentTeams automatic task assignment from the shared task list.
49
144
 
50
- Task: ${ticket.taskId} ${ticket.subject}${description}
145
+ You are executing as configured member "${ticket.memberName}".
146
+ Do not start a teammate's assigned task.
147
+
148
+ Team goal:
149
+ ${goal}
150
+
151
+ Profile protocol:
152
+ ${protocol}
153
+ ${executionPrompt === undefined || executionPrompt === '' ? '' : `
154
+ Execution guidance:
155
+ ${executionPrompt}
156
+ `}
157
+ Completed dependency results:
158
+ ${formatDependencyOutputs(ticket.dependencyOutputs)}
159
+
160
+ Task: ${ticket.taskId}${seed} — ${ticket.subject}${description}
161
+ ${contract === '' ? '' : `\nContract:\n${contract}\n`}
162
+ ${structuredCompletion}
51
163
  Attempt: ${ticket.attempt}
52
164
  Attempt id: ${ticket.attemptId}
53
165
 
54
- Call agent_teams_claim_task for ${ticket.taskId}; it will return this same attempt_id. Include attempt_id=${ticket.attemptId} in every agent_teams_update_task call. If it is rejected as stale, stop work because the task was reassigned. Work only this task in this turn, report the result to the captain, then become idle so the scheduler can select your next ready task.
166
+ Call agent_teams_claim_task for ${ticket.taskId}; it will return this same attempt_id. Include attempt_id=${ticket.attemptId} in every agent_teams_update_task call. If it is rejected as stale, stop work because the task was reassigned. claimed cannot jump to completed. Mark in_progress first, then completed or failed. Include attempt_id on every update. Then send_message to captain and become idle.
167
+ When finishing: use status=completed only when the task's success criteria are satisfied; use status=failed when blocking findings or validation failures mean downstream work must not proceed; include a concise output in either case. Quality kinds must submit structured fields: review/requirements need verdict=pass to complete (needs_revision/reject must fail with findings); implementation/repair/verification/integration need acceptanceResults and commandsRun, while implementation/repair also need in-scope changedPaths. Use status values "passed" or "failed" inside those arrays. After the work and verification finish, call agent_teams_update_task immediately; do not wait for captain confirmation and do not continue exploring. Do not approve your own implementation. Mail is not a formal next review. Treat the dependency results above as source material. Do not ignore them. Work only this task and only its in-scope paths in this turn.
55
168
 
56
169
  State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
57
170
  }
@@ -92,7 +205,7 @@ export function installTeamScheduler(ctx, config) {
92
205
  async kickTeam(workspace, teamId, suppliedCaptain) {
93
206
  const stateRoot = stateRootOf(workspace, config);
94
207
  const team = await readTeam(stateRoot, teamId);
95
- if (team === undefined)
208
+ if (team === undefined || team.halted === true || team.phase === 'staged')
96
209
  return;
97
210
  const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
98
211
  if (captain === undefined)
@@ -108,7 +221,7 @@ export function installTeamScheduler(ctx, config) {
108
221
  const queueKey = memberQueueKey(stateRoot, teamId, memberName);
109
222
  await serializeMember(queueKey, async () => {
110
223
  let team = await readTeam(stateRoot, teamId);
111
- if (team === undefined)
224
+ if (team === undefined || team.halted === true || team.phase === 'staged')
112
225
  return;
113
226
  const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
114
227
  if (captain === undefined)
@@ -132,7 +245,7 @@ export function installTeamScheduler(ctx, config) {
132
245
  }
133
246
  const ticket = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
134
247
  const fresh = await readTeam(stateRoot, team.id);
135
- if (fresh === undefined)
248
+ if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
136
249
  return undefined;
137
250
  const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
138
251
  if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
@@ -163,6 +276,8 @@ export function installTeamScheduler(ctx, config) {
163
276
  parkedAttempts.delete(currentMember.id);
164
277
  currentMember.status = 'working';
165
278
  await writeTeam(stateRoot, fresh);
279
+ const profileSeedId = taskProfileSeedId(task);
280
+ const protocol = teamProfileProtocol(fresh);
166
281
  return {
167
282
  taskId: task.id,
168
283
  memberName: currentMember.name,
@@ -172,6 +287,21 @@ export function installTeamScheduler(ctx, config) {
172
287
  previousAssignee,
173
288
  subject: task.subject,
174
289
  description: task.description,
290
+ teamDescription: fresh.description,
291
+ ...protocol === undefined ? {} : { profileProtocol: protocol },
292
+ ...profileSeedId === undefined ? {} : { profileSeedId },
293
+ ...fresh.profile?.executionPrompt === undefined && config.executionPrompt === undefined
294
+ ? {}
295
+ : { executionPrompt: fresh.profile?.executionPrompt ?? config.executionPrompt },
296
+ kind: task.kind ?? 'work',
297
+ ...task.round === undefined ? {} : { round: task.round },
298
+ ...task.objective === undefined ? {} : { objective: task.objective },
299
+ ...task.inScope === undefined ? {} : { inScope: task.inScope },
300
+ ...task.outOfScope === undefined ? {} : { outOfScope: task.outOfScope },
301
+ ...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
302
+ ...task.verify === undefined ? {} : { verify: task.verify },
303
+ ...task.reviewedTaskId === undefined ? {} : { reviewedTaskId: task.reviewedTaskId },
304
+ dependencyOutputs: collectCompletedDependencyOutputs(fresh.tasks, task.id, (message) => ctx.logger.warn(message)),
175
305
  };
176
306
  });
177
307
  if (ticket === undefined)
@@ -210,8 +340,37 @@ export function installTeamScheduler(ctx, config) {
210
340
  parkedAttempts.delete(agent.id);
211
341
  return;
212
342
  }
213
- if (located.captainSessionId === agent.id)
343
+ if (located.captainSessionId === agent.id) {
344
+ // Captain takeover is scoped to the captain's current turn. Unlike a
345
+ // durable member, the captain has no scheduler lane that can resume an
346
+ // abandoned attempt later. Returning unfinished captain-owned work to
347
+ // the shared pool on the idle edge prevents it from becoming a
348
+ // permanently parked `claimed` task after the captain answers, is
349
+ // interrupted, or the user switches conversations.
350
+ if (status === 'running')
351
+ return;
352
+ let requeued = false;
353
+ await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
354
+ const fresh = await readTeam(stateRoot, located.id);
355
+ if (fresh === undefined || fresh.captainSessionId !== agent.id)
356
+ return;
357
+ for (const task of fresh.tasks) {
358
+ if (task.assignee !== CAPTAIN_KEY
359
+ || task.status === 'completed'
360
+ || task.status === 'failed'
361
+ || task.status === 'cancelled')
362
+ continue;
363
+ invalidateTaskAttempt(task);
364
+ task.reassigning = false;
365
+ requeued = true;
366
+ }
367
+ if (requeued)
368
+ await writeTeam(stateRoot, fresh);
369
+ });
370
+ if (requeued)
371
+ await runtime.kickTeam(workspace, located.id, agent);
214
372
  return;
373
+ }
215
374
  const member = located.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
216
375
  if (member === undefined) {
217
376
  parkedAttempts.delete(agent.id);
package/lib/snapshot.js CHANGED
@@ -19,6 +19,16 @@ function currentTaskOf(memberName, tasks) {
19
19
  }
20
20
  return '';
21
21
  }
22
+ /** Compact `provider/model` route for the activity panel, or just the model. */
23
+ export function memberModelRoute(member) {
24
+ if (member === undefined)
25
+ return '';
26
+ const provider = member.provider?.trim() ?? '';
27
+ const model = member.model?.trim() ?? '';
28
+ if (provider !== '' && model !== '')
29
+ return `${provider}/${model}`;
30
+ return model;
31
+ }
22
32
  /**
23
33
  * Assemble one team snapshot from its durable files plus live activity.
24
34
  * @param ctx - the plugin context (injects `subagents`, used for activity).
@@ -53,6 +63,10 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
53
63
  id: member.id,
54
64
  name: member.name,
55
65
  role: member.role ?? '',
66
+ provider: member.provider?.trim() ?? '',
67
+ model: member.model?.trim() ?? '',
68
+ reasoningEffort: member.reasoningEffort?.trim() ?? '',
69
+ executionPrompt: member.executionPrompt ?? '',
56
70
  status: member.status,
57
71
  activity: options.historic === true
58
72
  ? 'idle'
@@ -62,7 +76,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
62
76
  : activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
63
77
  ? 'idle'
64
78
  : 'unknown')
65
- : 'unknown',
79
+ : state.phase === 'staged' ? 'idle' : 'unknown',
66
80
  progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
67
81
  done,
68
82
  total: owned.length,
@@ -77,15 +91,25 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
77
91
  name: state.name,
78
92
  ...state.description !== undefined ? { description: state.description } : {},
79
93
  captainSessionId: state.captainSessionId,
94
+ phase: state.phase ?? 'running',
95
+ ...state.phase === 'staged'
96
+ ? { planReviewState: state.planReviewState ?? 'awaiting_review' }
97
+ : {},
98
+ ...state.halted === true ? { halted: true } : {},
80
99
  members,
81
100
  tasks: tasks.map((task) => ({
82
101
  id: task.id,
83
102
  subject: task.subject,
103
+ description: task.description ?? '',
84
104
  status: task.status,
85
105
  state: taskVisualState(task.status, task.dependencies, tasks),
86
106
  assignee: task.assignee ?? '',
107
+ model: memberModelRoute(roster.find((member) => member.name === task.assignee)),
87
108
  dependencies: task.dependencies,
88
109
  depth: depths.get(task.id) ?? 0,
110
+ ...task.kind === undefined ? {} : { kind: task.kind },
111
+ ...task.round === undefined ? {} : { round: task.round },
112
+ ...task.verdict === undefined ? {} : { verdict: task.verdict },
89
113
  })),
90
114
  messageCount: captainInbox.length
91
115
  + members.reduce((count, member) => count + member.unread, 0),
package/lib/state.js CHANGED
@@ -16,6 +16,9 @@ import { createHash, randomUUID } from 'node:crypto';
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
18
18
  import { join } from 'node:path';
19
+ import { TERMINAL_TASK_STATUSES } from "./types.js";
20
+ import { hasValidQualityTaskFields, isReviewPolicy } from "./quality-gates.js";
21
+ export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
19
22
  /** Mailbox key of the captain. */
20
23
  export const CAPTAIN_KEY = 'captain';
21
24
  /** A crashed live-delivery attempt becomes retryable after this interval. */
@@ -137,6 +140,18 @@ export function beginTaskAttempt(task, assignee) {
137
140
  * Revoke the current worker immediately. Clearing its capability makes old
138
141
  * updates stale; a separate handoff generation serializes async quiescence.
139
142
  */
143
+ /** Cancel one unfinished task without returning it to the ready pool. */
144
+ export function cancelUnfinishedTask(task, output) {
145
+ if (TERMINAL_TASK_STATUSES.includes(task.status))
146
+ return;
147
+ task.status = 'cancelled';
148
+ task.attemptId = undefined;
149
+ task.handoffId = undefined;
150
+ task.reassigning = false;
151
+ if (output !== undefined)
152
+ task.output = output;
153
+ task.updatedAt = Date.now();
154
+ }
140
155
  export function invalidateTaskAttempt(task, nextAssignee, reassigning = false) {
141
156
  task.attemptId = undefined;
142
157
  task.handoffId = randomUUID();
@@ -165,10 +180,11 @@ export async function readTeam(stateRoot, teamId) {
165
180
  try {
166
181
  const raw = await readFile(join(stateRoot, teamId, 'team.json'), 'utf8');
167
182
  const value = JSON.parse(stripLeadingBom(raw));
168
- if (!isTeamState(value, teamId)) {
183
+ const team = coerceTeamState(value, teamId);
184
+ if (team === undefined) {
169
185
  throw new Error(`invalid AgentTeams state in team "${teamId}"`);
170
186
  }
171
- return value;
187
+ return team;
172
188
  }
173
189
  catch (error) {
174
190
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
@@ -190,10 +206,11 @@ export function readTeamSync(stateRoot, teamId) {
190
206
  try {
191
207
  const raw = readFileSync(join(stateRoot, teamId, 'team.json'), 'utf8');
192
208
  const value = JSON.parse(stripLeadingBom(raw));
193
- if (!isTeamState(value, teamId)) {
209
+ const team = coerceTeamState(value, teamId);
210
+ if (team === undefined) {
194
211
  throw new Error(`invalid AgentTeams state in team "${teamId}"`);
195
212
  }
196
- return value;
213
+ return team;
197
214
  }
198
215
  catch (error) {
199
216
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
@@ -549,14 +566,82 @@ function isTeamMember(value) {
549
566
  && isOptionalString(value['provider'])
550
567
  && isOptionalString(value['model'])
551
568
  && isOptionalString(value['reasoningEffort'])
569
+ && isOptionalString(value['activeProvider'])
570
+ && isOptionalString(value['activeModel'])
571
+ && (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
572
+ && (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
573
+ && (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
552
574
  && isFiniteNumber(value['joinedAt'])
553
575
  && (value['status'] === 'idle' || value['status'] === 'working' || value['status'] === 'removed');
554
576
  }
555
577
  /** Validate one task record at the durable JSON boundary. */
556
- function isTeamTask(value) {
578
+ function isTeamProfileSnapshot(value) {
579
+ return isRecord(value)
580
+ && typeof value['name'] === 'string'
581
+ && value['name'].trim() !== ''
582
+ && isOptionalString(value['description'])
583
+ && isOptionalString(value['protocol'])
584
+ && (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
585
+ && (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
586
+ && (value['taskPlanning'] === undefined || value['taskPlanning'] === 'captain' || value['taskPlanning'] === 'seed')
587
+ && (value['reviewPolicy'] === undefined || isReviewPolicy(value['reviewPolicy']));
588
+ }
589
+ function coerceProfileSnapshot(value) {
590
+ if (typeof value === 'string') {
591
+ const name = value.trim();
592
+ return name === '' ? undefined : { name };
593
+ }
594
+ if (!isRecord(value))
595
+ return undefined;
596
+ if (!isTeamProfileSnapshot(value))
597
+ return undefined;
598
+ return {
599
+ name: value.name.trim(),
600
+ ...value.description === undefined ? {} : { description: value.description },
601
+ ...value.protocol === undefined ? {} : { protocol: value.protocol },
602
+ ...value.taskPlanning === undefined ? {} : { taskPlanning: value.taskPlanning },
603
+ };
604
+ }
605
+ function coerceTeamState(value, expectedId) {
606
+ if (!isRecord(value))
607
+ return undefined;
608
+ if (value['profile'] !== undefined && !isTeamProfileSnapshot(value['profile']) && typeof value['profile'] !== 'string') {
609
+ const next = { ...value };
610
+ delete next['profile'];
611
+ value = next;
612
+ }
613
+ else if (typeof value['profile'] === 'string') {
614
+ const upgraded = coerceProfileSnapshot(value['profile']);
615
+ value = upgraded === undefined
616
+ ? (() => {
617
+ const next = { ...value };
618
+ delete next['profile'];
619
+ return next;
620
+ })()
621
+ : { ...value, profile: upgraded };
622
+ }
623
+ if (!isRecord(value) || !Array.isArray(value['tasks'])) {
624
+ return isTeamState(value, expectedId) ? value : undefined;
625
+ }
626
+ const tasks = value['tasks'].map((task) => {
627
+ if (!isRecord(task))
628
+ return task;
629
+ if (task['profileSeedId'] !== undefined && (typeof task['profileSeedId'] !== 'string' || task['profileSeedId'].trim() === '')) {
630
+ const next = { ...task };
631
+ delete next['profileSeedId'];
632
+ return next;
633
+ }
634
+ return task;
635
+ });
636
+ const coerced = { ...value, tasks };
637
+ return isTeamState(coerced, expectedId) ? coerced : undefined;
638
+ }
639
+ export function isTeamTask(value) {
557
640
  if (!isRecord(value))
558
641
  return false;
559
642
  return typeof value['id'] === 'string'
643
+ && isOptionalString(value['profileSeedId'])
644
+ && (value['profileSeedId'] === undefined || value['profileSeedId'].trim() !== '')
560
645
  && typeof value['subject'] === 'string'
561
646
  && isOptionalString(value['description'])
562
647
  && (value['status'] === 'pending'
@@ -575,7 +660,8 @@ function isTeamTask(value) {
575
660
  && isOptionalString(value['handoffId'])
576
661
  && (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
577
662
  && isFiniteNumber(value['createdAt'])
578
- && isFiniteNumber(value['updatedAt']);
663
+ && isFiniteNumber(value['updatedAt'])
664
+ && hasValidQualityTaskFields(value);
579
665
  }
580
666
  /** Validate the full team record before it can participate in authorization. */
581
667
  function isTeamState(value, expectedId) {
@@ -585,6 +671,7 @@ function isTeamState(value, expectedId) {
585
671
  && typeof value['name'] === 'string'
586
672
  && value['name'].trim() !== ''
587
673
  && isOptionalString(value['description'])
674
+ && (value['profile'] === undefined || isTeamProfileSnapshot(value['profile']))
588
675
  && typeof value['captainSessionId'] === 'string'
589
676
  && value['captainSessionId'] !== ''
590
677
  && isFiniteNumber(value['createdAt'])
@@ -593,18 +680,32 @@ function isTeamState(value, expectedId) {
593
680
  && Array.isArray(value['tasks'])
594
681
  && value['tasks'].every(isTeamTask)
595
682
  && Number.isSafeInteger(value['taskSeq'])
596
- && value['taskSeq'] >= 0;
683
+ && value['taskSeq'] >= 0
684
+ && (value['phase'] === undefined || value['phase'] === 'staged' || value['phase'] === 'running')
685
+ && (value['planReviewState'] === undefined
686
+ || value['planReviewState'] === 'awaiting_review'
687
+ || value['planReviewState'] === 'awaiting_feedback')
688
+ && (value['approvedAt'] === undefined || isFiniteNumber(value['approvedAt']))
689
+ && (value['halted'] === undefined || typeof value['halted'] === 'boolean')
690
+ && (value['haltedAt'] === undefined || isFiniteNumber(value['haltedAt']))
691
+ && (value['reviewPolicy'] === undefined || isReviewPolicy(value['reviewPolicy']))
692
+ && (value['escalated'] === undefined || typeof value['escalated'] === 'boolean');
597
693
  if (!validShape)
598
694
  return false;
599
695
  const members = value['members'];
600
696
  const tasks = value['tasks'];
601
697
  const memberIds = new Set();
602
698
  const memberKeys = new Set();
699
+ const staged = value['phase'] === 'staged';
603
700
  for (const member of members) {
604
701
  const key = sanitizeKey(member.name);
605
- if (member.id === '' || key === CAPTAIN_KEY || memberIds.has(member.id) || memberKeys.has(key))
702
+ if ((!staged && member.id === '') || key === CAPTAIN_KEY || memberKeys.has(key))
606
703
  return false;
607
- memberIds.add(member.id);
704
+ if (member.id !== '') {
705
+ if (memberIds.has(member.id))
706
+ return false;
707
+ memberIds.add(member.id);
708
+ }
608
709
  memberKeys.add(key);
609
710
  }
610
711
  const taskIds = new Set();
@@ -739,11 +840,16 @@ export async function listArchivedTeamIds(stateRoot) {
739
840
  }
740
841
  /**
741
842
  * The visual state of one task: `running` while in_progress, `completed`
742
- * when done, `blocked` while any dependency is unfinished, else `open`.
843
+ * when done, `failed`/`cancelled` when terminal without success, `blocked`
844
+ * while any dependency is unfinished, else `open`.
743
845
  */
744
846
  export function taskVisualState(status, dependencies, tasks) {
745
847
  if (status === 'completed')
746
848
  return 'completed';
849
+ if (status === 'failed')
850
+ return 'failed';
851
+ if (status === 'cancelled')
852
+ return 'cancelled';
747
853
  if (status === 'in_progress')
748
854
  return 'running';
749
855
  const byId = new Map(tasks.map((task) => [task.id, task]));