@xema/omni-protocol 0.1.102 → 0.1.104

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.
@@ -91,7 +91,7 @@ const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true
91
91
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
92
92
  const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
93
93
  const EXPIRABLE_PHASES = membersOf({
94
- pending: true, confirmed: true, preview: true,
94
+ pending: true, confirmed: true,
95
95
  });
96
96
  const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
97
97
  /** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
@@ -100,8 +100,8 @@ const TASK_CAPABILITIES = {
100
100
  browsers: true, outcomes: true, custom: true, decline: true, hold: true,
101
101
  endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
102
102
  }),
103
- chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
104
- email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
103
+ chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true, schedule: true }),
104
+ email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, schedule: true }),
105
105
  };
106
106
  // The published list and the type's keys are the same set, or one of them is wrong.
107
107
  true;
@@ -393,6 +393,10 @@ export function validatePhone(value, manifest, path = "context.phone") {
393
393
  function manifestDials(manifest) {
394
394
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
395
395
  }
396
+ /** Whether a manifest declares a calendar, and so may publish tasks that schedule onto it. `undefined` where there is no manifest to ask. */
397
+ function manifestCalendar(manifest) {
398
+ return isPlainObject(manifest) ? isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.calendar === true : undefined;
399
+ }
396
400
  /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
