@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.23

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 (42) hide show
  1. package/README.md +32 -10
  2. package/README_ZH.md +32 -10
  3. package/lib/artifacts.js +342 -0
  4. package/lib/capabilities.js +6 -4
  5. package/lib/client/ActivityPanel.js +5 -2
  6. package/lib/client/agent-teams-card-definition.js +100 -6
  7. package/lib/client.js +90 -7
  8. package/lib/client.js.map +1 -1
  9. package/lib/command.js +3 -2
  10. package/lib/git-artifact-stamp.json +2 -2
  11. package/lib/harness-compat.js +70 -21
  12. package/lib/index.js +26 -3
  13. package/lib/lineage.js +198 -0
  14. package/lib/mailbox.js +59 -34
  15. package/lib/members.js +301 -53
  16. package/lib/profiles.js +3 -2
  17. package/lib/quality-gates.js +850 -65
  18. package/lib/scheduler.js +22 -3
  19. package/lib/snapshot.js +22 -1
  20. package/lib/state.js +279 -13
  21. package/lib/tool-policies.js +187 -0
  22. package/lib/tools.js +370 -89
  23. package/lib/types/artifacts.d.ts +66 -0
  24. package/lib/types/client/activity-monitor.d.ts +6 -0
  25. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  26. package/lib/types/harness-compat.d.ts +56 -14
  27. package/lib/types/index.d.ts +26 -1
  28. package/lib/types/lineage.d.ts +92 -0
  29. package/lib/types/members.d.ts +112 -1
  30. package/lib/types/profiles.d.ts +6 -0
  31. package/lib/types/quality-gates.d.ts +216 -10
  32. package/lib/types/scheduler.d.ts +2 -1
  33. package/lib/types/snapshot.d.ts +20 -0
  34. package/lib/types/state.d.ts +22 -5
  35. package/lib/types/tool-policies.d.ts +119 -0
  36. package/lib/types/tools.d.ts +12 -1
  37. package/lib/types/types.d.ts +95 -0
  38. package/lib/types/workspace-audit.d.ts +27 -0
  39. package/lib/workspace-audit.js +52 -0
  40. package/package.json +12 -5
  41. package/release-notes/v0.1.23.md +34 -0
  42. package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/scheduler.js CHANGED
@@ -15,6 +15,8 @@ import { join } from 'node:path';
15
15
  import { deliverToMember } from "./members.js";
16
16
  import { isCurrentMail, mailboxPrompt } from "./mailbox.js";
