@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.19

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.
@@ -3,7 +3,7 @@
3
3
  * coverage, and resume. Tools and persistence call these; they do not I/O.
4
4
  * @module dsh-agent-teams/quality-gates
5
5
  */
6
- import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, } from "./types.js";
6
+ import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, TERMINAL_TASK_STATUSES, } from "./types.js";
7
7
  const QUALITY_KINDS = [
8
8
  'requirements',
9
9
  'implementation',
@@ -269,13 +269,14 @@ export function validateCreateTask(team, input) {
269
269
  if (kind === 'implementation') {
270
270
  const requirements = team.tasks.filter((item) => taskKindOf(item) === 'requirements');
271
271
  const passed = requirements.some((item) => item.status === 'completed' && item.verdict === 'pass');
272
- const stagedBehindRequirements = team.phase === 'staged' && requirements.some((item) => (dependencyClosureContains(team.tasks, dependencies, item.id)));
273
- if (requirements.length > 0 && !passed && !stagedBehindRequirements) {
272
+ const behindRequirements = requirements.some((item) => (dependencyClosureContains(team.tasks, dependencies, item.id)));
273
+ // Planning an implementation is safe in either approval mode when its
274
+ // dependency chain fences execution behind requirements. Scheduling and
275
+ // claiming still wait for successful dependency completion.
276
+ if (requirements.length > 0 && !passed && !behindRequirements) {
274
277
  return {
275
278
  ok: false,
276
- error: team.phase === 'staged'
277
- ? 'implementation must depend on the staged requirements task; it will run only after requirements passes'
278
- : 'implementation is blocked until a requirements task completes with verdict=pass',
279
+ error: 'implementation must depend on a requirements task until requirements completes with verdict=pass',
279
280
  };
280
281
  }
281
282
  }
@@ -404,6 +405,140 @@ function unresolvedFindings(task) {
404
405
  function findingKey(ids) {
405
406
  return [...ids].sort().join(',');
406
407
  }
408
+ /**
409
+ * Path-like tokens worth considering as repair-scope candidates. Two shapes:
410
+ * slash paths (`src/parser.ts`, `docs/guide.md`) and bare filenames with a
411
+ * known code/doc extension (`README.md`, `wc.js`). An optional `:line`
412
+ * suffix is tolerated and stripped. The extension allowlist keeps version
413
+ * tokens (`v0.1.17`), hex hashes, and prose out of the derived scope.
414
+ */
415
+ const REPAIR_SCOPE_PATH_PATTERN = /(?:[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)+|[\w.\-]+\.(?:tsx?|jsx?|mjs|cjs|json|md|txt|ya?ml|py|rs|go|java|html?|css|scss|sh|ps1|toml|xml|sql))(?::\d+)?/gu;
416
+ const REPAIR_SCOPE_LINE_SUFFIX = /:\d+$/;
417
+ /**
418
+ * Derive the repair round's inScope from the findings that caused it.
419
+ *
420
+ * `finding.file` records where the problem was OBSERVED, but the fix often
421
+ * targets a different file named in `requiredFix` (docs vs sample data,
422
+ * config vs code). Deriving the scope from both keeps the auto-generated
423
+ * repair contract satisfiable; deriving from `file` alone can produce a
424
+ * contract where the acceptance ("edit README.md") names a path the scope
425
+ * forbids, so no honest completion exists and the repair dead-locks.
426
+ *
427
+ * Absolute and otherwise illegal paths are dropped (they can never match
428
+ * workspace-relative scope patterns anyway); when nothing legal remains,
429
+ * the source task's own inScope is kept as the fallback. Over-inclusion is
430
+ * accepted: inScope is an audit upper bound, and the requiredFix text still
431
+ * tells the implementer what to touch.
432
+ *
433
+ * Keep all derived paths until the generator resolves inherited exclusions;
434
+ * filtering first would silently discard a required fix target.
435
+ */
436
+ export function repairScopeFromFindings(findings, fallback) {
437
+ const derived = [];
438
+ const push = (raw) => {
439
+ const normalized = normalizeWorkspacePath(raw.replace(REPAIR_SCOPE_LINE_SUFFIX, ''));
440
+ if (normalized === undefined || derived.includes(normalized))
441
+ return;
442
+ derived.push(normalized);
443
+ };
444
+ for (const finding of findings) {
445
+ if (nonemptyString(finding.file))
446
+ push(finding.file);
447
+ for (const match of finding.requiredFix.matchAll(REPAIR_SCOPE_PATH_PATTERN))
448
+ push(match[0]);
449
+ }
450
+ return derived.length > 0 ? derived : fallback === undefined ? undefined : [...new Set(fallback)];
451
+ }
452
+ const AMENDABLE_CONTRACT_FIELDS = ['objective', 'acceptance', 'verify', 'inScope', 'outOfScope'];
453
+ /**
454
+ * Controlled contract amendment (the pure rule; tooling keeps it
455
+ * captain-only). When a quality contract is wrong — a verify command that
456
+ * cannot pass, an inScope that forbids the file the objective names — the
457
+ * worker has no honest completion and either dead-locks or games the gate.
458
+ * Instead the captain may fix the contract mid-flight: every amendment is
459
+ * recorded on the task as a {@link TaskRevision} (previous values + reason),
460
+ * and once a review/requirements task has passed judgment on this task the
461
+ * contract is frozen. Amendments replace whole fields (lists are full
462
+ * replacements, not deltas); the implementer re-reads the amended contract
463
+ * before its next quality gate. Completion gates need no special casing:
464
+ * they read the task's current fields, so they naturally evaluate the
465
+ * amended contract.
466
+ */
467
+ export function amendTaskContract(team, task, input, by, reason) {
468
+ if (!nonemptyString(by))
469
+ return { ok: false, error: 'contract amendment requires a non-empty author identity' };
470
+ if (!nonemptyString(reason))
471
+ return { ok: false, error: 'contract amendment requires a non-empty reason' };
472
+ if (TERMINAL_TASK_STATUSES.includes(task.status)) {
473
+ return { ok: false, error: `task ${task.id} is ${task.status}; terminal contracts are immutable` };
474
+ }
475
+ if (taskKindOf(task) === 'work') {
476
+ return { ok: false, error: `task ${task.id} has kind=work and no contract to amend` };
477
+ }
478
+ if (!AMENDABLE_CONTRACT_FIELDS.some((field) => input[field] !== undefined)) {
479
+ return { ok: false, error: `amendment requires at least one of: ${AMENDABLE_CONTRACT_FIELDS.join(', ')}` };
480
+ }
481
+ const next = {};
482
+ const previous = {};
483
+ if (input.objective !== undefined) {
484
+ if (!nonemptyString(input.objective)) {
485
+ return { ok: false, error: 'amended objective must be a non-empty string' };
486
+ }
487
+ next['objective'] = input.objective;
488
+ previous['objective'] = task.objective;
489
+ }
490
+ for (const field of ['acceptance', 'verify']) {
491
+ const value = input[field];
492
+ if (value === undefined)
493
+ continue;
494
+ if (!nonemptyStringList(value)) {
495
+ return { ok: false, error: `amended ${field} must be a non-empty list of non-empty strings` };
496
+ }
497
+ next[field] = value;
498
+ previous[field] = task[field];
499
+ }
500
+ for (const field of ['inScope', 'outOfScope']) {
501
+ const value = input[field];
502
+ if (value === undefined)
503
+ continue;
504
+ if (!nonemptyStringList(value)) {
505
+ return { ok: false, error: `amended ${field} must be a non-empty list of non-empty strings` };
506
+ }
507
+ for (const entry of value) {
508
+ if (normalizeWorkspacePath(entry) === undefined) {
509
+ return {
510
+ ok: false,
511
+ error: `amended ${field} entry "${entry}" is not a workspace-relative path (absolute paths and ".." can never match scope patterns)`,
512
+ };
513
+ }
514
+ }
515
+ next[field] = value;
516
+ previous[field] = task[field];
517
+ }
518
+ const reviewPassed = team.tasks.some((item) => ((taskKindOf(item) === 'review' || taskKindOf(item) === 'requirements')
519
+ && item.reviewedTaskId === task.id
520
+ && item.verdict === 'pass'));
521
+ if (reviewPassed) {
522
+ return { ok: false, error: `task ${task.id} already passed review; its contract is frozen` };
523
+ }
524
+ const revision = {
525
+ at: Date.now(),
526
+ by,
527
+ reason,
528
+ fields: Object.keys(next),
529
+ previous,
530
+ };
531
+ return {
532
+ ok: true,
533
+ revision,
534
+ task: {
535
+ ...task,
536
+ ...next,
537
+ revisions: [...(task.revisions ?? []), revision],
538
+ updatedAt: Date.now(),
539
+ },
540
+ };
541
+ }
407
542
  const CAPTAIN_ASSIGNEE = 'captain';
408
543
  const OPEN_FOLLOW_UP_STATUSES = ['pending', 'claimed', 'in_progress'];
409
544
  function schedulableAssignee(preferred, team, forbidden) {
@@ -429,6 +564,13 @@ function hasOpenFollowUp(team, sourceTaskId, findingIds) {
429
564
  && findingKey(item.sourceFindingIds ?? []) === key
430
565
  && OPEN_FOLLOW_UP_STATUSES.includes(item.status)));
431
566
  }
567
+ /** Drop previous-round exclusions that intersect this generated repair's scope. */
568
+ function withoutScopeConflicts(outOfScope, inScope) {
569
+ if (outOfScope === undefined)
570
+ return undefined;
571
+ const conflicting = new Set(inScopeOverlap(outOfScope, inScope ?? []));
572
+ return outOfScope.filter((pattern) => !conflicting.has(pattern));
573
+ }
432
574
  export function planQualityFollowUp(team, closed) {
433
575
  const empty = { created: [], tasks: [] };
434
576
  const kind = taskKindOf(closed);
@@ -467,7 +609,9 @@ export function planQualityFollowUp(team, closed) {
467
609
  if (countRepairAttempts(team, sourceId, findingIds) >= policy.maxRepairAttempts) {
468
610
  return { ...empty, escalated: true, status: 'escalated' };
469
611
  }
470
- const files = findings.map((finding) => finding.file).filter((file) => nonemptyString(file));
612
+ // inScope is derived from the findings below: the observed file plus any
613
+ // workspace-relative paths referenced by the requiredFix instructions.
614
+ const repairScope = repairScopeFromFindings(findings, source?.inScope);
471
615
  const implementer = schedulableAssignee(source?.assignee, team);
472
616
  const repair = {
473
617
  id: `repair-round-${nextRound}`,
@@ -477,8 +621,8 @@ export function planQualityFollowUp(team, closed) {
477
621
  dependencies: [sourceId],
478
622
  round: nextRound,
479
623
  objective: source?.objective ?? closed.objective ?? `Fix findings from ${sourceId}`,
480
- inScope: files.length > 0 ? files : source?.inScope,
481
- outOfScope: source?.outOfScope,
624
+ inScope: repairScope,
625
+ outOfScope: withoutScopeConflicts(source?.outOfScope, repairScope),
482
626
  verify: source?.verify,
483
627
  acceptance: findings.map((finding) => finding.requiredFix),
484
628
  sourceTaskId: sourceId,
@@ -515,6 +659,20 @@ export function buildCoverageMatrix(goalItems, tasks) {
515
659
  }
516
660
  export function canDeclareDelivery(team) {
517
661
  const blockers = [];
662
+ if (team.phase === 'staged')
663
+ blockers.push('team plan is awaiting approval');
664
+ if (team.halted === true)
665
+ blockers.push('team is halted');
666
+ if (team.escalated === true)
667
+ blockers.push('team requires escalation resolution');
668
+ if (team.tasks.length === 0)
669
+ blockers.push('team has no completed work');
670
+ for (const item of team.tasks.filter(item => !isQualityKind(taskKindOf(item)))) {
671
+ if (item.status !== 'completed' && item.status !== 'cancelled')
672
+ blockers.push(`${item.id} (${taskKindOf(item)}) is not completed`);
673
+ }
674
+ if (team.tasks.length > 0 && team.tasks.every(item => item.status === 'cancelled'))
675
+ blockers.push('all work was cancelled');
518
676
  const quality = team.tasks.filter((item) => isQualityKind(taskKindOf(item)));
519
677
  const implementations = quality.filter((item) => taskKindOf(item) === 'implementation' || taskKindOf(item) === 'repair');
520
678
  const reviews = quality.filter((item) => taskKindOf(item) === 'review');
@@ -600,6 +758,15 @@ export function isCommandResult(value) {
600
758
  && (value['exitCode'] === undefined || (Number.isSafeInteger(value['exitCode'])))
601
759
  && (value['evidence'] === undefined || typeof value['evidence'] === 'string');
602
760
  }
761
+ export function isTaskRevision(value) {
762
+ if (!isRecord(value))
763
+ return false;
764
+ return Number.isSafeInteger(value['at'])
765
+ && nonemptyString(value['by'])
766
+ && nonemptyString(value['reason'])
767
+ && nonemptyStringList(value['fields'])
768
+ && isRecord(value['previous']);
769
+ }
603
770
  // Optional fields whose persisted values must be non-empty when present
604
771
  // (mirrors the checks in hasValidQualityTaskFields). Some models materialize
605
772
  // optional tool parameters as "" instead of omitting them (e.g. GPT-5.6
@@ -683,6 +850,10 @@ export function hasValidQualityTaskFields(value) {
683
850
  if (!Array.isArray(value['commandsRun']) || !value['commandsRun'].every(isCommandResult))
684
851
  return false;
685
852
  }
853
+ if (value['revisions'] !== undefined) {
854
+ if (!Array.isArray(value['revisions']) || !value['revisions'].every(isTaskRevision))
855
+ return false;
856
+ }
686
857
  return true;
687
858
  }
688
859
  function isRecord(value) {
@@ -796,7 +967,7 @@ export function describeQualityLoop(team) {
796
967
  halted: false,
797
968
  escalated: team.escalated === true,
798
969
  deliverable: true,
799
- summary: 'All required quality gates passed. The captain may report delivery.',
970
+ summary: 'All required work and quality gates passed. The captain may report delivery.',
800
971
  };
801
972
  }
802
973
  if (team.escalated === true) {
package/lib/scheduler.js CHANGED
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import { join } from 'node:path';
15
15
  import { deliverToMember } from "./members.js";
16
- import { acknowledgeMailbox, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
+ import { isCurrentMail, mailboxPrompt } from "./mailbox.js";
17
+ import { markMailboxDelivered, discardMailboxMessages, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readPendingMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
17
18
  /** Per-dependency output cap in the assignment prompt. */
18
19
  export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
19
20
  /** Combined dependency-output budget in the assignment prompt. */
@@ -103,6 +104,8 @@ function liveMember(ctx, member) {
103
104
  return ctx.agents.get(member.id);
104
105
  }
105
106
  function isMemberAvailable(ctx, member) {
107
+ if (member.stopping === true)
108
+ return false;
106
109
  const live = liveMember(ctx, member);
107
110
  return live === undefined || live.status === 'idle';
108
111
  }
@@ -168,13 +171,6 @@ When finishing: use status=completed only when the task's success criteria are s
168
171
 
169
172
  State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
170
173
  }
171
- function fallbackMailboxPrompt(messages) {
172
- return [
173
- 'AgentTeams delivered messages that were persisted while live delivery was unavailable:',
174
- ...messages.map(message => `\nFrom ${message.from}:\n${message.content}`),
175
- '\nHandle these messages in this turn. Task assignments still require agent_teams_claim_task and the current attempt_id.',
176
- ].join('\n');
177
- }
178
174
  /** Install one scheduler and its member activity observer. */
179
175
  export function installTeamScheduler(ctx, config) {
180
176
  const memberQueues = new Map();
@@ -227,16 +223,28 @@ export function installTeamScheduler(ctx, config) {
227
223
  if (captain === undefined)
228
224
  return;
229
225
  let member = team.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
230
- if (member === undefined || member.id === '' || !isMemberAvailable(ctx, member))
226
+ if (member === undefined || !isMemberAvailable(ctx, member))
231
227
  return;
232
228
  // A mailbox-only fallback is real pending work. Deliver it before a
233
229
  // fresh task and acknowledge only after Harness accepts the follow-up.
234
- const unread = await readUnreadMailbox(stateRoot, team.id, member.name);
230
+ const unread = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
231
+ const fresh = await readTeam(stateRoot, team.id);
232
+ if (fresh === undefined)
233
+ return [];
234
+ const pending = await readPendingMailbox(stateRoot, fresh.id, member.name);
235
+ await discardMailboxMessages(stateRoot, fresh.id, member.name, pending.filter(message => !isCurrentMail(fresh, message)).map(message => message.id));
236
+ const current = pending.filter(message => isCurrentMail(fresh, message));
237
+ await claimMailboxDelivery(stateRoot, fresh.id, member.name, current.map(message => message.id));
238
+ return current;
239
+ });
235
240
  if (unread.length > 0) {
236
- await withTeamLock(teamLockKey(stateRoot, team.id), () => (claimMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
237
- const accepted = await deliverToMember(ctx, captain, member.id, fallbackMailboxPrompt(unread), new AbortController().signal);
241
+ const prompt = mailboxPrompt(team.id, member.name, unread);
242
+ const signal = new AbortController().signal;
243
+ const accepted = config.dispatch === undefined
244
+ ? await deliverToMember(ctx, captain, member.id, prompt, signal, 'steer')
245
+ : await config.dispatch(captain, team.id, member.name, prompt, signal, 'steer');
238
246
  if (accepted) {
239
- await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, member.name, unread.map(message => message.id))));
247
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (markMailboxDelivered(stateRoot, team.id, member.name, unread.map(message => message.id))));
240
248
  }
241
249
  else {
242
250
  await withTeamLock(teamLockKey(stateRoot, team.id), () => (releaseMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
@@ -248,7 +256,7 @@ export function installTeamScheduler(ctx, config) {
248
256
  if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
249
257
  return undefined;
250
258
  const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
251
- if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
259
+ if (currentMember === undefined || !isMemberAvailable(ctx, currentMember))
252
260
  return undefined;
253
261
  const owned = ownedOpenTask(fresh.tasks, currentMember.name);
254
262
  // An idle edge observed by this scheduler parks the exact open
@@ -318,7 +326,11 @@ export function installTeamScheduler(ctx, config) {
318
326
  });
319
327
  if (ticket === undefined)
320
328
  return;
321
- const accepted = await deliverToMember(ctx, captain, ticket.memberId, assignmentPrompt(ticket, config.stateDir, team.id), new AbortController().signal);
329
+ const prompt = assignmentPrompt(ticket, config.stateDir, team.id);
330
+ const signal = new AbortController().signal;
331
+ const accepted = config.dispatch === undefined
332
+ ? await deliverToMember(ctx, captain, ticket.memberId, prompt, signal)
333
+ : await config.dispatch(captain, team.id, ticket.memberName, prompt, signal, 'queue', ticket.attemptId);
322
334
  if (accepted)
323
335
  return;
324
336
  // Roll back only our exact failed dispatch. A concurrent captain
package/lib/snapshot.js CHANGED
@@ -76,7 +76,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
76
76
  : activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
77
77
  ? 'idle'
78
78
  : 'unknown')
79
- : state.phase === 'staged' ? 'idle' : 'unknown',
79
+ : 'idle',
80
80
  progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
81
81
  done,
82
82
  total: owned.length,
package/lib/state.js CHANGED
@@ -18,7 +18,7 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promise
18
18
  import { join } from 'node:path';
19
19
  import { TERMINAL_TASK_STATUSES } from "./types.js";
20
20
  import { hasValidQualityTaskFields, isReviewPolicy, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
21
- export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } 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";
22
22
  /** Mailbox key of the captain. */
23
23
  export const CAPTAIN_KEY = 'captain';
24
24
  /** A crashed live-delivery attempt becomes retryable after this interval. */
@@ -419,9 +419,14 @@ export async function readMailbox(stateRoot, teamId, agentKey, onMalformedLine)
419
419
  }
420
420
  /** Read only messages that have not been acknowledged by their recipient. */
421
421
  export async function readUnreadMailbox(stateRoot, teamId, agentKey, onMalformedLine) {
422
- const now = Date.now();
423
422
  return (await readMailbox(stateRoot, teamId, agentKey, onMalformedLine))
424
- .filter(message => message.readAt === undefined
423
+ .filter(message => message.readAt === undefined && message.discardedAt === undefined);
424
+ }
425
+ /** Pending delivery is distinct from delivered-but-not-yet-read input. */
426
+ export async function readPendingMailbox(stateRoot, teamId, agentKey) {
427
+ const now = Date.now();
428
+ return (await readUnreadMailbox(stateRoot, teamId, agentKey))
429
+ .filter(message => message.deliveredAt === undefined
425
430
  && (message.deliveryClaimedAt === undefined
426
431
  || now - message.deliveryClaimedAt >= MAILBOX_DELIVERY_LEASE_MS));
427
432
  }
@@ -485,6 +490,16 @@ export async function acknowledgeMailbox(stateRoot, teamId, agentKey, messageIds
485
490
  };
486
491
  });
487
492
  }
493
+ /** Acceptance by Harness is not evidence that a model step consumed input. */
494
+ export async function markMailboxDelivered(stateRoot, teamId, agentKey, ids) {
495
+ await mutateMailbox(stateRoot, teamId, agentKey, ids, (message) => {
496
+ const { deliveryClaimedAt: _claimed, ...rest } = message;
497
+ return { ...rest, deliveredAt: message.deliveredAt ?? Date.now() };
498
+ });
499
+ }
500
+ export async function discardMailboxMessages(stateRoot, teamId, agentKey, ids) {
501
+ await mutateMailbox(stateRoot, teamId, agentKey, ids, message => ({ ...message, discardedAt: message.discardedAt ?? Date.now() }));
502
+ }
488
503
  /** Remove the optional UTF-8 BOM some editors prepend to JSON text. */
489
504
  function stripLeadingBom(value) {
490
505
  return value.charCodeAt(0) === 0xFEFF ? value.slice(1) : value;
@@ -591,11 +606,13 @@ function isTeamMember(value) {
591
606
  && typeof value['name'] === 'string'
592
607
  && value['name'].trim() !== ''
593
608
  && isOptionalString(value['role'])
609
+ && (value['stopping'] === undefined || typeof value['stopping'] === 'boolean')
594
610
  && isOptionalString(value['provider'])
595
611
  && isOptionalString(value['model'])
596
612
  && isOptionalString(value['reasoningEffort'])
597
613
  && isOptionalString(value['activeProvider'])
598
614
  && isOptionalString(value['activeModel'])
615
+ && isOptionalString(value['spawnError'])
599
616
  && (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
600
617
  && (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
601
618
  && (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
@@ -691,6 +708,7 @@ export function isTeamTask(value) {
691
708
  || (Number.isSafeInteger(value['attempt']) && value['attempt'] >= 0))
692
709
  && isOptionalString(value['attemptId'])
693
710
  && isOptionalString(value['handoffId'])
711
+ && isOptionalString(value['handoffFromMemberId'])
694
712
  && (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
695
713
  && isFiniteNumber(value['createdAt'])
696
714
  && isFiniteNumber(value['updatedAt'])
@@ -729,10 +747,9 @@ function isTeamState(value, expectedId) {
729
747
  const tasks = value['tasks'];
730
748
  const memberIds = new Set();
731
749
  const memberKeys = new Set();
732
- const staged = value['phase'] === 'staged';
733
750
  for (const member of members) {
734
751
  const key = sanitizeKey(member.name);
735
- if ((!staged && member.id === '') || key === CAPTAIN_KEY || memberKeys.has(key))
752
+ if (key === CAPTAIN_KEY || memberKeys.has(key))
736
753
  return false;
737
754
  if (member.id !== '') {
738
755
  if (memberIds.has(member.id))
@@ -760,7 +777,10 @@ function isTeamMessage(value) {
760
777
  && isFiniteNumber(value['ts'])
761
778
  && (value['deliveryClaimedAt'] === undefined || isFiniteNumber(value['deliveryClaimedAt']))
762
779
  && (value['deliveredAt'] === undefined || isFiniteNumber(value['deliveredAt']))
763
- && (value['readAt'] === undefined || isFiniteNumber(value['readAt']));
780
+ && (value['readAt'] === undefined || isFiniteNumber(value['readAt']))
781
+ && (value['discardedAt'] === undefined || isFiniteNumber(value['discardedAt']))
782
+ && (value['taskId'] === undefined || typeof value['taskId'] === 'string')
783
+ && (value['attemptId'] === undefined || typeof value['attemptId'] === 'string');
764
784
  }
765
785
  /**
766
786
  * Remove a team's whole directory (members should be interrupted first).
package/lib/tool-names.js CHANGED
@@ -3,6 +3,7 @@ export const TEAM_TOOL_NAMES = [
3
3
  'agent_teams_create', 'agent_teams_approve', 'agent_teams_edit_plan',
4
4
  'agent_teams_add_member', 'agent_teams_remove_member', 'agent_teams_create_task',
5
5
  'agent_teams_reassign_task', 'agent_teams_claim_task', 'agent_teams_update_task',
6
+ 'agent_teams_amend_task',
6
7
  'agent_teams_send_message', 'agent_teams_status', 'agent_teams_resume', 'agent_teams_delete',
7
8
  ];
8
9
  export const MEMBER_TOOL_NAMES = [