397
401
  export function validateProviderTimeEstimate(value, scope, path = "estimate") {
398
402
  const into = new Collector();
@@ -520,8 +524,10 @@ export function validateManifest(manifest, path = "manifest") {
520
524
  if (manifest.runningStepReports !== undefined) {
521
525
  into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
522
526
  }
523
- into.require(!Object.hasOwn(manifest, "disposalSettleMs"), "manifest.completionSettleMs.renamed", `${path}.disposalSettleMs`, "use completionSettleMs; the former field is not accepted");
524
- into.require(typeof manifest.completionSettleMs === "number" && Number.isInteger(manifest.completionSettleMs) && manifest.completionSettleMs > 0, "manifest.completionSettleMs", `${path}.completionSettleMs`, "completionSettleMs is how long after an applied completion the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
527
+ for (const former of ["disposalSettleMs", "completionSettleMs"]) {
528
+ into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
529
+ }
530
+ into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
525
531
  if (manifest.orgLevels !== undefined) {
526
532
  if (!Array.isArray(manifest.orgLevels)) {
527
533
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -1120,6 +1126,11 @@ function validateTaskInto(task, context, path, into) {
1120
1126
  into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
1121
1127
  into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
1122
1128
  }
1129
+ // How long the offer has left travels on the pending task, so a reconnect snapshot restates it.
1130
+ if (task.expiresInSeconds !== undefined) {
1131
+ into.require(task.phase === "pending", "task.pending.expiresInSeconds.unexpected", `${path}.expiresInSeconds`, "expiresInSeconds is how long an offer has left, and belongs to a pending task; accepted, there is no offer to run out");
1132
+ into.require(isDurationSeconds(task.expiresInSeconds), "task.pending.expiresInSeconds", `${path}.expiresInSeconds`, "expiresInSeconds is how long the offer has left: a whole number of seconds, zero or more, counted from this publication");
1133
+ }
1123
1134
  // The deadline and what happens at it travel together, and only while the record is being previewed.
1124
1135
  if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
1125
1136
  into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
@@ -1239,8 +1250,10 @@ function validateTaskInto(task, context, path, into) {
1239
1250
  if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
1240
1251
  into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
1241
1252
  }
1253
+ // Under agent-command the allowance is the expected wrap, shown and never acted on: the agent
1254
+ // completes whenever they are done, so there is always a wrap to collect the code in.
1242
1255
  if (capabilities.outcomes !== undefined) {
1243
- into.require(task.wrapAllowance !== 0, "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and wrapAllowance 0 gives none: publish a wrap or no outcomes");
1256
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and provider-automatic with wrapAllowance 0 gives none: publish a wrap or no outcomes");
1244
1257
  }
1245
1258
  for (const [name, declared] of Object.entries(capabilities)) {
1246
1259
  if (declared === undefined)
@@ -1256,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
1256
1269
  if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
1257
1270
  into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
1258
1271
  }
1272
+ // A follow-up lands on the calendar, so the manifest has to have one for it to land on.
1273
+ if (name === "schedule" && context.calendarDeclared === false) {
1274
+ into.add("task.capability.calendar.required", `${path}.capabilities.schedule`, "schedule puts a follow-up on the calendar, and the manifest declares no calendar for it to land on");
1275
+ }
1259
1276
  // A control the queue could allow may stand locked in its place, saying whose. What the
1260
1277
  // queue provides -- browsers, outcomes, custom controls -- is content, not a control.
1261
1278
  if (isLocked(declared)) {
@@ -1296,8 +1313,9 @@ function validateForcedBreak(value, path, into) {
1296
1313
  for (const field of ["endsAutomatically", "endsAt"]) {
1297
1314
  into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
1298
1315
  }
1299
- if (value.expectedDurationMs !== undefined)
1300
- into.require(typeof value.expectedDurationMs === "number" && Number.isFinite(value.expectedDurationMs) && value.expectedDurationMs > 0, "break.forced.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be a positive finite number of milliseconds");
1316
+ into.require(!Object.hasOwn(value, "expectedDurationMs"), "break.forced.expectedDurationMs.renamed", `${path}.expectedDurationMs`, "the state says how long is left, as expectedEndsInSeconds; the duration asked for travels on the lead's command alone");
1317
+ if (value.expectedEndsInSeconds !== undefined)
1318
+ into.require(isDurationSeconds(value.expectedEndsInSeconds), "break.forced.expectedEndsInSeconds", `${path}.expectedEndsInSeconds`, "expectedEndsInSeconds is how long the break is expected to run yet: a whole number of seconds, zero or more, counted from this publication");
1301
1319
  }
1302
1320
  /** Checks break status against the full currently retained task set, after a transaction. */
1303
1321
  export function validateBreakStatus(state, tasks, path = "snapshot") {
@@ -1399,8 +1417,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1399
1417
  else if (command.type === "force-break" || command.type === "end-forced-break") {
1400
1418
  validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1401
1419
  const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1402
- if (command.type === "end-forced-break")
1420
+ if (command.type === "end-forced-break") {
1403
1421
  into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
1422
+ // The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
1423
+ into.require(!(isPlainObject(state.forced) && state.forced.by === "provider"), "team.command.endForcedBreak.provider", path, "the platform put this member on the break, and it lifts it: a lead ends a break a lead forced");
1424
+ }
1404
1425
  if (command.type === "force-break") {
1405
1426
  if (command.expectedDurationMs !== undefined)
1406
1427
  into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
@@ -1415,7 +1436,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1415
1436
  into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
1416
1437
  }
1417
1438
  else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
1418
- into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
1439
+ // Every lead's entry is on the member; the one this command moves is the signed-in lead's.
1440
+ into.require(isPlainObject(target) && Array.isArray(target.listening) && target.listening.some((entry) => isPlainObject(entry) && entry.leadId === self), "team.command.listening", path, `${command.type} needs this lead on this member's call: the team member list shows listening on them naming this lead`);
1419
1441
  }
1420
1442
  return into.violations;
1421
1443
  }
@@ -1611,10 +1633,19 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1611
1633
  }
1612
1634
  validateTeamMemberInto(member, at, context, into);
1613
1635
  });
1614
- // One member's call at a time.
1615
- const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1616
- if (listened.length > 1)
1617
- into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1636
+ // One member's call at a time, per lead.
1637
+ const onCalls = new Map();
1638
+ for (const member of teamMembers.members) {
1639
+ if (!isPlainObject(member) || !Array.isArray(member.listening))
1640
+ continue;
1641
+ const leads = new Set(member.listening.filter(isPlainObject).map(entry => entry.leadId).filter((id) => typeof id === "string"));
1642
+ for (const leadId of leads)
1643
+ onCalls.set(leadId, (onCalls.get(leadId) ?? 0) + 1);
1644
+ }
1645
+ for (const [leadId, count] of onCalls) {
1646
+ if (count > 1)
1647
+ into.add("team.listening.single", `${path}.members`, `${leadId} is on ${count} members' calls: a lead is on one member's call at a time`);
1648
+ }
1618
1649
  }
1619
1650
  /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1620
1651
  export function validateTeamMember(member, path = "member", context = {}) {
@@ -1659,23 +1690,41 @@ function validateTeamMemberInto(member, at, context, into) {
1659
1690
  into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1660
1691
  }
1661
1692
  }
1662
- // The lead on this member's call, in the mode they are heard.
1693
+ // Every lead on this member's call, in the mode they are heard, published to every lead alike.
1663
1694
  if (member.listening !== undefined) {
1664
- if (!isPlainObject(member.listening)) {
1665
- into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
1695
+ if (!Array.isArray(member.listening)) {
1696
+ into.add("team.member.listening.shape", `${at}.listening`, "listening is an array of the leads on the member's call: absent while none is");
1666
1697
  }
1667
1698
  else {
1668
- const listened = member.listening;
1669
- if (into.require(isAssignmentId(member.listening.assignmentId), "team.member.listening.assignmentId", `${at}.listening.assignmentId`, "listening names which of the member's calls the lead is on: the application opens the lead's audio on it") && Array.isArray(member.tasks)) {
1670
- into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === listened.assignmentId), "team.member.listening.assignment", `${at}.listening.assignmentId`, "the call the lead is on is one the member's tasks carry");
1671
- }
1672
- into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1673
- into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1699
+ into.require(member.listening.length > 0, "team.member.listening.empty", `${at}.listening`, "nobody on the call is absence, not an empty list");
1700
+ const leads = new Set();
1701
+ member.listening.forEach((entry, index) => {
1702
+ const here = `${at}.listening[${index}]`;
1703
+ if (!isPlainObject(entry)) {
1704
+ into.add("team.member.listening.entry", here, "each listening entry must be an object");
1705
+ return;
1706
+ }
1707
+ if (into.require(isUserId(entry.leadId), "team.member.listening.leadId", `${here}.leadId`, "a listening entry names the lead on the call, by user id")) {
1708
+ if (leads.has(entry.leadId))
1709
+ into.add("team.member.listening.unique", `${here}.leadId`, `${entry.leadId} is listed twice on one member: a lead is on a member's call once`);
1710
+ leads.add(entry.leadId);
1711
+ }
1712
+ const task = Array.isArray(member.tasks) ? member.tasks.find((task) => isPlainObject(task) && task.assignmentId === entry.assignmentId) : undefined;
1713
+ if (into.require(isAssignmentId(entry.assignmentId), "team.member.listening.assignmentId", `${here}.assignmentId`, "a listening entry names which of the member's calls the lead is on: the application opens its own audio on it") && Array.isArray(member.tasks)) {
1714
+ into.require(task !== undefined, "team.member.listening.assignment", `${here}.assignmentId`, "the call the lead is on is one the member's tasks carry");
1715
+ }
1716
+ if (into.oneOf(entry.mode, LISTENING_MODES, "team.member.listening.mode", `${here}.mode`) && entry.mode === "join-call"
1717
+ && isPlainObject(task) && Array.isArray(task.onCall) && isUserId(entry.leadId)) {
1718
+ // A lead who is heard is in the room the member sees; a silent one is not.
1719
+ into.require(task.onCall.some((who) => isPlainObject(who) && who.role === "agent" && who.userId === entry.leadId), "team.member.listening.heard", `${here}.mode`, `${entry.leadId} has joined the call and is heard, so the task's onCall carries them as an agent`);
1720
+ }
1721
+ into.timestamp(entry.since, "team.member.listening.since", `${here}.since`);
1722
+ });
1674
1723
  }
1675
1724
  }
1676
1725
  // The member's own history for the day: about the person, not any one call.
1677
1726
  if (member.shift !== undefined)
1678
- validateShiftInto(member.shift, `${at}.shift`, into);
1727
+ validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
1679
1728
  // The member's standing ask for a lead, on one of the calls they hold.
1680
1729
  if (member.request !== undefined) {
1681
1730
  if (!isPlainObject(member.request)) {
@@ -1701,6 +1750,18 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1701
1750
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1702
1751
  const levels = context.levels ?? manifestLevels(manifest);
1703
1752
  into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
1753
+ // The provider's own instant of the read: required where the provider has a clock to state, and
1754
+ // a clock nobody declared is not one the desk may count from.
1755
+ if (isPlainObject(manifest) && manifest.timeCheck === true) {
1756
+ into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
1757
+ }
1758
+ else if (snapshot.providerTime !== undefined) {
1759
+ into.add("snapshot.providerTime.unexpected", `${path}.providerTime`, "providerTime is the clock of a provider that declares timeCheck; declare it, or send none");
1760
+ }
1761
+ requireClockInto(snapshot, manifest, path, into);
1762
+ // The agent's own day, as the provider counts it: the same numbers their lead sees.
1763
+ if (snapshot.shift !== undefined)
1764
+ validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
1704
1765
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1705
1766
  && context.loginId !== undefined) {
1706
1767
  into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
@@ -1717,7 +1778,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1717
1778
  else {
1718
1779
  const seen = new Set();
1719
1780
  snapshot.tasks.forEach((task, index) => {
1720
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1781
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1721
1782
  if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1722
1783
  if (seen.has(task.assignmentId))
1723
1784
  into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
@@ -1874,6 +1935,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1874
1935
  return into.violations;
1875
1936
  }
1876
1937
  const at = `${path}.event`;
1938
+ // A snapshot event is read by validateSnapshot, which asks the clock question of it there.
1939
+ if (event.type !== "snapshot")
1940
+ requireClockInto(event, manifest, at, into);
1877
1941
  into.require(!Object.hasOwn(event, "taskId"), "event.assignmentId.renamed", `${at}.taskId`, "an event names a task by its assignment alone; the former field is not accepted");
1878
1942
  switch (event.type) {
1879
1943
  case "snapshot":
@@ -1899,16 +1963,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1899
1963
  validateBreakState(event.break, `${at}.break`, into);
1900
1964
  break;
1901
1965
  case "task-offered":
1902
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1966
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1903
1967
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1904
1968
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1905
1969
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
1906
1970
  }
1907
- if (event.expiresInSeconds !== undefined)
1908
- into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
1971
+ into.require(event.expiresInSeconds === undefined, "event.taskOffered.expiresInSeconds.unexpected", `${at}.expiresInSeconds`, "expiresInSeconds travels on the pending task, where a reconnect snapshot restates it, not on the event");
1909
1972
  break;
1910
1973
  case "task-updated":
1911
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1974
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1912
1975
  break;
1913
1976
  case "task-audio-started":
1914
1977
  into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
@@ -1985,6 +2048,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1985
2048
  event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
1986
2049
  }
1987
2050
  break;
2051
+ case "shift-updated":
2052
+ validateShiftInto(event.shift, `${at}.shift`, into, "shift");
2053
+ break;
1988
2054
  case "calendar-updated":
1989
2055
  into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
1990
2056
  if (!Array.isArray(event.calendar)) {
@@ -2036,38 +2102,65 @@ function validatePreferencesInto(value, path, into, levels) {
2036
2102
  });
2037
2103
  }
2038
2104
  /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
2039
- function validateShiftInto(value, path, into) {
2105
+ /** Whether a publication carries an instant the desk renders as a running duration, which the provider's clock reads. */
2106
+ function carriesRunningInstant(value) {
2107
+ if (!isPlainObject(value))
2108
+ return false;
2109
+ if (isPlainObject(value.shift) && value.shift.signedInAt !== undefined)
2110
+ return true;
2111
+ const tasks = [
2112
+ ...(Array.isArray(value.tasks) ? value.tasks : []),
2113
+ ...(isPlainObject(value.task) ? [value.task] : []),
2114
+ ];
2115
+ if (tasks.some(task => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))
2116
+ return true;
2117
+ const members = [
2118
+ ...(isPlainObject(value.team) && Array.isArray(value.team.members) ? value.team.members : []),
2119
+ ...(isPlainObject(value.member) ? [value.member] : []),
2120
+ ];
2121
+ return members.some(member => isPlainObject(member) && (member.since !== undefined
2122
+ || (Array.isArray(member.listening) && member.listening.some((entry) => isPlainObject(entry) && entry.since !== undefined))
2123
+ || (isPlainObject(member.shift) && member.shift.signedInAt !== undefined)
2124
+ || (Array.isArray(member.tasks) && member.tasks.some((task) => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))));
2125
+ }
2126
+ /** A running duration is counted from the provider's clock: a provider that publishes one declares timeCheck. */
2127
+ function requireClockInto(value, manifest, path, into) {
2128
+ if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
2129
+ return;
2130
+ into.add("manifest.timeCheck.required", path, "this publishes an instant the desk renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
2131
+ }
2132
+ function validateShiftInto(value, path, into, rule) {
2040
2133
  if (!isPlainObject(value)) {
2041
- into.add("team.member.shift.shape", path, "shift must be an object when present");
2134
+ into.add(`${rule}.shape`, path, "shift must be an object when present");
2042
2135
  return;
2043
2136
  }
2044
- const signedIn = into.timestamp(value.signedInAt, "team.member.shift.signedInAt", `${path}.signedInAt`);
2045
- if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, "team.member.shift.signedOutAt", `${path}.signedOutAt`) && signedIn) {
2046
- into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), "team.member.shift.signedOutAt.order", `${path}.signedOutAt`, "a member signs out after signing in");
2137
+ const signedIn = into.timestamp(value.signedInAt, `${rule}.signedInAt`, `${path}.signedInAt`);
2138
+ if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, `${rule}.signedOutAt`, `${path}.signedOutAt`) && signedIn) {
2139
+ into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), `${rule}.signedOutAt.order`, `${path}.signedOutAt`, "a member signs out after signing in");
2047
2140
  }
