@orboto/mcp 0.192.0 → 0.194.0

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 (131) hide show
  1. package/dist/event-bridge.js +1 -47
  2. package/dist/event-bridge.test.js +0 -19
  3. package/dist/http-transport-binding.test.js +1 -4
  4. package/dist/http-transport.js +1 -213
  5. package/dist/http-transport.test.js +0 -56
  6. package/dist/index.js +1 -48
  7. package/dist/input-schema.js +2 -76
  8. package/dist/input-schema.test.js +1 -15
  9. package/dist/instructions-budget.test.js +0 -2
  10. package/dist/manifest-size.test.js +3 -72
  11. package/dist/oauth-bootstrap.js +0 -47
  12. package/dist/oauth-bootstrap.test.js +4 -16
  13. package/dist/orboto-client.js +0 -30
  14. package/dist/orboto-client.test.js +0 -12
  15. package/dist/prompts.js +0 -39
  16. package/dist/protect-text-usage.test.js +1 -9
  17. package/dist/required-rules-wrapper.test.js +0 -5
  18. package/dist/required-rules.js +0 -2
  19. package/dist/resources.js +0 -62
  20. package/dist/resources.test.js +0 -16
  21. package/dist/response-budget.js +2 -150
  22. package/dist/response-budget.test.js +1 -32
  23. package/dist/server.js +4 -180
  24. package/dist/session-nudge.js +0 -9
  25. package/dist/session-nudge.test.js +2 -10
  26. package/dist/session-nudge.transport.test.js +0 -23
  27. package/dist/tool-annotations.test.js +0 -44
  28. package/dist/tool-docs.js +1 -12
  29. package/dist/tool-docs.test.js +2 -8
  30. package/dist/tool-parameters.test.js +0 -19
  31. package/dist/tools/absence-writes.js +1 -17
  32. package/dist/tools/admin-translations.js +0 -11
  33. package/dist/tools/admin-writes.js +0 -28
  34. package/dist/tools/admin-writes.test.js +0 -1
  35. package/dist/tools/agent-coordination.js +2 -24
  36. package/dist/tools/agent-coordination.test.js +0 -2
  37. package/dist/tools/agent-instructions.js +0 -10
  38. package/dist/tools/agent-instructions.test.js +0 -2
  39. package/dist/tools/agent-messages.js +0 -1
  40. package/dist/tools/ai-status.js +0 -14
  41. package/dist/tools/ai-usage.js +0 -10
  42. package/dist/tools/analytics.js +0 -2
  43. package/dist/tools/api-call.js +0 -12
  44. package/dist/tools/api-call.test.js +1 -2
  45. package/dist/tools/api-search.js +1 -6
  46. package/dist/tools/approvals.js +0 -6
  47. package/dist/tools/attach.js +0 -25
  48. package/dist/tools/backup.js +0 -13
  49. package/dist/tools/backup.test.js +1 -2
  50. package/dist/tools/bulk-create.js +1 -25
  51. package/dist/tools/bulk-create.test.js +0 -4
  52. package/dist/tools/bulk-writes.js +69 -61
  53. package/dist/tools/bulk-writes.test.js +34 -4
  54. package/dist/tools/check-similar.js +0 -17
  55. package/dist/tools/checklist-writes.js +82 -27
  56. package/dist/tools/checklist-writes.test.js +69 -1
  57. package/dist/tools/claim.js +1 -71
  58. package/dist/tools/claim.test.js +0 -2
  59. package/dist/tools/critical-path.js +0 -5
  60. package/dist/tools/critical-path.test.js +0 -3
  61. package/dist/tools/cross-project-links.ee.js +1 -24
  62. package/dist/tools/doc-attachments.js +0 -28
  63. package/dist/tools/doc-attachments.test.js +1 -4
  64. package/dist/tools/doc-comments.js +1 -37
  65. package/dist/tools/doc-comments.test.js +1 -2
  66. package/dist/tools/doc-edits.js +1 -31
  67. package/dist/tools/doc-edits.test.js +0 -9
  68. package/dist/tools/doc-export.js +0 -24
  69. package/dist/tools/doc-export.test.js +0 -12
  70. package/dist/tools/doc-revisions.js +0 -23
  71. package/dist/tools/docs-ai.js +0 -34
  72. package/dist/tools/docs-ai.test.js +0 -11
  73. package/dist/tools/docs.js +1 -75
  74. package/dist/tools/docs.test.js +1 -3
  75. package/dist/tools/embedding-status.js +0 -13
  76. package/dist/tools/embedding-status.test.js +0 -1
  77. package/dist/tools/feedback.js +1 -15
  78. package/dist/tools/feedback.test.js +0 -1
  79. package/dist/tools/get-checklists.js +6 -14
  80. package/dist/tools/get-project-primer.js +1 -25
  81. package/dist/tools/get-project.js +0 -17
  82. package/dist/tools/get-ticket.js +1 -61
  83. package/dist/tools/get-timer.js +0 -11
  84. package/dist/tools/git-health-reasons.js +0 -1
  85. package/dist/tools/identity.js +0 -13
  86. package/dist/tools/list-projects.js +0 -20
  87. package/dist/tools/list-projects.test.js +0 -1
  88. package/dist/tools/list-tickets.js +10 -9
  89. package/dist/tools/milestones-crud.test.js +0 -4
  90. package/dist/tools/milestones.js +0 -43
  91. package/dist/tools/my-tickets.js +0 -7
  92. package/dist/tools/phase-b-tools.test.js +14 -21
  93. package/dist/tools/primer-facts.js +1 -49
  94. package/dist/tools/primer-facts.test.js +0 -20
  95. package/dist/tools/project-listings.js +0 -25
  96. package/dist/tools/query.js +1 -12
  97. package/dist/tools/query.test.js +0 -8
  98. package/dist/tools/raci.js +0 -11
  99. package/dist/tools/requirements-spec.js +1 -12
  100. package/dist/tools/response-expand.js +0 -19
  101. package/dist/tools/review-policy.js +0 -24
  102. package/dist/tools/review-policy.test.js +0 -2
  103. package/dist/tools/search.js +1 -13
  104. package/dist/tools/session-start.js +1 -136
  105. package/dist/tools/session-start.test.js +1 -44
  106. package/dist/tools/set-parent.js +1 -19
  107. package/dist/tools/shared.js +0 -41
  108. package/dist/tools/shared.test.js +0 -4
  109. package/dist/tools/similar-projection.js +0 -15
  110. package/dist/tools/ticket-attachments.js +1 -25
  111. package/dist/tools/ticket-schedules.js +0 -9
  112. package/dist/tools/ticket-schedules.test.js +0 -1
  113. package/dist/tools/ticket-writes.js +1 -110
  114. package/dist/tools/ticket-writes.test.js +1 -15
  115. package/dist/tools/time-writes.js +26 -46
  116. package/dist/tools/time-writes.test.js +18 -0
  117. package/dist/tools/update-project.js +1 -27
  118. package/dist/tools/update-project.test.js +1 -1
  119. package/dist/tools/wiki.js +0 -8
  120. package/dist/tools/work-next.js +0 -5
  121. package/dist/tools/work-sessions-start.test.js +0 -2
  122. package/dist/tools/work-sessions.js +0 -48
  123. package/dist/tools/work-sessions.test.js +0 -5
  124. package/dist/toolset.js +2 -81
  125. package/dist/toolset.test.js +0 -20
  126. package/dist/version-guard.test.js +0 -4
  127. package/dist/version.js +0 -12
  128. package/dist/with-metrics-output-schema.test.js +1 -14
  129. package/dist/with-metrics.js +2 -74
  130. package/dist/with-metrics.test.js +7 -29
  131. package/package.json +1 -1