17
17
  import { markMailboxDelivered, discardMailboxMessages, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readPendingMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
18
+ import { contractStaleness, isVerdictKind, taskKindOf, verdictScopePatterns } from "./quality-gates.js";
19
+ import { digestWorkspacePaths, snapshotWorkspacePaths } from "./artifacts.js";
18
20
  /** Per-dependency output cap in the assignment prompt. */
19
21
  export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
20
22
  /** Combined dependency-output budget in the assignment prompt. */
@@ -120,13 +122,16 @@ function nextReadyTask(tasks, memberName) {
120
122
  return ready.find(task => task.assignee === memberName)
121
123
  ?? ready.find(task => task.assignee === undefined);
122
124
  }
123
- export function assignmentPrompt(ticket, stateDir, teamId) {
125
+ export function assignmentPrompt(ticket, stateDir, teamId, workOrderWarnings = []) {
124
126
  const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
125
127
  const seed = ticket.profileSeedId === undefined ? '' : ` [${ticket.profileSeedId}]`;
126
128
  const goal = ticket.teamDescription?.trim() || '(not provided)';
127
129
  const protocol = ticket.profileProtocol?.trim() || '(none)';
128
130
  const executionPrompt = ticket.executionPrompt?.trim();
129
131
  const kind = ticket.kind?.trim() || 'work';
132
+ const staleWorkOrder = workOrderWarnings.length === 0
133
+ ? ''
134
+ : `\nBefore you edit anything: this auto-generated work order was derived from a workspace that has since changed.\n${workOrderWarnings.map((warning) => `- ${warning}`).join('\n')}\nRe-read the cited files first, locate the code by content anchor rather than by any position, and report a blocker instead of editing a target that no longer exists.\n`;
130
135
  const contract = [
131
136
  `Kind: ${kind}${ticket.round === undefined ? '' : ` (round ${ticket.round})`}`,
132
137
  ticket.objective === undefined || ticket.objective === '' ? '' : `Objective: ${ticket.objective}`,
@@ -135,6 +140,7 @@ export function assignmentPrompt(ticket, stateDir, teamId) {
135
140
  ticket.acceptance === undefined || ticket.acceptance.length === 0 ? '' : `Acceptance: ${ticket.acceptance.join('; ')}`,
136
141
  ticket.verify === undefined || ticket.verify.length === 0 ? '' : `Verify: ${ticket.verify.join('; ')}`,
137
142
  ticket.reviewedTaskId === undefined ? '' : `Reviewed task: ${ticket.reviewedTaskId}`,
143
+ ticket.findingSourceTaskIds === undefined || ticket.findingSourceTaskIds.length === 0 ? '' : `Fixes findings of: ${ticket.findingSourceTaskIds.join(', ')}`,
138
144
  ].filter((line) => line !== '').join('\n');
139
145
  const structuredCompletion = ['implementation', 'repair', 'verification', 'integration'].includes(kind)
140
146
  ? `
@@ -161,7 +167,7 @@ Completed dependency results:
161
167
  ${formatDependencyOutputs(ticket.dependencyOutputs)}
162
168
 
163
169
  Task: ${ticket.taskId}${seed} — ${ticket.subject}${description}
164
- ${contract === '' ? '' : `\nContract:\n${contract}\n`}
170
+ ${staleWorkOrder}${contract === '' ? '' : `\nContract:\n${contract}\n`}
165
171
  ${structuredCompletion}
166
172
  Attempt: ${ticket.attempt}
167
173
  Attempt id: ${ticket.attemptId}
@@ -286,6 +292,14 @@ export function installTeamScheduler(ctx, config) {
286
292
  changedPaths: task.changedPaths, acceptanceResults: task.acceptanceResults, commandsRun: task.commandsRun,
287
293
  } : undefined;
288
294
  const attemptId = beginTaskAttempt(task, currentMember.name);
295
+ // A verdict must be able to say which revision was on disk when it
296
+ // started, so a review that runs while someone else edits the tree
297
+ // cannot later claim to have judged stable bytes (issue #161).
298
+ if (isVerdictKind(taskKindOf(task))) {
299
+ const scope = verdictScopePatterns(fresh, task);
300
+ if (scope.length > 0)
301
+ task.verdictStartSnapshot = snapshotWorkspacePaths(workspace, scope);
302
+ }
289
303
  // A recovered generation is parked before delivery. This makes each
290
304
  // (member, attempt) recovery idempotent even if every status poll
291
305
  // sees a disposed handle. Fresh pending work remains unparked so a
@@ -326,12 +340,17 @@ export function installTeamScheduler(ctx, config) {
326
340
  ...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
327
341
  ...task.verify === undefined ? {} : { verify: task.verify },
328
342
  ...task.reviewedTaskId === undefined ? {} : { reviewedTaskId: task.reviewedTaskId },
343
+ ...task.findingSourceTaskIds === undefined ? {} : { findingSourceTaskIds: task.findingSourceTaskIds },
329
344
  dependencyOutputs: collectCompletedDependencyOutputs(fresh.tasks, task.id, (message) => ctx.logger.warn(message)),
330
345
  };
331
346
  });
332
347
  if (ticket === undefined)
333
348
  return;
334
- const prompt = assignmentPrompt(ticket, config.stateDir, team.id);
349
+ const taskForWarning = team.tasks.find((item) => item.id === ticket.taskId);
350
+ const workOrderStale = taskForWarning?.contractSnapshot === undefined
351
+ ? []
352
+ : contractStaleness(taskForWarning, digestWorkspacePaths(workspace, taskForWarning.contractSnapshot.artifacts.map((artifact) => artifact.path)));
353
+ const prompt = assignmentPrompt(ticket, config.stateDir, team.id, workOrderStale);
335
354
  const signal = new AbortController().signal;
336
355
  const accepted = config.dispatch === undefined
337
356
  ? await deliverToMember(ctx, captain, ticket.memberId, prompt, signal)
package/lib/snapshot.js CHANGED
@@ -10,6 +10,8 @@
10
10
  import { readdir } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { memberActivity } from "./members.js";
13
+ import { canDeclareDelivery, describeQualityLoop, verdictAuditNotes } from "./quality-gates.js";
14
+ import { auditTeamWorkspace } from "./workspace-audit.js";
13
15
  import { CAPTAIN_KEY, listArchivedTeamIds, readArchivedTeam, readUnreadMailbox, readTeam, taskDepthsById, taskVisualState, } from "./state.js";
14
16
  /** The current task of a member: its first unfinished owned task. */
15
17
  function currentTaskOf(memberName, tasks) {
@@ -85,6 +87,17 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
85
87
  };
86
88
  });
87
89
  const captainInbox = await readUnreadMailbox(stateRoot, state.id, CAPTAIN_KEY);
90
+ // The panel must reach the same delivery conclusion as the status tool, so
91
+ // it reads the same current bytes instead of trusting task records alone.
92
+ const audit = options.workspacePath === undefined ? undefined : auditTeamWorkspace(options.workspacePath, state);
93
+ const deliveryCheck = audit === undefined ? canDeclareDelivery(state) : canDeclareDelivery(state, audit);
94
+ const loop = audit === undefined ? describeQualityLoop(state) : describeQualityLoop(state, audit);
95
+ const staleById = new Map();
96
+ if (audit !== undefined) {
97
+ for (const note of verdictAuditNotes(state, audit)) {
98
+ staleById.set(note.taskId, [...staleById.get(note.taskId) ?? [], note.message]);
99
+ }
100
+ }
88
101
  return {
89
102
  workspace,
90
103
  teamId: state.id,
@@ -96,6 +109,11 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
96
109
  ? { planReviewState: state.planReviewState ?? 'awaiting_review' }
97
110
  : {},
98
111
  ...state.halted === true ? { halted: true } : {},
112
+ ...audit === undefined ? {} : {
113
+ loopState: loop.state,
114
+ deliverable: loop.deliverable,
115
+ delivery: { ok: deliveryCheck.ok, blockers: [...deliveryCheck.blockers], warnings: [...deliveryCheck.warnings] },
116
+ },
99
117
  members,
100
118
  tasks: tasks.map((task) => ({
101
119
  id: task.id,
@@ -110,6 +128,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
110
128
  ...task.kind === undefined ? {} : { kind: task.kind },
111
129
  ...task.round === undefined ? {} : { round: task.round },
112
130
  ...task.verdict === undefined ? {} : { verdict: task.verdict },
131
+ ...staleById.get(task.id) === undefined ? {} : { verdictStale: staleById.get(task.id).join('; ') },
113
132
  updatedAt: task.updatedAt,
114
133
  })),
115
134
  messageCount: captainInbox.length
@@ -146,7 +165,9 @@ export async function collectTeamsActivity(ctx, roots) {
146
165
  const state = await readTeam(root.stateRoot, entry.name);
147
166
  if (state === undefined)
148
167
  continue;
149
- snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state));
168
+ snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state, {
169
+ ...root.workspacePath === undefined ? {} : { workspacePath: root.workspacePath },
170
+ }));
150
171
  }
151
172
  catch {
152
173
  ctx.logger.warn(`agent-teams: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}"`);
package/lib/state.js CHANGED
@@ -16,9 +16,10 @@ 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, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
21
- export { amendTaskContract, buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, isTaskRevision, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
19
+ import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, TERMINAL_TASK_STATUSES } from "./types.js";
20
+ import { hasValidQualityTaskFields, isReviewPolicy, isValidFindingLine, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
21
+ import { teamLeadMatch } from "./lineage.js";
22
+ export { amendTaskContract, buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, isTaskRevision, isValidFindingLine, normalizeBlankOptionalTaskFields, normalizeSentinelNumericTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
22
23
  /** Mailbox key of the captain. */
23
24
  export const CAPTAIN_KEY = 'captain';
24
25
  /** A crashed live-delivery attempt becomes retryable after this interval. */
@@ -205,7 +206,7 @@ export async function readTeam(stateRoot, teamId) {
205
206
  const value = JSON.parse(stripLeadingBom(raw));
206
207
  const team = coerceTeamState(value, teamId);
207
208
  if (team === undefined) {
208
- throw new Error(`invalid AgentTeams state in team "${teamId}"`);
209
+ throw new Error(`invalid AgentTeams state in team "${teamId}": ${describeTeamStateDefect(value, teamId)}`);
209
210
  }
210
211
  return team;
211
212
  }
@@ -231,7 +232,7 @@ export function readTeamSync(stateRoot, teamId) {
231
232
  const value = JSON.parse(stripLeadingBom(raw));
232
233
  const team = coerceTeamState(value, teamId);
233
234
  if (team === undefined) {
234
- throw new Error(`invalid AgentTeams state in team "${teamId}"`);
235
+ throw new Error(`invalid AgentTeams state in team "${teamId}": ${describeTeamStateDefect(value, teamId)}`);
235
236
  }
236
237
  return team;
237
238
  }
@@ -297,9 +298,11 @@ export async function recordRetiredMemberIds(stateRoot, memberIds) {
297
298
  * Find the team owned by one captain session (at most one per captain).
298
299
  * @param stateRoot - resolved absolute state root directory.
299
300
  * @param captainSessionId - the owning session id.
301
+ * @param lead - durable lead identity of the calling session, when it may lead
302
+ * through a fork chain; omitted keeps the exact-id contract.
300
303
  * @returns the team record, or undefined when the captain leads no team.
301
304
  */
302
- export async function findTeamByCaptain(stateRoot, captainSessionId) {
305
+ export async function findTeamByCaptain(stateRoot, captainSessionId, lead) {
303
306
  let entries;
304
307
  try {
305
308
  entries = await readdir(stateRoot, { withFileTypes: true });
@@ -315,7 +318,8 @@ export async function findTeamByCaptain(stateRoot, captainSessionId) {
315
318
  if (!entry.isDirectory())
316
319
  continue;
317
320
  const team = await readTeam(stateRoot, entry.name);
318
- if (team?.captainSessionId === captainSessionId) {
321
+ if (team !== undefined && (team.captainSessionId === captainSessionId
322
+ || (lead !== undefined && teamLeadMatch(team, lead)))) {
319
323
  if (found !== undefined && found.id !== team.id) {
320
324
  throw new Error(`captain session leads multiple active teams ("${found.id}", "${team.id}"); archive one before continuing`);
321
325
  }
@@ -326,13 +330,15 @@ export async function findTeamByCaptain(stateRoot, captainSessionId) {
326
330
  }
327
331
  /**
328
332
  * Find the team in which one session is an active participant.
329
- * Captains match `captainSessionId`; members match their durable child session
330
- * id. Removed members no longer have access to team-scoped tools.
333
+ * Captains match `captainSessionId` — including a lead session that inherited
334
+ * it through a fork — and members match their durable child session id.
335
+ * Removed members no longer have access to team-scoped tools.
331
336
  * @param stateRoot - resolved absolute state root directory.
332
337
  * @param agentSessionId - calling captain/member session id.
338
+ * @param lead - durable lead identity of the calling session, when available.
333
339
  * @returns the team record, or undefined when the caller belongs to no team.
334
340
  */
335
- export async function findTeamByParticipant(stateRoot, agentSessionId) {
341
+ export async function findTeamByParticipant(stateRoot, agentSessionId, lead) {
336
342
  let entries;
337
343
  try {
338
344
  entries = await readdir(stateRoot, { withFileTypes: true });
@@ -348,8 +354,9 @@ export async function findTeamByParticipant(stateRoot, agentSessionId) {
348
354
  if (!entry.isDirectory())
349
355
  continue;
350
356
  const team = await readTeam(stateRoot, entry.name);
351
- const participates = team?.captainSessionId === agentSessionId
352
- || team?.members.some((member) => member.id === agentSessionId && member.status !== 'removed') === true;
357
+ const participates = team !== undefined && (team.captainSessionId === agentSessionId
358
+ || (lead !== undefined && teamLeadMatch(team, lead))
359
+ || team.members.some((member) => member.id === agentSessionId && member.status !== 'removed'));
353
360
  if (participates && team !== undefined) {
354
361
  if (found !== undefined && found.id !== team.id) {
355
362
  throw new Error(`agent session belongs to multiple active teams ("${found.id}", "${team.id}"); the target team is ambiguous`);
@@ -603,6 +610,36 @@ function isRecord(value) {
603
610
  function isOptionalString(value) {
604
611
  return value === undefined || typeof value === 'string';
605
612
  }
613
+ /** Whether a value is a non-empty (non-blank) string. */
614
+ function nonemptyString(value) {
615
+ return typeof value === 'string' && value.trim() !== '';
616
+ }
617
+ /** Whether a value is an absent or non-blank string (a persisted id/name). */
618
+ function isOptionalNonBlankString(value) {
619
+ return value === undefined || (typeof value === 'string' && value.trim() !== '');
620
+ }
621
+ /** Whether a value is a non-empty list of non-blank tool names. */
622
+ function isToolNameList(value) {
623
+ return Array.isArray(value)
624
+ && value.length > 0
625
+ && value.every(name => typeof name === 'string' && name.trim() !== '');
626
+ }
627
+ /**
628
+ * Validate one persisted effective tool filter (#59). `deny` is part of the
629
+ * contract and never empty (the captain-only deny list is always included); an
630
+ * `allow` list, when present, is non-empty for the same reason. A wrong-typed
631
+ * or empty list is rejected instead of being silently accepted at the durable
632
+ * boundary — the same class of drift #188 fixed for team records.
633
+ */
634
+ function isMemberToolFilter(value) {
635
+ if (!isRecord(value))
636
+ return false;
637
+ const allow = value['allow'];
638
+ const deny = value['deny'];
639
+ if (allow !== undefined && !isToolNameList(allow))
640
+ return false;
641
+ return isToolNameList(deny);
642
+ }
606
643
  /** Whether a value is a finite timestamp/counter number. */
607
644
  function isFiniteNumber(value) {
608
645
  return typeof value === 'number' && Number.isFinite(value);
@@ -625,6 +662,9 @@ function isTeamMember(value) {
625
662
  && (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
626
663
  && (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
627
664
  && (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
665
+ // A member without a policy keeps the legacy shape: both fields stay absent.
666
+ && isOptionalNonBlankString(value['toolPolicy'])
667
+ && (value['toolFilter'] === undefined || isMemberToolFilter(value['toolFilter']))
628
668
  && isFiniteNumber(value['joinedAt'])
629
669
  && (value['status'] === 'idle' || value['status'] === 'working' || value['status'] === 'removed');
630
670
  }
@@ -683,7 +723,9 @@ function coerceTeamState(value, expectedId) {
683
723
  // Tolerate legacy dirty records instead of bricking the whole team on
684
724
  // reload: blank optional fields written by older builds (or by models that
685
725
  // materialize optionals as "") are normalized to omitted, matching the
686
- // profileSeedId handling below and the tool-input normalization.
726
+ // profileSeedId handling below and the tool-input normalization. The same
727
+ // pass drops out-of-contract numeric sentinels such as `line: -1`
728
+ // (issue #188), so an already-written bad record self-heals on read.
687
729
  const cleaned = normalizeBlankOptionalTaskFields(task);
688
730
  if (cleaned['profileSeedId'] !== undefined && (typeof cleaned['profileSeedId'] !== 'string' || cleaned['profileSeedId'].trim() === '')) {
689
731
  const next = { ...cleaned };
@@ -695,6 +737,230 @@ function coerceTeamState(value, expectedId) {
695
737
  const coerced = { ...value, tasks };
696
738
  return isTeamState(coerced, expectedId) ? coerced : undefined;
697
739
  }
740
+ /**
741
+ * Name the first durable defect in a record `coerceTeamState` rejected. Before
742
+ * this, a rejected `team.json` surfaced only as
743
+ * `invalid AgentTeams state in team "x"` — no task, no field, no value — while
744
+ * every AgentTeams tool (read *and* write) failed at once, leaving an operator
745
+ * to bisect the file by hand (issue #188). The detail is computed only on the
746
+ * failure path, so the happy path keeps its existing cost.
747
+ * @param raw - the parsed record as read from disk, before coercion.
748
+ * @param expectedId - the team id the record was read for.
749
+ * @returns a `task "t2": findings[0].line -1 must be ...` style detail.
750
+ */
751
+ export function describeTeamStateDefect(raw, expectedId) {
752
+ if (!isRecord(raw))
753
+ return 'record must be a JSON object';
754
+ if (!Array.isArray(raw['tasks'])) {
755
+ return raw['tasks'] === undefined ? 'tasks is missing' : `tasks must be an array (got ${typeof raw['tasks']})`;
756
+ }
757
+ const tasks = raw['tasks'].map((task) => isRecord(task) ? normalizeBlankOptionalTaskFields(task) : task);
758
+ for (const [index, task] of tasks.entries()) {
759
+ if (!isRecord(task))
760
+ return `tasks[${index}] must be an object`;
761
+ const label = typeof task['id'] === 'string' && task['id'] !== '' ? `task "${task['id']}"` : `tasks[${index}]`;
762
+ const detail = describeTaskDefect(task);
763
+ if (detail === undefined)
764
+ continue;
765
+ return `${label}: ${detail}`;
766
+ }
767
+ const ids = tasks.filter(isRecord).map((task) => task['id']);
768
+ if (ids.some((id) => typeof id !== 'string' || id === ''))
769
+ return 'a task id must be a non-empty string';
770
+ const seen = new Set();
771
+ for (const id of ids) {
772
+ if (seen.has(id))
773
+ return `task "${id}": duplicated task id`;
774
+ seen.add(id);
775
+ }
776
+ if (raw['id'] !== expectedId)
777
+ return `id ${JSON.stringify(raw['id'])} must equal "${expectedId}"`;
778
+ if (typeof raw['name'] !== 'string' || raw['name'].trim() === '')
779
+ return 'name must be a non-empty string';
780
+ if (!isOptionalString(raw['description']))
781
+ return 'description must be a string';
782
+ if (raw['profile'] !== undefined && !isTeamProfileSnapshot(raw['profile']))
783
+ return 'profile is not a valid profile snapshot';
784
+ if (typeof raw['captainSessionId'] !== 'string' || raw['captainSessionId'] === '')
785
+ return 'captainSessionId must be a non-empty string';
786
+ if (!isFiniteNumber(raw['createdAt']))
787
+ return 'createdAt must be a finite number';
788
+ if (!Array.isArray(raw['members']))
789
+ return 'members must be an array';
790
+ const memberKeys = new Set();
791
+ const memberIds = new Set();
792
+ for (const [index, member] of raw['members'].entries()) {
793
+ if (!isTeamMember(member))
794
+ return `members[${index}] is not a valid member record`;
795
+ const key = sanitizeKey(member.name);
796
+ if (key === CAPTAIN_KEY)
797
+ return `members[${index}]: name "${member.name}" is reserved for the captain`;
798
+ if (memberKeys.has(key))
799
+ return `members[${index}]: name "${member.name}" is already used by another member`;
800
+ memberKeys.add(key);
801
+ if (member.id === '')
802
+ continue;
803
+ if (memberIds.has(member.id))
804
+ return `members[${index}]: id "${member.id}" is duplicated`;
805
+ memberIds.add(member.id);
806
+ }
807
+ if (!Number.isSafeInteger(raw['taskSeq']) || raw['taskSeq'] < 0)
808
+ return 'taskSeq must be an integer >= 0';
809
+ if (raw['phase'] !== undefined && raw['phase'] !== 'staged' && raw['phase'] !== 'running') {
810
+ return `phase ${JSON.stringify(raw['phase'])} must be "staged" or "running"`;
811
+ }
812
+ if (raw['planReviewState'] !== undefined
813
+ && raw['planReviewState'] !== 'awaiting_review'
814
+ && raw['planReviewState'] !== 'awaiting_feedback') {
815
+ return `planReviewState ${JSON.stringify(raw['planReviewState'])} is invalid`;
816
+ }
817
+ for (const key of ['approvedAt', 'haltedAt']) {
818
+ if (raw[key] !== undefined && !isFiniteNumber(raw[key]))
819
+ return `${key} must be a finite number`;
820
+ }
821
+ if (raw['halted'] !== undefined && typeof raw['halted'] !== 'boolean')
822
+ return 'halted must be a boolean';
823
+ if (raw['reviewPolicy'] !== undefined && !isReviewPolicy(raw['reviewPolicy']))
824
+ return 'reviewPolicy is invalid';
825
+ if (raw['escalated'] !== undefined && typeof raw['escalated'] !== 'boolean')
826
+ return 'escalated must be a boolean';
827
+ return 'record does not satisfy the durable team contract';
828
+ }
829
+ /** The first task-contract violation, or `undefined` when the task is valid. */
830
+ function describeTaskDefect(task) {
831
+ if (typeof task['id'] !== 'string')
832
+ return 'id must be a string';
833
+ if (!isOptionalString(task['profileSeedId']))
834
+ return 'profileSeedId must be a string';
835
+ if (typeof task['profileSeedId'] === 'string' && task['profileSeedId'].trim() === '')
836
+ return 'profileSeedId must not be blank';
837
+ if (typeof task['subject'] !== 'string')
838
+ return 'subject must be a string';
839
+ if (!isOptionalString(task['description']))
840
+ return 'description must be a string';
841
+ const statuses = ['pending', 'claimed', 'in_progress', 'completed', 'failed', 'cancelled'];
842
+ if (!statuses.includes(task['status']))
843
+ return `status ${JSON.stringify(task['status'])} is invalid`;
844
+ if (!isOptionalString(task['assignee']))
845
+ return 'assignee must be a string';
846
+ if (!Array.isArray(task['dependencies']))
847
+ return 'dependencies must be an array';
848
+ if (!task['dependencies'].every((dependency) => typeof dependency === 'string'))
849
+ return 'dependencies must contain only strings';
850
+ if (!isOptionalString(task['output']))
851
+ return 'output must be a string';
852
+ if (task['attempt'] !== undefined && !(Number.isSafeInteger(task['attempt']) && task['attempt'] >= 0)) {
853
+ return `attempt ${JSON.stringify(task['attempt'])} must be an integer >= 0`;
854
+ }
855
+ if (!isOptionalString(task['attemptId']))
856
+ return 'attemptId must be a string';
857
+ if (!isOptionalString(task['handoffId']))
858
+ return 'handoffId must be a string';
859
+ if (!isOptionalString(task['handoffFromMemberId']))
860
+ return 'handoffFromMemberId must be a string';
861
+ if (task['reassigning'] !== undefined && typeof task['reassigning'] !== 'boolean')
862
+ return 'reassigning must be a boolean';
863
+ if (!isFiniteNumber(task['createdAt']))
864
+ return 'createdAt must be a finite number';
865
+ if (!isFiniteNumber(task['updatedAt']))
866
+ return 'updatedAt must be a finite number';
867
+ return describeQualityTaskDefect(task);
868
+ }
869
+ /** Mirror of `hasValidQualityTaskFields`, reporting the first failing field. */
870
+ function describeQualityTaskDefect(task) {
871
+ if (task['kind'] !== undefined && !TASK_KINDS.includes(task['kind'])) {
872
+ return `kind ${JSON.stringify(task['kind'])} is invalid`;
873
+ }
874
+ if (task['verdict'] !== undefined && !REVIEW_VERDICTS.includes(task['verdict'])) {
875
+ return `verdict ${JSON.stringify(task['verdict'])} is invalid`;
876
+ }
877
+ if (task['round'] !== undefined && !(Number.isSafeInteger(task['round']) && task['round'] >= 1)) {
878
+ return `round ${JSON.stringify(task['round'])} must be an integer >= 1`;
879
+ }
880
+ for (const key of ['objective', 'reviewedTaskId', 'sourceTaskId']) {
881
+ if (task[key] !== undefined && !nonemptyString(task[key]))
882
+ return `${key} must be a non-empty string`;
883
+ }
884
+ if (task['reviewedAttempt'] !== undefined
885
+ && !(Number.isSafeInteger(task['reviewedAttempt']) && task['reviewedAttempt'] >= 0)) {
886
+ return `reviewedAttempt ${JSON.stringify(task['reviewedAttempt'])} must be an integer >= 0`;
887
+ }
888
+ const stringLists = ['inScope', 'outOfScope', 'acceptance', 'verify', 'deliverables', 'nonGoals', 'changedPaths', 'sourceFindingIds', 'coverageOf'];
889
+ for (const key of stringLists) {
890
+ const value = task[key];
891
+ if (value === undefined)
892
+ continue;
893
+ if (!Array.isArray(value))
894
+ return `${key} must be an array`;
895
+ const badIndex = value.findIndex((item) => !nonemptyString(item));
896
+ if (badIndex >= 0)
897
+ return `${key}[${badIndex}] must be a non-empty string`;
898
+ }
899
+ if (task['findings'] !== undefined) {
900
+ const findings = task['findings'];
901
+ if (!Array.isArray(findings))
902
+ return 'findings must be an array';
903
+ for (const [index, finding] of findings.entries()) {
904
+ const detail = describeFindingDefect(finding);
905
+ if (detail !== undefined)
906
+ return `findings[${index}].${detail}`;
907
+ }
908
+ const ids = findings.map((finding) => finding.id);
909
+ if (new Set(ids).size !== ids.length)
910
+ return 'findings ids must be unique';
911
+ }
912
+ if (task['acceptanceResults'] !== undefined) {
913
+ const results = task['acceptanceResults'];
914
+ if (!Array.isArray(results))
915
+ return 'acceptanceResults must be an array';
916
+ for (const [index, result] of results.entries()) {
917
+ if (!isRecord(result))
918
+ return `acceptanceResults[${index}] must be an object`;
919
+ if (!nonemptyString(result['criterion']))
920
+ return `acceptanceResults[${index}].criterion must be a non-empty string`;
921
+ if (result['status'] !== 'passed' && result['status'] !== 'failed')
922
+ return `acceptanceResults[${index}].status must be "passed" or "failed"`;
923
+ if (result['evidence'] !== undefined && typeof result['evidence'] !== 'string')
924
+ return `acceptanceResults[${index}].evidence must be a string`;
925
+ }
926
+ }
927
+ if (task['commandsRun'] !== undefined) {
928
+ const commands = task['commandsRun'];
929
+ if (!Array.isArray(commands))
930
+ return 'commandsRun must be an array';
931
+ for (const [index, command] of commands.entries()) {
932
+ if (!isRecord(command))
933
+ return `commandsRun[${index}] must be an object`;
934
+ if (!nonemptyString(command['command']))
935
+ return `commandsRun[${index}].command must be a non-empty string`;
936
+ if (command['status'] !== 'passed' && command['status'] !== 'failed')
937
+ return `commandsRun[${index}].status must be "passed" or "failed"`;
938
+ }
939
+ }
940
+ return undefined;
941
+ }
942
+ /** The first finding-contract violation, or `undefined` when the finding is valid. */
943
+ function describeFindingDefect(finding) {
944
+ if (!isRecord(finding))
945
+ return 'must be an object';
946
+ if (!nonemptyString(finding['id']))
947
+ return 'id must be a non-empty string';
948
+ if (!FINDING_SEVERITIES.includes(finding['severity'])) {
949
+ return `severity ${JSON.stringify(finding['severity'])} is invalid`;
950
+ }
951
+ if (!nonemptyString(finding['problem']))
952
+ return 'problem must be a non-empty string';
953
+ if (!nonemptyString(finding['requiredFix']))
954
+ return 'requiredFix must be a non-empty string';
955
+ if (finding['file'] !== undefined && !nonemptyString(finding['file']))
956
+ return 'file must be a non-empty string';
957
+ if (finding['line'] !== undefined && !isValidFindingLine(finding['line'])) {
958
+ return `line ${JSON.stringify(finding['line'])} must be an integer >= 0 (omit it when there is no line number)`;
959
+ }
960
+ if (finding['resolved'] !== undefined && typeof finding['resolved'] !== 'boolean')
961
+ return 'resolved must be a boolean';
962
+ return undefined;
963
+ }
698
964
  export function isTeamTask(value) {
699
965
  if (!isRecord(value))
700
966
  return false;