2048
2141
  for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
2049
2142
  if (value[field] !== undefined)
2050
2143
  into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
2051
2144
  }
2052
2145
  if (value.tasksHandled !== undefined)
2053
- into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, "team.member.shift.tasksHandled", `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
2146
+ into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, `${rule}.tasksHandled`, `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
2054
2147
  if (value.events !== undefined) {
2055
2148
  if (!Array.isArray(value.events)) {
2056
- into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
2149
+ into.add(`${rule}.events.shape`, `${path}.events`, "events must be an array when present");
2057
2150
  return;
2058
2151
  }
2059
2152
  let previous;
2060
2153
  value.events.forEach((event, index) => {
2061
2154
  const at = `${path}.events[${index}]`;
2062
2155
  if (!isPlainObject(event)) {
2063
- into.add("team.member.shift.event.shape", at, "each shift event must be an object");
2156
+ into.add(`${rule}.event.shape`, at, "each shift event must be an object");
2064
2157
  return;
2065
2158
  }
2066
- into.oneOf(event.kind, SHIFT_EVENT_KINDS, "team.member.shift.event.kind", `${at}.kind`);
2067
- if (into.timestamp(event.at, "team.member.shift.event.at", `${at}.at`)) {
2159
+ into.oneOf(event.kind, SHIFT_EVENT_KINDS, `${rule}.event.kind`, `${at}.kind`);
2160
+ if (into.timestamp(event.at, `${rule}.event.at`, `${at}.at`)) {
2068
2161
  const instant = Date.parse(event.at);
2069
2162
  if (previous !== undefined && instant < previous)
2070
- into.add("team.member.shift.events.order", `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2163
+ into.add(`${rule}.events.order`, `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2071
2164
  previous = instant;
2072
2165
  }
2073
2166
  });
@@ -2543,7 +2636,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2543
2636
  case "complete": {
2544
2637
  // The agent may finish early under either mode; the provider is free to end at once. With
2545
2638
  // no wrap at all there is no window to cut short, and nothing for the command to do.
2546
- into.require(task.wrapAllowance !== 0, "command.complete.wrapAllowance", path, "the task has no wrap: with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2639
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "command.complete.wrapAllowance", path, "the task has no wrap: under provider-automatic with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2547
2640
  // What travels with complete is what the outcomes capability published: a code from its
2548
2641
  // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2549
2642
  const outcomes = capabilities.outcomes;