@@ -1,22 +1,7 @@
1
1
  /**
2
2
  * ORB-1455 - read + view ticket attachments from MCP.
3
3
  *
4
- * Every agent surface could WRITE attachments (orboto_attach_to_ticket) but
5
- * none could READ them: an agent working a ticket could not tell the ticket
6
- * HAD files, let alone SEE an attached screenshot. These two tools close that
7
- * hole (orboto_get_ticket also gains an `attachments` array):
8
- *
9
- * - orboto_list_ticket_attachments - thin wrapper on the ticket-attachment
10
- * list route; mirrors orboto_list_doc_attachments (ORB-914).
11
- * - orboto_get_attachment - fetch an attachment's bytes via the new
12
- * authenticated base64 route and return them to the model:
13
- * * images (image/*) -> an MCP image content block so the model
14
- * actually VIEWS the screenshot, plus a text
15
- * line with filename/size.
16
- * * everything else -> a text block with metadata + the base64
17
- * (small files) or a pointer to the skill
18
- * download shortcut (large files).
19
- * Works for ticket/doc/comment attachments - the id is global.
4
+ * @see ORB-914
20
5
  */
21
6
  import { z } from 'zod';
22
7
  import { resolveTicketByKey } from './shared.js';
@@ -27,9 +12,6 @@ const MAX_INLINE_BYTES = 5 * 1024 * 1024;
27
12
  function isImage(mimetype) {
28
13
  return mimetype.startsWith('image/');
29
14
  }
30
- // ---------------------------------------------------------------------------
31
- // orboto_list_ticket_attachments
32
- // ---------------------------------------------------------------------------
33
15
  export const listTicketAttachmentsToolConfig = {
34
16
  title: 'List attachments on a ticket',
35
17
  description: 'Return a ticket\'s attachments newest-first, with each attachment\'s id, filename, content type, size, and stable download URL. Use the returned id with orboto_get_attachment to actually view an image or fetch the bytes. Empty list = no attachments. Input is the ticket key like "ACME-42".',
@@ -73,9 +55,6 @@ export function makeListTicketAttachmentsHandler(client) {
73
55
  };
74
56
  };
75
57
  }
76
- // ---------------------------------------------------------------------------
77
- // orboto_get_attachment
78
- // ---------------------------------------------------------------------------
79
58
  export const getAttachmentToolConfig = {
80
59
  title: 'View or fetch an attachment\'s bytes',
81
60
  description: 'Fetch a single attachment by its id (from orboto_list_ticket_attachments, orboto_get_ticket, or orboto_list_doc_attachments) and return its content. For an image, the model receives an image content block so it actually SEES the screenshot, plus a text line with filename and size. For a non-image, the model gets a text block with metadata and, for small files, the base64 content; large binaries point at the skill\'s download-attachment shortcut. Works for ticket, doc, and comment attachments (the id is global). Enforces the same project/space access check as listing the attachment.',
@@ -88,8 +67,6 @@ export function makeGetAttachmentHandler(client) {
88
67
  return async ({ attachmentId }) => {
89
68
  const att = await client.get(`/attachments/${attachmentId}/base64`);
90
69
  const kb = Math.round(att.sizeBytes / 1024);
91
- // Over the inline cap: refuse to spill a multi-MB blob into the model's
92
- // context. Point at the skill download shortcut, which streams to disk.
93
70
  if (att.sizeBytes > MAX_INLINE_BYTES) {
94
71
  return {
95
72
  content: [{
@@ -112,7 +89,6 @@ export function makeGetAttachmentHandler(client) {
112
89
  },
113
90
  };
114
91
  }
115
- // Non-image: return metadata + the base64 so an agent can decode / save it.
116
92
  return {
117
93
  content: [{
118
94
  type: 'text',
@@ -16,9 +16,6 @@ function scheduleLine(s) {
16
16
  const state = s.cancelledAt ? ' [CANCELLED]' : '';
17
17
  return `- ${s.title}${state} - ${when} - ${people} attendee(s) (id ${s.id})`;
18
18
  }
19
- // ---------------------------------------------------------------------------
20
- // orboto_list_ticket_schedules
21
- // ---------------------------------------------------------------------------
22
19
  export const listTicketSchedulesToolConfig = {
23
20
  title: 'List a ticket\'s scheduled working sessions',
24
21
  description: 'List the meeting / working sessions scheduled on a ticket (by key). Active sessions only by default; pass includeCancelled=true for history. Returns each session\'s id, title, window and attendee count - get the id here before cancelling one with orboto_cancel_ticket_session.',
@@ -44,9 +41,6 @@ export function makeListTicketSchedulesHandler(client) {
44
41
  };
45
42
  };
46
43
  }
47
- // ---------------------------------------------------------------------------
48
- // orboto_schedule_ticket_session
49
- // ---------------------------------------------------------------------------
50
44
  export const scheduleTicketSessionToolConfig = {
51
45
  title: 'Schedule a working session on a ticket',
52
46
  description: 'Schedule a working session ("scope review", "design crit") on a ticket. Drops a plan-block on every orboto attendee and emails each attendee an iCal invite. startsAt/endsAt are ISO 8601 datetimes. attendeeUserIds are orboto user UUIDs (from orboto_list_users); externalEmails are non-orboto guests who get the invite but no plan-block. Title defaults to "Working session: <TICKET-KEY>".',
@@ -88,9 +82,6 @@ export function makeScheduleTicketSessionHandler(client) {
88
82
  };
89
83
  };
90
84
  }
91
- // ---------------------------------------------------------------------------
92
- // orboto_cancel_ticket_session
93
- // ---------------------------------------------------------------------------
94
85
  export const cancelTicketSessionToolConfig = {
95
86
  title: 'Cancel a scheduled working session',
96
87
  description: 'Cancel a working session on a ticket. Removes every attendee\'s plan-block and emails an iCal cancellation to all attendees. Get the scheduleId from orboto_list_ticket_schedules.',
@@ -22,7 +22,6 @@ function stub(responses) {
22
22
  status: r.status ?? 200,
23
23
  statusText: 'OK',
24
24
  json: async () => payload,
25
- // delete<T> reads text() then JSON.parses it - serialise the payload.
26
25
  text: async () => JSON.stringify(payload),
27
26
  };
28
27
  });
@@ -1,39 +1,13 @@
1
1
  /**
2
2
  * ORB-244 Phase C Group 1 - ticket mutation tools.
3
3
  *
4
- * Eight tools that round-trip the API's existing PBAC cascade. Every
5
- * write is gated on the caller's project-level permissions; a 403
6
- * from the API surfaces as `isError: true` on the MCP response with
7
- * the API's error message intact, so the model can either retry
8
- * elsewhere or explain the lock to the user.
9
- *
10
- * Resolution patterns mirror Phase B:
11
- * - `projectKey` (`ACME`) → UUID via `/projects/by-key/:key`
12
- * - `ticketKey` (`ACME-42`) → UUID via `/projects/:id/tickets/by-key/:n`
13
- * - `assigneeEmail` → userId via the project members list
14
- * - `milestone` (name) → milestoneId via the project milestones list
15
- *
16
- * Tools in this file:
17
- * - orboto_create_ticket
18
- * - orboto_update_ticket
19
- * - orboto_move_ticket
20
- * - orboto_close_ticket
21
- * - orboto_comment
22
- * - orboto_assign
23
- * - orboto_unassign
24
- * - orboto_set_milestone
25
- * - orboto_add_ticket_dependency (ORB-453)
26
- * - orboto_remove_ticket_dependency (ORB-453)
27
- * - orboto_list_ticket_dependencies (ORB-453)
4
+ * @see ORB-453
28
5
  */
29
6
  import { z } from 'zod';
30
7
  import { trimSimilarEntries } from './similar-projection.js';
31
8
  import { OrbotoApiError } from '../orboto-client.js';
32
9
  import { resolveProjectByKey, resolveTicketByKey, resolveByName } from './shared.js';
33
10
  import { resolveMilestoneByNameOrId } from './milestones.js';
34
- // ---------------------------------------------------------------------------
35
- // Local helpers
36
- // ---------------------------------------------------------------------------
37
11
  const STATUS_CATEGORIES = ['todo', 'in_progress', 'in_review', 'done', 'wont_fix'];
38
12
  const CATEGORY_TO_LEGACY = {
39
13
  todo: 'TODO',
@@ -68,24 +42,14 @@ function ticketStructured(t) {
68
42
  statusCategory: t.statusCategory ?? null,
69
43
  type: t.type,
70
44
  priority: t.priority,
71
- // ORB-1608 - role-aware commit policy. Absent on responses the API's
72
- // enrich pipeline didn't touch; the API itself defaults unset rows to
73
- // 'implementation'.
74
45
  deliveryMode: t.deliveryMode ?? 'implementation',
75
46
  dueDate: t.dueDate,
76
47
  isPrivate: t.isPrivate,
77
48
  };
78
49
  }
79
- // ---------------------------------------------------------------------------
80
- // orboto_create_ticket
81
- // ---------------------------------------------------------------------------
82
50
  export const createTicketToolConfig = {
83
51
  title: 'Create a ticket',
84
52
  description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):** some workspaces REFUSE a create whose top similarity match is at/above a configured threshold - the tool returns a `duplicateBlocked` result (NOT a created ticket) listing the matching tickets. Extend or comment on one of those instead. If you have confirmed none of them cover this work, retry with `allowDuplicate: true` AND a `duplicateJustification` explaining why - the justification is persisted as a comment on the new ticket. '
85
- // ORB-1805 - parameter prose moved out of the input schema (which
86
- // every client pays for on connect) into this text, which the
87
- // manifest summarises to one sentence and orboto_help serves in
88
- // full. Same rows also live in the skill's REFERENCE.md.
89
53
  + '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
90
54
  inputSchema: z.object({
91
55
  projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
@@ -115,12 +79,8 @@ export function makeCreateTicketHandler(client) {
115
79
  type: input.type ?? 'task',
116
80
  priority: input.priority ?? 'normal',
117
81
  isPrivate: input.isPrivate ?? false,
118
- // ORB-1608 - leave unset when the caller didn't pass one; the API
119
- // defaults it (epic for type=epic, else implementation).
120
82
  ...(input.deliveryMode ? { deliveryMode: input.deliveryMode } : {}),
121
83
  };
122
- // ORB-1471 - the override justification rides in the body; the override
123
- // flag itself is a querystring param (see createPath below).
124
84
  if (input.allowDuplicate && input.duplicateJustification)
125
85
  body.duplicateJustification = input.duplicateJustification;
126
86
  if (input.dueDate)
@@ -131,18 +91,10 @@ export function makeCreateTicketHandler(client) {
131
91
  const parent = await resolveTicketByKey(client, input.parentTicketKey);
132
92
  body.parentTicketId = parent.id;
133
93
  }
134
- // ORB-1416 - labels + assignees attach INLINE in the create
135
- // transaction so the whole thing is atomic (one round-trip). No more
136
- // post-create attach loops: a bad label/assignee reference now rolls
137
- // the create back on the API side (clean 400, no orphan ticket) rather
138
- // than leaving a half-created ticket the agent retries into a dup.
139
94
  if (input.labels && input.labels.length > 0)
140
95
  body.labelNames = input.labels;
141
96
  if (input.assigneeEmails && input.assigneeEmails.length > 0)
142
97
  body.assigneeEmails = input.assigneeEmails;
143
- // ORB-990 - strict ticket-language enforcement may reject this with a
144
- // 422; ORB-1471 - the hard duplicate-block may reject it with a 409.
145
- // Surface each as a clear block result instead of a raw error.
146
98
  const qs = [];
147
99
  if (input.allowLanguageMismatch)
148
100
  qs.push('allowLanguageMismatch=true');
@@ -162,21 +114,11 @@ export function makeCreateTicketHandler(client) {
162
114
  return blocked;
163
115
  throw err;
164
116
  }
165
- // ORB-831 / ORB-887 - surface the backend's `similarWarnings` to the
166
- // calling agent. The text block prepends a clearly-visible warning
167
- // when matches exist so a model scanning the result for "warning"
168
- // / "duplicate" notices and self-corrects.
169
- // ORB-890 / ORB-891 - same surface for `languageWarning` when the
170
- // detected language doesn't match the workspace default.
171
117
  const warnings = created.similarWarnings ?? [];
172
118
  const langWarning = created.languageWarning;
173
119
  const deferred = created.duplicateCheckDeferred === true;
174
120
  const baseText = ticketSummaryText('Created', created);
175
121
  const parts = [baseText];
176
- // ORB-1437 - under a create burst the backend defers the duplicate-check
177
- // to a background job (empty similarWarnings, duplicateCheckDeferred:true).
178
- // Tell the agent so an empty warnings list isn't read as "no duplicates":
179
- // a strong match will land as an advisory comment on the new ticket shortly.
180
122
  if (deferred) {
181
123
  parts.push(`\nℹ Duplicate-check deferred - the project is under high create load, so the check runs in the background. `
182
124
  + `An empty duplicate list here does NOT mean "no duplicates". If a strong match is found, an advisory comment `
@@ -193,14 +135,7 @@ export function makeCreateTicketHandler(client) {
193
135
  content: [{ type: 'text', text }],
194
136
  structuredContent: {
195
137
  ...ticketStructured(created),
196
- // ORB-1176 - the one unambiguous field for the NEW ticket's key.
197
- // `similarWarnings[].ticketKey` are OTHER tickets; read
198
- // `createdTicketKey` (never a warning's key) to avoid grabbing the
199
- // wrong one.
200
138
  createdTicketKey: created.ticketKey,
201
- // ORB-1693 - agent projection: key/title/category/2dp-similarity/
202
- // relation only. The rich shape (UUID, colours, statusName, 15dp)
203
- // stays on the REST response for the web UI.
204
139
  similarWarnings: trimSimilarEntries(warnings),
205
140
  ...(deferred ? { duplicateCheckDeferred: true } : {}),
206
141
  ...(langWarning ? { languageWarning: langWarning } : {}),
@@ -277,8 +212,6 @@ function duplicateBlockResult(err) {
277
212
  parsed = JSON.parse(err.body);
278
213
  }
279
214
  catch { /* non-JSON body */ }
280
- // Only OUR duplicate-block 409 carries similarWarnings; a different 409
281
- // (none currently on this route) should bubble as a normal error.
282
215
  if (!parsed.similarWarnings)
283
216
  return null;
284
217
  const candidates = parsed.similarWarnings;
@@ -301,9 +234,6 @@ function formatSimilarity(w) {
301
234
  const pct = `${Math.round(w.similarity * 100)}% ${w.matchMode === 'embedding' ? 'AI match' : 'text match'}`;
302
235
  return w.statusName ? `${w.statusName}, ${pct}` : pct;
303
236
  }
304
- // ---------------------------------------------------------------------------
305
- // orboto_update_ticket
306
- // ---------------------------------------------------------------------------
307
237
  export const updateTicketToolConfig = {
308
238
  title: 'Update a ticket',
309
239
  description: 'Patch one or more fields on a ticket. Patchable: title, description, customerSummary, type, priority, deliveryMode, dueDate, startDate, isPrivate, estimatedTimeMinutes. `customerSummary` is the customer-facing text shown in the customer project report instead of the internal description; null or empty falls back to an AI distillation or the title. `deliveryMode` is the role-aware commit policy (ORB-1608) - see `orboto_create_ticket` for the mode semantics. Use `orboto_move_ticket` for status, `orboto_set_milestone` for milestone, and `orboto_assign` / `orboto_unassign` for members.',
@@ -328,8 +258,6 @@ export const updateTicketToolConfig = {
328
258
  export function makeUpdateTicketHandler(client) {
329
259
  return async ({ ticketKey, patch, allowLanguageMismatch }) => {
330
260
  const ticket = await resolveTicketByKey(client, ticketKey);
331
- // ORB-990 - strict ticket-language enforcement may reject a
332
- // title/description patch with a 422; surface a clear block result.
333
261
  const patchPath = `/projects/${ticket.projectId}/tickets/${ticket.id}${allowLanguageMismatch ? '?allowLanguageMismatch=true' : ''}`;
334
262
  let updated;
335
263
  try {
@@ -355,9 +283,6 @@ export function makeUpdateTicketHandler(client) {
355
283
  };
356
284
  };
357
285
  }
358
- // ---------------------------------------------------------------------------
359
- // orboto_move_ticket
360
- // ---------------------------------------------------------------------------
361
286
  export const moveTicketToolConfig = {
362
287
  title: 'Move a ticket between status categories',
363
288
  description: 'Move a ticket to a new status category - todo / in_progress / in_review / done / wont_fix. The API picks the project\'s first status with that category. Caller must have `ticket:change_status`. **Summary warning (ORB-1332):** moving to `in_review` or `done` without having just posted a summary comment returns a non-blocking `summaryWarning` - the move still succeeds, but you should post what changed, the commit SHA, and how to verify. Use `orboto_close_ticket` with a `comment` (or comment first, then move) to avoid it. **Delivery-mode warning (ORB-1608/1642):** moving to `done` on an `implementation`/`docs` ticket with zero linked commits returns a non-blocking `deliveryModeWarning` - UNLESS the project has no git connection at all, in which case it\'s omitted (a link isn\'t possible there). Code `no_commit_linked` means the connection looks healthy - link a commit, or change deliveryMode via `orboto_update_ticket` if the work genuinely is not commit-shaped. Code `git_delivery_failing` means the connection itself looks unhealthy - a commit may already exist and just hasn\'t arrived; check the connection before assuming none was made.',
@@ -377,9 +302,6 @@ export function makeMoveTicketHandler(client) {
377
302
  }, updated.summaryWarning), updated.deliveryModeWarning);
378
303
  };
379
304
  }
380
- // ---------------------------------------------------------------------------
381
- // orboto_close_ticket
382
- // ---------------------------------------------------------------------------
383
305
  export const closeTicketToolConfig = {
384
306
  title: 'Close a ticket',
385
307
  description: 'Move a ticket to `done` and optionally post a closing comment in one call. Closing more than ~3 tickets? Use `orboto_bulk_close_tickets` instead. Convenience wrapper around `orboto_move_ticket` + `orboto_comment` so the model doesn\'t need to chain two writes. Passing a `comment` is the recommended way to close - it doubles as the transition summary and suppresses the ORB-1332 `summaryWarning`. Closing with no comment (and none posted in the last few minutes) returns a non-blocking `summaryWarning`; the close still succeeds. **Delivery-mode warning (ORB-1608/1642):** on an `implementation`/`docs` ticket, closing with zero linked commits returns a non-blocking `deliveryModeWarning` too - UNLESS the project has no git connection at all, in which case it\'s omitted. `no_commit_linked` = healthy connection, link a commit or change deliveryMode via `orboto_update_ticket`. `git_delivery_failing` = the connection itself looks unhealthy - check it before assuming no commit was made.',
@@ -393,9 +315,6 @@ export function makeCloseTicketHandler(client) {
393
315
  return async ({ ticketKey, comment }) => {
394
316
  const ticket = await resolveTicketByKey(client, ticketKey);
395
317
  if (comment) {
396
- // Comment first so the close-comment lands in the audit trail
397
- // even if the status move 403s. Mirrors the wrapper's `close`
398
- // behaviour.
399
318
  await client.post(`/tickets/${ticket.id}/comments`, { content: comment });
400
319
  }
401
320
  const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: 'DONE' });
@@ -405,9 +324,6 @@ export function makeCloseTicketHandler(client) {
405
324
  }, updated.summaryWarning), updated.deliveryModeWarning);
406
325
  };
407
326
  }
408
- // ---------------------------------------------------------------------------
409
- // orboto_delete_ticket
410
- // ---------------------------------------------------------------------------
411
327
  export const deleteTicketToolConfig = {
412
328
  title: 'Permanently delete a ticket',
413
329
  description: 'DESTRUCTIVE, IRREVERSIBLE hard-delete of a ticket (by key): the row and its history are gone, and a `ticket.deleted` event + webhook fire. Strongly prefer moving the ticket to `wont_fix` (orboto_move_ticket) or closing it instead - wont_fix keeps the history and analytics intact. Only hard-delete a ticket that should truly never have existed (accidental duplicate, spam). Caller must have `ticket:delete`.',
@@ -456,9 +372,6 @@ export function makeCommentHandler(client) {
456
372
  };
457
373
  };
458
374
  }
459
- // ---------------------------------------------------------------------------
460
- // orboto_update_comment (ORB-1285)
461
- // ---------------------------------------------------------------------------
462
375
  export const updateCommentToolConfig = {
463
376
  title: 'Edit a ticket comment',
464
377
  description: 'Edit the text of a comment you authored (super-admins can edit any). Markdown. The prior text is snapshotted into the comment\'s revision history. Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
@@ -479,9 +392,6 @@ export function makeUpdateCommentHandler(client) {
479
392
  };
480
393
  };
481
394
  }
482
- // ---------------------------------------------------------------------------
483
- // orboto_delete_comment (ORB-1285)
484
- // ---------------------------------------------------------------------------
485
395
  export const deleteCommentToolConfig = {
486
396
  title: 'Delete a ticket comment',
487
397
  description: 'Delete a comment. You can always delete your own; deleting another user\'s comment needs the `comment:delete_others` permission in the project (super-admins always can). Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
@@ -501,9 +411,6 @@ export function makeDeleteCommentHandler(client) {
501
411
  };
502
412
  };
503
413
  }
504
- // ---------------------------------------------------------------------------
505
- // orboto_assign / orboto_unassign
506
- // ---------------------------------------------------------------------------
507
414
  export const assignToolConfig = {
508
415
  title: 'Assign a user to a ticket',
509
416
  description: 'Add a project member as an assignee on a ticket. Multi-assignee is supported - this adds, it does not replace. Use `orboto_unassign` to remove.',
@@ -522,7 +429,6 @@ export function makeAssignHandler(client) {
522
429
  }
523
430
  catch (err) {
524
431
  if (err instanceof OrbotoApiError && err.status === 409) {
525
- // Already assigned - idempotent success.
526
432
  return {
527
433
  content: [{ type: 'text', text: `[${ticket.ticketKey}] already assigned to ${assigneeEmail}.` }],
528
434
  structuredContent: { ticketKey: ticket.ticketKey, alreadyAssigned: true },
@@ -553,7 +459,6 @@ export function makeUnassignHandler(client) {
553
459
  await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`);
554
460
  }
555
461
  catch (err) {
556
- // 404 = wasn't assigned. Idempotent success.
557
462
  if (err instanceof OrbotoApiError && err.status === 404) {
558
463
  return {
559
464
  content: [{ type: 'text', text: `${assigneeEmail} wasn't assigned to [${ticket.ticketKey}].` }],
@@ -568,17 +473,8 @@ export function makeUnassignHandler(client) {
568
473
  };
569
474
  };
570
475
  }
571
- // ---------------------------------------------------------------------------
572
- // orboto_label_ticket / orboto_unlabel_ticket - ORB-1043
573
- // ---------------------------------------------------------------------------
574
476
  async function resolveLabelId(client, projectId, name) {
575
477
  const labels = await client.get(`/projects/${projectId}/labels`);
576
- // ORB-1826 - exact match wins first, then a unique normalised match
577
- // (HTML-entity-decoded, trimmed, whitespace-collapsed, casefolded) so
578
- // case/whitespace/entity variants of an existing label name resolve
579
- // instead of rejecting. Labels have no unique-name constraint, so an
580
- // ambiguous normalised match (two labels differing only by case) still
581
- // errors rather than silently picking one.
582
478
  const { match, ambiguous } = resolveByName(labels, name, (l) => l.name);
583
479
  if (ambiguous) {
584
480
  const list = ambiguous.map((l) => `"${l.name}" (${l.id})`).join(', ');
@@ -629,9 +525,6 @@ export function makeUnlabelTicketHandler(client) {
629
525
  };
630
526
  };
631
527
  }
632
- // ---------------------------------------------------------------------------
633
- // orboto_set_milestone
634
- // ---------------------------------------------------------------------------
635
528
  export const setMilestoneToolConfig = {
636
529
  title: 'Set a ticket\'s milestone',
637
530
  description: 'Move a ticket onto a different milestone (or off all milestones with milestone=null/undefined). Resolves the milestone by key (e.g. "ORB-M3"), name, OR UUID within the ticket\'s project (including closed/archived). If two milestones share a name, pass the key or UUID - an ambiguous name is rejected, not silently guessed.',
@@ -677,7 +570,6 @@ export function makeAddTicketDependencyHandler(client) {
677
570
  await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
678
571
  }
679
572
  catch (err) {
680
- // 409 = edge already exists - idempotent success.
681
573
  if (err instanceof OrbotoApiError && err.status === 409) {
682
574
  return {
683
575
  content: [{ type: 'text', text: `[${ticket.ticketKey}] already depends on [${dependsOn.ticketKey}].` }],
@@ -715,7 +607,6 @@ export function makeRemoveTicketDependencyHandler(client) {
715
607
  await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies/${dependsOn.id}`);
716
608
  }
717
609
  catch (err) {
718
- // 404 = edge wasn't there. Idempotent success.
719
610
  if (err instanceof OrbotoApiError && err.status === 404) {
720
611
  return {
721
612
  content: [{ type: 'text', text: `[${ticket.ticketKey}] didn't depend on [${dependsOn.ticketKey}].` }],
@@ -63,9 +63,6 @@ describe('orboto_create_ticket', () => {
63
63
  projectKey: 'ACME', title: 'Labelled', labels: ['bug', 'ui'],
64
64
  assigneeEmails: ['dev@example.com'],
65
65
  });
66
- // Exactly one POST - the atomic create - carrying labels + assignees
67
- // in its body. No follow-up /labels/ or /assignees/ round-trips, so a
68
- // bad reference can't leave an orphan ticket the agent retries into a dup.
69
66
  const posts = calls.filter((c) => c.method === 'POST');
70
67
  expect(posts).toHaveLength(1);
71
68
  expect(posts[0].url).toBe('https://orboto.example.com/projects/p1/tickets');
@@ -121,12 +118,8 @@ describe('orboto_create_ticket', () => {
121
118
  expect(text).toContain('ACME-13');
122
119
  const sc = res.structuredContent;
123
120
  expect(sc.similarWarnings).toHaveLength(2);
124
- // ORB-1176 - createdTicketKey is the NEW key, never a warning's key.
125
121
  expect(sc.createdTicketKey).toBe('ACME-99');
126
122
  expect(sc.similarWarnings.map((w) => w.ticketKey)).not.toContain(sc.createdTicketKey);
127
- // ORB-1693 - the agent projection is pinned: exactly these fields, no
128
- // UUID, no colours, no statusName, similarity at 2dp. A re-grow of the
129
- // rich shape is a regression, not an enhancement.
130
123
  for (const w of sc.similarWarnings) {
131
124
  expect(Object.keys(w).sort()).toEqual(['relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
132
125
  }
@@ -204,13 +197,12 @@ describe('orboto_create_ticket', () => {
204
197
  if (m === 'GET') {
205
198
  return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
206
199
  }
207
- createUrl = u; // the POST /tickets call
200
+ createUrl = u;
208
201
  return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
209
202
  });
210
203
  const res = await makeCreateTicketHandler(client)({
211
204
  projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern',
212
205
  });
213
- // No override flag → no query param on the create call.
214
206
  expect(createUrl).not.toContain('allowLanguageMismatch');
215
207
  expect(res.isError).toBe(true);
216
208
  const text = res.content[0].text;
@@ -236,8 +228,6 @@ describe('orboto_create_ticket', () => {
236
228
  });
237
229
  expect(createUrl).toContain('allowLanguageMismatch=true');
238
230
  });
239
- // ORB-1471 - hard duplicate-block 409 becomes a clear duplicateBlocked
240
- // result that surfaces the candidate list verbatim + the override recipe.
241
231
  it('turns a hard duplicate-block 409 into a duplicateBlocked result listing the candidates (ORB-1471)', async () => {
242
232
  const blockBody = JSON.stringify({
243
233
  error: 'This ticket looks like a duplicate (98% match to an existing ticket).',
@@ -595,10 +585,6 @@ describe('orboto_add_ticket_dependency / remove / list - ORB-453', () => {
595
585
  expect(text).toContain('Blocks');
596
586
  expect(text).toContain('_(none)_');
597
587
  });
598
- // ORB-1614 - a cross-project blocker the caller cannot read comes back
599
- // as an opaque stub (title/ticketKey/projectId/status all null). The
600
- // rendered text must never print the literal "null" and must not need
601
- // any of those fields to say something useful.
602
588
  it('list renders an opaque placeholder for an unreadable cross-project stub, never "null"', async () => {
603
589
  stub([
604
590
  { json: PROJ }, { json: TICKET },
@@ -1,27 +1,9 @@
1
1
  /**
2
2
  * ORB-244 Phase C Group 2 - time-tracking tools.
3
- *
4
- * Three tools that wrap the existing /time/timer/* + /tickets/:id/time-entries
5
- * endpoints:
6
- * - orboto_timer_start (ticketKey, description?, replace?)
7
- * - orboto_timer_stop - closes the running timer; the API converts
8
- * elapsed time into a time_entries row using the
9
- * description set at start.
10
- * - orboto_log_time - direct time-entry POST for after-the-fact
11
- * logging (no running timer involved).
12
- *
13
- * Note: the wrapper's `timer-stop "note"` syntax sends a `note` field
14
- * the API silently ignores - the time entry's description is whatever
15
- * was set on `start`. To attach a note to a stopped session, post a
16
- * comment afterwards via `orboto_comment`. The MCP tool only accepts
17
- * what the API actually accepts.
18
3
  */
19
4
  import { z } from 'zod';
20
5
  import { OrbotoApiError } from '../orboto-client.js';
21
6
  import { resolveTicketByKey } from './shared.js';
22
- // ---------------------------------------------------------------------------
23
- // orboto_timer_start
24
- // ---------------------------------------------------------------------------
25
7
  export const timerStartToolConfig = {
26
8
  title: 'Start a timer on a ticket',
27
9
  description: 'Start the user\'s stopwatch on a ticket. If a timer is already running on a different ticket, pass replace=true to auto-stop the previous one (its elapsed time is committed to time_entries before the new timer begins). Description becomes the time entry\'s note when this timer eventually stops.',
@@ -55,9 +37,6 @@ export function makeTimerStartHandler(client) {
55
37
  };
56
38
  }
57
39
  catch (err) {
58
- // 409 = a timer is already running on a different ticket.
59
- // Surface the API's hint about `replace` rather than letting
60
- // the model guess.
61
40
  if (err instanceof OrbotoApiError && err.status === 409) {
62
41
  throw new Error('A timer is already running on a different ticket. Pass replace=true to commit its elapsed time and start fresh on this one.');
63
42
  }
@@ -65,9 +44,6 @@ export function makeTimerStartHandler(client) {
65
44
  }
66
45
  };
67
46
  }
68
- // ---------------------------------------------------------------------------
69
- // orboto_timer_stop
70
- // ---------------------------------------------------------------------------
71
47
  export const timerStopToolConfig = {
72
48
  title: 'Stop the running timer',
73
49
  description: 'Stop the user\'s stopwatch. The API commits a time_entries row using the description set at start (or pause/resume), then deletes the active-timer record. Returns the duration in minutes.',
@@ -77,10 +53,6 @@ export const timerStopToolConfig = {
77
53
  export function makeTimerStopHandler(client) {
78
54
  return async () => {
79
55
  try {
80
- // ORB-1603 - the API stop is idempotent now: nothing-to-stop returns
81
- // 200 { stopped:false } instead of a 404 (the 404 branch below stays
82
- // for older servers), and laneFallback flags that the timer was found
83
- // via the single-active-timer fallback after an instance-token mismatch.
84
56
  const res = await client.post('/time/timer/stop', {});
85
57
  if (res.stopped === false) {
86
58
  return {
@@ -107,23 +79,43 @@ export function makeTimerStopHandler(client) {
107
79
  }
108
80
  };
109
81
  }
110
- // ---------------------------------------------------------------------------
111
- // orboto_log_time
112
- // ---------------------------------------------------------------------------
113
82
  export const logTimeToolConfig = {
114
83
  title: 'Log a time entry on a ticket',
115
- description: 'Direct time-entry POST - for "I just spent 90 minutes on this last Tuesday but forgot to start a timer". `loggedAt` defaults to now; pass an ISO datetime to back-date.',
84
+ description: 'Direct time-entry POST - for "I just spent 90 minutes on this last Tuesday but forgot to start a timer". `loggedAt` defaults to now; pass an ISO datetime to back-date. Pass `dates` (YYYY-MM-DD list) to book the same entry on several days in one call (ORB-2056): all-or-nothing, a day inside an approved timesheet refuses the set and names the day, capacity warnings come back advisory.',
116
85
  inputSchema: z.object({
117
86
  ticketKey: z.string().min(3),
118
87
  durationMinutes: z.number().int().positive().describe('Minutes, > 0.'),
119
88
  description: z.string().optional(),
120
- loggedAt: z.string().datetime().optional().describe('ISO 8601. Default: now.'),
89
+ loggedAt: z.string().datetime().optional().describe('ISO 8601. Default: now. Ignored when `dates` is set.'),
90
+ dates: z.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).min(1).max(200).optional().describe('Book this entry on each of these days (YYYY-MM-DD).'),
91
+ time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional().describe('Wall-clock start for `dates`, workspace timezone. Default 09:00.'),
121
92
  }).shape,
122
93
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
123
94
  };
124
95
  export function makeLogTimeHandler(client) {
125
- return async ({ ticketKey, durationMinutes, description, loggedAt }) => {
96
+ return async ({ ticketKey, durationMinutes, description, loggedAt, dates, time }) => {
126
97
  const ticket = await resolveTicketByKey(client, ticketKey);
98
+ if (dates && dates.length > 0) {
99
+ const template = { ticketId: ticket.id, durationMinutes };
100
+ if (description !== undefined)
101
+ template.description = description;
102
+ if (time)
103
+ template.time = time;
104
+ const booked = await client.post('/time-entries/bulk', { template, dates });
105
+ const warn = booked.warnings.length ? ` - ${booked.warnings.length} capacity warning(s): ${booked.warnings.map((w) => `${w.date} ${w.kind}`).join(', ')}` : '';
106
+ return {
107
+ content: [{
108
+ type: 'text',
109
+ text: `Logged ${durationMinutes} min on [${ticket.ticketKey}] on ${booked.entries.length} day(s) (${booked.totalMinutes} min total)${warn}`,
110
+ }],
111
+ structuredContent: {
112
+ ticketKey: ticket.ticketKey,
113
+ entries: booked.entries.map((e) => ({ entryId: e.id, loggedAt: e.loggedAt, durationMinutes: e.durationMinutes })),
114
+ totalMinutes: booked.totalMinutes,
115
+ warnings: booked.warnings,
116
+ },
117
+ };
118
+ }
127
119
  const body = { durationMinutes };
128
120
  if (description !== undefined)
129
121
  body.description = description;
@@ -145,9 +137,6 @@ export function makeLogTimeHandler(client) {
145
137
  };
146
138
  };
147
139
  }
148
- // ---------------------------------------------------------------------------
149
- // orboto_list_time_entries (ORB-1292)
150
- // ---------------------------------------------------------------------------
151
140
  export const listTimeEntriesToolConfig = {
152
141
  title: 'List a ticket\'s time entries',
153
142
  description: 'List the time entries on a ticket (most recent first) with each entry\'s id, duration, date and agent label. Use this to FIND an over-tracked entry before fixing it with orboto_edit_time_entry / orboto_delete_time_entry.',
@@ -162,9 +151,6 @@ export function makeListTimeEntriesHandler(client) {
162
151
  const ticket = await resolveTicketByKey(client, ticketKey);
163
152
  const page = await client.get(`/tickets/${ticket.id}/time-entries?limit=${limit ?? 25}`);
164
153
  const lines = page.items.map((e) => {
165
- // ORB-1368 - agentLabel already flags a known agent instance; add a
166
- // bare [agent] marker for agent work with no instance label (a bot's
167
- // NULL-lane entry).
168
154
  const agentMark = e.agentLabel ? ` [${e.agentLabel}]` : (e.isAgentWork ? ' [agent]' : '');
169
155
  return `- ${e.durationMinutes} min - ${e.loggedAt}${agentMark}${e.description ? ` - ${e.description}` : ''} (id ${e.id})`;
170
156
  });
@@ -177,9 +163,6 @@ export function makeListTimeEntriesHandler(client) {
177
163
  };
178
164
  };
179
165
  }
180
- // ---------------------------------------------------------------------------
181
- // orboto_edit_time_entry (ORB-1292)
182
- // ---------------------------------------------------------------------------
183
166
  export const editTimeEntryToolConfig = {
184
167
  title: 'Edit / correct a time entry',
185
168
  description: 'Correct a time entry - fix an over-tracked duration, edit its note, change its date, or re-target it to another ticket. Use THIS (not orboto_log_time) to fix a wrong entry: log_time is additive and only makes the total worse. Get the entryId from orboto_list_time_entries (or an orboto_log_time response). Blocked if the entry is locked by an approved timesheet.',
@@ -214,9 +197,6 @@ export function makeEditTimeEntryHandler(client) {
214
197
  };
215
198
  };
216
199
  }
217
- // ---------------------------------------------------------------------------
218
- // orboto_delete_time_entry (ORB-1292)
219
- // ---------------------------------------------------------------------------
220
200
  export const deleteTimeEntryToolConfig = {
221
201
  title: 'Delete a time entry',
222
202
  description: 'Delete a time entry - e.g. a bogus over-tracked entry that should not exist at all (prefer orboto_edit_time_entry when it should just have a different duration). You can delete your own; deleting another user\'s needs the `time:delete_others` permission. Blocked if locked by an approved timesheet. Get the entryId from orboto_list_time_entries.',
@@ -101,6 +101,24 @@ describe('orboto_log_time', () => {
101
101
  expect(calls[2].body).toEqual({ durationMinutes: 30, loggedAt: '2026-04-20T10:00:00Z' });
102
102
  });
103
103
  });
104
+ describe('orboto_log_time with dates (ORB-2056)', () => {
105
+ it('books through POST /time-entries/bulk with a template and reports the total', async () => {
106
+ const calls = stub([
107
+ { json: PROJ },
108
+ { json: TICKET },
109
+ { json: { entries: [{ id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 120, description: 'sprint', loggedAt: '2026-06-01T07:00:00.000Z' }, { id: 'e2', ticketId: 't1', userId: 'u1', durationMinutes: 120, description: 'sprint', loggedAt: '2026-06-02T07:00:00.000Z' }], warnings: [{ kind: 'non_working_day', date: '2026-06-06', message: 'x' }], lockedDates: [], dryRun: false, totalMinutes: 240 } },
110
+ ]);
111
+ const out = await makeLogTimeHandler(client)({
112
+ ticketKey: 'ACME-1', durationMinutes: 120, description: 'sprint', dates: ['2026-06-01', '2026-06-02'], time: '09:00', loggedAt: '2026-01-01T00:00:00Z',
113
+ });
114
+ expect(calls[2].method).toBe('POST');
115
+ expect(calls[2].url).toContain('/time-entries/bulk');
116
+ expect(calls[2].body).toEqual({ template: { ticketId: 't1', durationMinutes: 120, description: 'sprint', time: '09:00' }, dates: ['2026-06-01', '2026-06-02'] });
117
+ expect(out.structuredContent.totalMinutes).toBe(240);
118
+ expect(out.content[0].text).toContain('2 day(s)');
119
+ expect(out.content[0].text).toContain('capacity warning');
120
+ });
121
+ });
104
122
  describe('orboto_list_time_entries / edit / delete (ORB-1292)', () => {
105
123
  it('list GETs the ticket time entries and returns them structured', async () => {
106
124
  const calls = stub([