@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,28 +1,12 @@
1
1
  /**
2
2
  * ORB-1694 - bulk create + bulk dependency writes.
3
3
  *
4
- * Measured over 32 transcripts: `orboto_create_ticket` ran in 48
5
- * consecutive-call clusters (longest 49 in a row) and
6
- * `orboto_add_ticket_dependency` in 13 (longest 27) - the two longest
7
- * runs in the whole tool corpus, each call paying a full round trip
8
- * plus a ~1.1k-character response into the calling agent's context.
9
- * These two tools collapse a milestone-planning session into one call
10
- * with ONE compact aggregated response.
11
- *
12
- * Same family contract as bulk-writes.ts: serial per-item against the
13
- * existing single-item REST endpoints (per-tenant rate-limit posture),
14
- * per-item error reporting - one bad item never drops the rest - and a
15
- * `{successful, failed}` outcome the model can branch on. Duplicate
16
- * findings are reported COMPACTLY: one line per flagged draft, never
17
- * the single-tool's full warning block (pairs with ORB-1693).
4
+ * @see ORB-1693
18
5
  */
19
6
  import { z } from 'zod';
20
7
  import { OrbotoApiError } from '../orboto-client.js';
21
8
  import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
22
9
  import { resolveMilestoneByNameOrId } from './milestones.js';
23
- // ---------------------------------------------------------------------------
24
- // orboto_bulk_create_tickets
25
- // ---------------------------------------------------------------------------
26
10
  const TicketDraftSchema = z.object({
27
11
  title: z.string().min(1).max(255),
28
12
  description: z.string().optional(),
@@ -60,8 +44,6 @@ export const bulkCreateTicketsToolConfig = {
60
44
  export function makeBulkCreateTicketsHandler(client) {
61
45
  return async (input) => {
62
46
  const project = await resolveProjectByKey(client, input.projectKey);
63
- // Resolve shared + per-draft milestone/parent references ONCE per
64
- // distinct value - 20 drafts on one milestone = one lookup.
65
47
  const milestoneIds = new Map();
66
48
  const resolveMilestone = async (ref) => {
67
49
  const cached = milestoneIds.get(ref);
@@ -147,9 +129,6 @@ export function makeBulkCreateTicketsHandler(client) {
147
129
  };
148
130
  };
149
131
  }
150
- // ---------------------------------------------------------------------------
151
- // orboto_bulk_add_ticket_dependencies
152
- // ---------------------------------------------------------------------------
153
132
  export const bulkAddTicketDependenciesToolConfig = {
154
133
  title: 'Add many ticket dependencies in one call',
155
134
  description: 'Create up to 200 blocked-by edges in one call - use instead of repeated orboto_add_ticket_dependency whenever wiring more than ~3 edges (dependency graphs after a bulk create). Each pair is (ticketKey, dependsOnKey) = "ticket is blocked by dependsOn". Per-pair error isolation; an already-existing edge counts as ok, a cycle rejection lands in `failed` with the API\'s reason.',
@@ -167,7 +146,6 @@ export const bulkAddTicketDependenciesToolConfig = {
167
146
  };
168
147
  export function makeBulkAddTicketDependenciesHandler(client) {
169
148
  return async (input) => {
170
- // Resolve every distinct key once, not once per edge.
171
149
  const resolved = new Map();
172
150
  const resolve = async (key) => {
173
151
  const cached = resolved.get(key);
@@ -188,8 +166,6 @@ export function makeBulkAddTicketDependenciesHandler(client) {
188
166
  await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
189
167
  }
190
168
  catch (err) {
191
- // 409 = edge already exists - idempotent success, same as the
192
- // single-edge tool.
193
169
  if (!(err instanceof OrbotoApiError && err.status === 409))
194
170
  throw err;
195
171
  }
@@ -64,9 +64,7 @@ describe('orboto_bulk_create_tickets', () => {
64
64
  const text = result.content[0].text;
65
65
  expect(text.length).toBeLessThan(2000);
66
66
  expect(JSON.stringify(result.structuredContent).length).toBeLessThan(2000);
67
- // Milestone resolved once, not once per draft.
68
67
  expect(calls.filter((c) => c.url.includes('/milestones')).length).toBe(1);
69
- // Every create carried the resolved milestone id.
70
68
  const creates = calls.filter((c) => c.method === 'POST' && c.url.includes('/tickets'));
71
69
  expect(creates).toHaveLength(20);
72
70
  expect(creates.every((c) => c.body.milestoneId === 'ms-1')).toBe(true);
@@ -127,9 +125,7 @@ describe('orboto_bulk_add_ticket_dependencies', () => {
127
125
  expect(sc.successful).toEqual(['ACME-2->ACME-1', 'ACME-3->ACME-2']);
128
126
  expect(sc.failed).toHaveLength(1);
129
127
  expect(sc.failed[0].pair).toBe('ACME-404->ACME-1');
130
- // Distinct keys resolved once each (ACME-1, ACME-2, ACME-3, ACME-404).
131
128
  expect(calls.filter((c) => c.url.includes('/tickets/by-key/')).length).toBe(4);
132
- // 409 on the first edge write still counted as successful (idempotent).
133
129
  expect(calls.filter((c) => c.method === 'POST' && c.url.includes('/dependencies')).length).toBe(2);
134
130
  });
135
131
  });
@@ -1,27 +1,7 @@
1
1
  /**
2
2
  * ORB-799 - bulk-* writes.
3
3
  *
4
- * The wrapper's `bulk-*` family is one of its most-used clusters, used
5
- * in every phase-cleanup pass. MCP-side we collapse the wrapper's
6
- * `--from=- | @file | "ORB-1,ORB-2"` UX into a single typed `ticketKeys:
7
- * string[]` array - the calling agent already has the list as JSON, so
8
- * the wrapper's stdin/file-roundtrip is a regression there.
9
- *
10
- * Each tool:
11
- * - Takes `ticketKeys: string[]` (1..200 keys) + tool-specific params
12
- * - Has a `dryRun: boolean` modifier - when true, resolves every key
13
- * to verify visibility + permission but does NOT issue the
14
- * mutating call. Returns the same outcome shape with a
15
- * `dryRun: true` marker so the caller can preview.
16
- * - Returns `{ successful: [...], failed: [{ticketKey, error}], skipped: [...] }`
17
- * so the model can branch on partial failures instead of parsing
18
- * stderr progress lines like the wrapper does.
19
- *
20
- * Concurrency: serial per-ticket. Bulk operations land on a tenant DB
21
- * that other operators may be writing concurrently; spraying parallel
22
- * PATCHes against the same tickets is the kind of thing tenant rate
23
- * limits exist to slow down. A 200-ticket bulk-close at ~80ms/ticket
24
- * is ~16s - acceptable.
4
+ * @see ORB-1, ORB-2
25
5
  */
26
6
  import { z } from 'zod';
27
7
  import { OrbotoApiError } from '../orboto-client.js';
@@ -34,8 +14,44 @@ const CATEGORY_TO_LEGACY = {
34
14
  done: 'DONE',
35
15
  wont_fix: 'WONT_FIX',
36
16
  };
37
- const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200)
38
- .describe('Ticket keys (e.g. ["ACME-1", "ACME-2"]). Capped at 200 per call to keep the per-tenant rate-limit budget reasonable.');
17
+ const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200).optional()
18
+ .describe('Ticket keys (e.g. ["ACME-1", "ACME-2"]). Capped at 200 per call to keep the per-tenant rate-limit budget reasonable. Give either ticketKeys or query.');
19
+ /** ORB-2054 - the target set can be an OQL query instead of a key list. */
20
+ const QUERY_SOURCE = z.string().min(1).max(8000).optional()
21
+ .describe('OQL query selecting the tickets instead of ticketKeys (same grammar as orboto_query, caller ACL applies). At most 200 matches per call - narrow it or use POST /projects/{id}/tickets/bulk with `query` for a server-side run.');
22
+ const QUERY_PROJECT = z.string().optional()
23
+ .describe('Project key that scopes `query` (prepended as project = KEY).');
24
+ /** Matches per call the MCP loop will process; above that the server-side bulk route is the right tool. */
25
+ export const BULK_QUERY_MAX = 200;
26
+ /** Resolve `query` to ticket keys through POST /query, paging until the cap. */
27
+ export async function resolveQueryKeys(client, query, projectKey) {
28
+ const oql = projectKey ? `project = ${projectKey} AND (${query})` : query;
29
+ const keys = [];
30
+ let cursor;
31
+ for (;;) {
32
+ const page = await client.post('/query', {
33
+ oql, syntax: 'oql', limit: 100, ...(cursor ? { cursor } : {}),
34
+ });
35
+ for (const t of page.items)
36
+ keys.push(t.ticketKey ?? t.id);
37
+ if (keys.length > BULK_QUERY_MAX) {
38
+ throw new Error(`The query matches more than ${BULK_QUERY_MAX} tickets - narrow it, or call POST /projects/{id}/tickets/bulk with \`query\` (dryRun first) for a server-side run.`);
39
+ }
40
+ if (!page.nextCursor)
41
+ break;
42
+ cursor = page.nextCursor;
43
+ }
44
+ if (keys.length === 0)
45
+ throw new Error('The query matched no tickets.');
46
+ return keys;
47
+ }
48
+ /** One of ticketKeys / query, resolved to the key list the loops iterate. */
49
+ async function targetKeys(client, input) {
50
+ if ((input.ticketKeys ? 1 : 0) + (input.query ? 1 : 0) !== 1) {
51
+ throw new Error('Give exactly one of ticketKeys or query.');
52
+ }
53
+ return input.query ? resolveQueryKeys(client, input.query, input.projectKey) : input.ticketKeys;
54
+ }
39
55
  function emptyOutcome(dryRun) {
40
56
  return { successful: [], failed: [], skipped: [], dryRun };
41
57
  }
@@ -81,14 +97,13 @@ async function resolveBatch(client, ticketKeys, outcome) {
81
97
  }
82
98
  return resolved;
83
99
  }
84
- // ---------------------------------------------------------------------------
85
- // orboto_bulk_patch_tickets
86
- // ---------------------------------------------------------------------------
87
100
  export const bulkPatchTicketsToolConfig = {
88
101
  title: 'Apply the same patch to many tickets',
89
- description: 'PATCH every ticket in `ticketKeys` with the same `patch` body. The patch shape mirrors `orboto_update_ticket`: title, description, type, priority, dueDate, startDate, isPrivate, estimatedTimeMinutes. To move many tickets between status categories, use `orboto_bulk_move_tickets` instead. Returns `{successful, failed, skipped, dryRun}` so the caller can branch on partial failures.',
102
+ description: 'PATCH every ticket in `ticketKeys` (or the tickets `query` selects) with the same `patch` body. The patch shape mirrors `orboto_update_ticket`: title, description, type, priority, dueDate, startDate, isPrivate, estimatedTimeMinutes. To move many tickets between status categories, use `orboto_bulk_move_tickets` instead. Returns `{successful, failed, skipped, dryRun}` so the caller can branch on partial failures.',
90
103
  inputSchema: z.object({
91
104
  ticketKeys: TICKET_KEY_ARRAY,
105
+ query: QUERY_SOURCE,
106
+ projectKey: QUERY_PROJECT,
92
107
  patch: z.object({
93
108
  title: z.string().min(1).max(255).optional(),
94
109
  description: z.string().optional(),
@@ -101,15 +116,12 @@ export const bulkPatchTicketsToolConfig = {
101
116
  }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
102
117
  dryRun: z.boolean().optional().describe('Resolve every ticket to verify visibility/permission, but skip the actual PATCH.'),
103
118
  }).shape,
104
- // ORB-1669 - the mutating bulk_* tools are destructive by blast radius:
105
- // they overwrite state across N tickets in one call with no undo. The
106
- // purely additive ones (bulk_comment, bulk_assign) are not.
107
119
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
108
120
  };
109
121
  export function makeBulkPatchTicketsHandler(client) {
110
- return async ({ ticketKeys, patch, dryRun }) => {
122
+ return async ({ ticketKeys, query, projectKey, patch, dryRun }) => {
111
123
  const outcome = emptyOutcome(dryRun === true);
112
- const resolved = await resolveBatch(client, ticketKeys, outcome);
124
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
113
125
  for (const [k, t] of resolved) {
114
126
  if (dryRun) {
115
127
  outcome.skipped.push(k);
@@ -126,23 +138,22 @@ export function makeBulkPatchTicketsHandler(client) {
126
138
  return bulkResult(`bulk_patch (${Object.keys(patch).join(',')})`, outcome);
127
139
  };
128
140
  }
129
- // ---------------------------------------------------------------------------
130
- // orboto_bulk_move_tickets
131
- // ---------------------------------------------------------------------------
132
141
  export const bulkMoveTicketsToolConfig = {
133
142
  title: 'Move many tickets to a status category',
134
- description: 'Move every ticket in `ticketKeys` to the same status category (todo / in_progress / in_review / done / wont_fix). Each ticket lands on its own project\'s first status with that category. Returns the per-ticket outcome.',
143
+ description: 'Move every ticket in `ticketKeys` (or the tickets `query` selects) to the same status category (todo / in_progress / in_review / done / wont_fix). Each ticket lands on its own project\'s first status with that category. Returns the per-ticket outcome.',
135
144
  inputSchema: z.object({
136
145
  ticketKeys: TICKET_KEY_ARRAY,
146
+ query: QUERY_SOURCE,
147
+ projectKey: QUERY_PROJECT,
137
148
  statusCategory: z.enum(STATUS_CATEGORIES),
138
149
  dryRun: z.boolean().optional(),
139
150
  }).shape,
140
151
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
141
152
  };
142
153
  export function makeBulkMoveTicketsHandler(client) {
143
- return async ({ ticketKeys, statusCategory, dryRun }) => {
154
+ return async ({ ticketKeys, query, projectKey, statusCategory, dryRun }) => {
144
155
  const outcome = emptyOutcome(dryRun === true);
145
- const resolved = await resolveBatch(client, ticketKeys, outcome);
156
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
146
157
  const legacy = CATEGORY_TO_LEGACY[statusCategory];
147
158
  for (const [k, t] of resolved) {
148
159
  if (dryRun) {
@@ -160,23 +171,22 @@ export function makeBulkMoveTicketsHandler(client) {
160
171
  return bulkResult(`bulk_move → ${statusCategory}`, outcome);
161
172
  };
162
173
  }
163
- // ---------------------------------------------------------------------------
164
- // orboto_bulk_close_tickets
165
- // ---------------------------------------------------------------------------
166
174
  export const bulkCloseTicketsToolConfig = {
167
175
  title: 'Close many tickets (optionally with a shared comment)',
168
- description: 'For every ticket in `ticketKeys`: optionally post the same `comment` first, then move it to `done`. Mirrors `orboto.mjs bulk-close`. Comment-first ordering means the close note lands in the audit trail even if the status PATCH 403s.',
176
+ description: 'For every ticket in `ticketKeys` (or the tickets `query` selects): optionally post the same `comment` first, then move it to `done`. Mirrors `orboto.mjs bulk-close`. Comment-first ordering means the close note lands in the audit trail even if the status PATCH 403s.',
169
177
  inputSchema: z.object({
170
178
  ticketKeys: TICKET_KEY_ARRAY,
179
+ query: QUERY_SOURCE,
180
+ projectKey: QUERY_PROJECT,
171
181
  comment: z.string().min(1).optional().describe('Optional shared closing comment posted on each ticket before its status move.'),
172
182
  dryRun: z.boolean().optional(),
173
183
  }).shape,
174
184
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
175
185
  };
176
186
  export function makeBulkCloseTicketsHandler(client) {
177
- return async ({ ticketKeys, comment, dryRun }) => {
187
+ return async ({ ticketKeys, query, projectKey, comment, dryRun }) => {
178
188
  const outcome = emptyOutcome(dryRun === true);
179
- const resolved = await resolveBatch(client, ticketKeys, outcome);
189
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
180
190
  for (const [k, t] of resolved) {
181
191
  if (dryRun) {
182
192
  outcome.skipped.push(k);
@@ -196,14 +206,13 @@ export function makeBulkCloseTicketsHandler(client) {
196
206
  return bulkResult(comment ? 'bulk_close + comment' : 'bulk_close', outcome);
197
207
  };
198
208
  }
199
- // ---------------------------------------------------------------------------
200
- // orboto_bulk_comment_tickets
201
- // ---------------------------------------------------------------------------
202
209
  export const bulkCommentTicketsToolConfig = {
203
210
  title: 'Post the same comment on many tickets',
204
- description: 'Append the same comment body to every ticket in `ticketKeys`. Useful for "I am back-propagating decision X to all affected tickets" workflows. `isInternal=true` hides from external/guest users.',
211
+ description: 'Append the same comment body to every ticket in `ticketKeys` (or the tickets `query` selects). Useful for "I am back-propagating decision X to all affected tickets" workflows. `isInternal=true` hides from external/guest users.',
205
212
  inputSchema: z.object({
206
213
  ticketKeys: TICKET_KEY_ARRAY,
214
+ query: QUERY_SOURCE,
215
+ projectKey: QUERY_PROJECT,
207
216
  text: z.string().min(1),
208
217
  isInternal: z.boolean().optional(),
209
218
  dryRun: z.boolean().optional(),
@@ -211,9 +220,9 @@ export const bulkCommentTicketsToolConfig = {
211
220
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
212
221
  };
213
222
  export function makeBulkCommentTicketsHandler(client) {
214
- return async ({ ticketKeys, text, isInternal, dryRun }) => {
223
+ return async ({ ticketKeys, query, projectKey, text, isInternal, dryRun }) => {
215
224
  const outcome = emptyOutcome(dryRun === true);
216
- const resolved = await resolveBatch(client, ticketKeys, outcome);
225
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
217
226
  for (const [k, t] of resolved) {
218
227
  if (dryRun) {
219
228
  outcome.skipped.push(k);
@@ -233,9 +242,6 @@ export function makeBulkCommentTicketsHandler(client) {
233
242
  return bulkResult('bulk_comment', outcome);
234
243
  };
235
244
  }
236
- // ---------------------------------------------------------------------------
237
- // orboto_bulk_assign_tickets / orboto_bulk_unassign_tickets
238
- // ---------------------------------------------------------------------------
239
245
  /** Resolve email → userId once per project. The bulk operation may
240
246
  * touch tickets across multiple projects; we memoize the lookup so we
241
247
  * don't refetch the members list for every ticket. */
@@ -255,18 +261,20 @@ async function makeAssigneeResolver(client, email) {
255
261
  }
256
262
  export const bulkAssignTicketsToolConfig = {
257
263
  title: 'Assign the same user to many tickets',
258
- description: 'POST `assigneeEmail` as an additional assignee on every ticket in `ticketKeys`. Multi-assignee is supported - this adds, it does not replace. Idempotent: a 409 (already assigned) counts as success.',
264
+ description: 'POST `assigneeEmail` as an additional assignee on every ticket in `ticketKeys` (or the tickets `query` selects). Multi-assignee is supported - this adds, it does not replace. Idempotent: a 409 (already assigned) counts as success.',
259
265
  inputSchema: z.object({
260
266
  ticketKeys: TICKET_KEY_ARRAY,
267
+ query: QUERY_SOURCE,
268
+ projectKey: QUERY_PROJECT,
261
269
  assigneeEmail: z.string().email(),
262
270
  dryRun: z.boolean().optional(),
263
271
  }).shape,
264
272
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
265
273
  };
266
274
  export function makeBulkAssignTicketsHandler(client) {
267
- return async ({ ticketKeys, assigneeEmail, dryRun }) => {
275
+ return async ({ ticketKeys, query, projectKey, assigneeEmail, dryRun }) => {
268
276
  const outcome = emptyOutcome(dryRun === true);
269
- const resolved = await resolveBatch(client, ticketKeys, outcome);
277
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
270
278
  const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
271
279
  for (const [k, t] of resolved) {
272
280
  if (dryRun) {
@@ -280,7 +288,6 @@ export function makeBulkAssignTicketsHandler(client) {
280
288
  }
281
289
  catch (err) {
282
290
  if (err instanceof OrbotoApiError && err.status === 409) {
283
- // Already assigned → idempotent success.
284
291
  outcome.successful.push(k);
285
292
  continue;
286
293
  }
@@ -297,18 +304,20 @@ export function makeBulkAssignTicketsHandler(client) {
297
304
  }
298
305
  export const bulkUnassignTicketsToolConfig = {
299
306
  title: 'Unassign the same user from many tickets',
300
- description: 'DELETE `assigneeEmail` from every ticket in `ticketKeys`. Idempotent: a 404 (was not assigned) counts as success.',
307
+ description: 'DELETE `assigneeEmail` from every ticket in `ticketKeys` (or the tickets `query` selects). Idempotent: a 404 (was not assigned) counts as success.',
301
308
  inputSchema: z.object({
302
309
  ticketKeys: TICKET_KEY_ARRAY,
310
+ query: QUERY_SOURCE,
311
+ projectKey: QUERY_PROJECT,
303
312
  assigneeEmail: z.string().email(),
304
313
  dryRun: z.boolean().optional(),
305
314
  }).shape,
306
315
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
307
316
  };
308
317
  export function makeBulkUnassignTicketsHandler(client) {
309
- return async ({ ticketKeys, assigneeEmail, dryRun }) => {
318
+ return async ({ ticketKeys, query, projectKey, assigneeEmail, dryRun }) => {
310
319
  const outcome = emptyOutcome(dryRun === true);
311
- const resolved = await resolveBatch(client, ticketKeys, outcome);
320
+ const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
312
321
  const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
313
322
  for (const [k, t] of resolved) {
314
323
  if (dryRun) {
@@ -322,7 +331,6 @@ export function makeBulkUnassignTicketsHandler(client) {
322
331
  }
323
332
  catch (err) {
324
333
  if (err instanceof OrbotoApiError && err.status === 404) {
325
- // Wasn't assigned → idempotent success.
326
334
  outcome.successful.push(k);
327
335
  continue;
328
336
  }
@@ -43,8 +43,6 @@ function ticket(n) {
43
43
  type: 'task', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
44
44
  };
45
45
  }
46
- // Resolution responses for keys ACME-1, ACME-2 (project lookup is shared
47
- // - but the client re-fetches each time. We supply one per key.)
48
46
  function resolveOK(n) {
49
47
  return [
50
48
  { json: PROJ },
@@ -154,7 +152,6 @@ describe('orboto_bulk_close_tickets', () => {
154
152
  const patchCall = calls.find((c) => c.method === 'PATCH');
155
153
  expect(commentCall).toBeDefined();
156
154
  expect(patchCall?.body).toEqual({ status: 'DONE' });
157
- // Comment must be BEFORE the patch.
158
155
  expect(calls.indexOf(commentCall)).toBeLessThan(calls.indexOf(patchCall));
159
156
  });
160
157
  it('without comment, only PATCHes status=DONE', async () => {
@@ -197,7 +194,6 @@ describe('orboto_bulk_assign_tickets', () => {
197
194
  ticketKeys: ['ACME-1', 'ACME-2'],
198
195
  assigneeEmail: 'who@orboto.io',
199
196
  });
200
- // Members fetched exactly once thanks to the per-project cache.
201
197
  expect(calls.filter((c) => c.url.includes('/members'))).toHaveLength(1);
202
198
  expect(res.structuredContent).toMatchObject({
203
199
  successful: ['ACME-1', 'ACME-2'],
@@ -237,3 +233,37 @@ describe('orboto_bulk_unassign_tickets', () => {
237
233
  });
238
234
  });
239
235
  });
236
+ describe('query-driven bulk targets', () => {
237
+ it('resolves the query through POST /query (paged) and then patches each match', async () => {
238
+ const calls = stub([
239
+ { json: { items: [ticket(1)], nextCursor: 'c2' } },
240
+ { json: { items: [ticket(2)], nextCursor: null } },
241
+ ...resolveOK(1),
242
+ ...resolveOK(2),
243
+ { json: ticket(1) },
244
+ { json: ticket(2) },
245
+ ]);
246
+ const res = await makeBulkPatchTicketsHandler(client)({
247
+ query: 'status = todo AND labels = "triage"',
248
+ projectKey: 'ACME',
249
+ patch: { priority: 'high' },
250
+ });
251
+ const queryCalls = calls.filter((c) => c.url.endsWith('/query'));
252
+ expect(queryCalls).toHaveLength(2);
253
+ expect(queryCalls[0].body).toMatchObject({ oql: 'project = ACME AND (status = todo AND labels = "triage")', limit: 100 });
254
+ expect(queryCalls[1].body).toMatchObject({ cursor: 'c2' });
255
+ expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(2);
256
+ expect(res.structuredContent).toMatchObject({ successful: ['ACME-1', 'ACME-2'], failed: [] });
257
+ });
258
+ it('refuses both or neither source, an empty match, and more than the per-call cap', async () => {
259
+ await expect(makeBulkMoveTicketsHandler(client)({ ticketKeys: ['ACME-1'], query: 'x', statusCategory: 'done' }))
260
+ .rejects.toThrow(/exactly one/);
261
+ await expect(makeBulkMoveTicketsHandler(client)({ statusCategory: 'done' })).rejects.toThrow(/exactly one/);
262
+ stub([{ json: { items: [], nextCursor: null } }]);
263
+ await expect(makeBulkCloseTicketsHandler(client)({ query: 'status = todo' })).rejects.toThrow(/matched no tickets/);
264
+ stub([{ json: { items: Array.from({ length: 100 }, (_, i) => ticket(i + 1)), nextCursor: 'c2' } },
265
+ { json: { items: Array.from({ length: 100 }, (_, i) => ticket(i + 101)), nextCursor: 'c3' } },
266
+ { json: { items: [ticket(201)], nextCursor: null } }]);
267
+ await expect(makeBulkCommentTicketsHandler(client)({ query: 'status = todo', text: 'hi' })).rejects.toThrow(/more than 200/);
268
+ });
269
+ });
@@ -1,17 +1,5 @@
1
1
  /**
2
2
  * ORB-831 / ORB-887 - `orboto_check_similar`.
3
- *
4
- * Dry-run sibling of `orboto_create_ticket`: takes a proposed title +
5
- * description and returns the tickets that would land in
6
- * `similarWarnings` if the create were to happen now. Intended as the
7
- * cautious agent's pre-create probe - call this first when the task
8
- * scope feels close to existing work, decide whether to follow up on
9
- * the existing ticket instead, then either commit (`orboto_create_ticket`)
10
- * or pivot.
11
- *
12
- * Wraps the existing `GET /projects/:id/tickets/similar` route so the
13
- * matching pipeline (tsvector + optional embedding rerank) is shared
14
- * with both the UI new-ticket form and the POST-create safety-net.
15
3
  */
16
4
  import { z } from 'zod';
17
5
  import { trimSimilarEntries } from './similar-projection.js';
@@ -38,7 +26,6 @@ export function makeCheckSimilarHandler(client) {
38
26
  });
39
27
  if (description)
40
28
  qs.set('description', description);
41
- // ORB-1604 - hierarchy-aware classification inputs.
42
29
  if (parentTicketKey) {
43
30
  const parent = await resolveTicketByKey(client, parentTicketKey);
44
31
  qs.set('parentTicketId', parent.id);
@@ -46,9 +33,6 @@ export function makeCheckSimilarHandler(client) {
46
33
  if (forType)
47
34
  qs.set('forType', forType);
48
35
  const result = await client.get(`/projects/${project.id}/tickets/similar?${qs.toString()}`);
49
- // ORB-1604 - only relation-free candidates are duplicate signals; the
50
- // parent, siblings and epics are related context (they drove the 70%
51
- // --allow-duplicate override rate in the field).
52
36
  const realDuplicates = result.candidates.filter((c) => !c.relation);
53
37
  const related = result.candidates.filter((c) => c.relation);
54
38
  const recommendation = realDuplicates.length === 0
@@ -74,7 +58,6 @@ export function makeCheckSimilarHandler(client) {
74
58
  return {
75
59
  content: [{ type: 'text', text }],
76
60
  structuredContent: {
77
- // ORB-1693 - same agent projection as create_ticket's warnings.
78
61
  similar: trimSimilarEntries(result.candidates),
79
62
  mode: result.mode,
80
63
  recommendation,
@@ -1,21 +1,33 @@
1
1
  /**
2
2
  * ORB-244 Phase C Group 3 - checklist write tools (ORB-234).
3
3
  *
4
- * Four tools:
5
- * - orboto_check / orboto_uncheck - toggle a single item's `isCompleted`
6
- * - orboto_add_check - append a new item to a list (default: first list
7
- * on the ticket)
8
- * - orboto_new_checklist - create a fresh list with optional triggers-done
9
- *
10
- * Item identifier: agents prefer 1-based indexes ("check item 3 on
11
- * ACME-42") because UUIDs are ergonomic disasters in a chat. The
12
- * helper resolves either form by reading the ticket's checklists
13
- * once. UUID input is honoured directly so callers that already have
14
- * the ID can skip the lookup.
4
+ * @see ORB-235
15
5
  */
16
6
  import { z } from 'zod';
17
7
  import { OrbotoApiError } from '../orboto-client.js';
18
8
  import { resolveTicketByKey } from './shared.js';
9
+ const ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
10
+ /**
11
+ * ORB-235 - resolve an assignee reference (user UUID, email, or full name)
12
+ * to a member of the ticket's project. Names the members on a miss so the
13
+ * agent can pick instead of guessing.
14
+ */
15
+ async function resolveMemberId(client, projectId, ref) {
16
+ const members = await client.get(`/projects/${projectId}/members`);
17
+ if (UUID_RE.test(ref) && members.some((m) => m.userId === ref))
18
+ return ref;
19
+ const needle = ref.trim().toLowerCase();
20
+ const hit = members.find((m) => m.user.email.toLowerCase() === needle || m.user.fullName.toLowerCase() === needle);
21
+ if (!hit) {
22
+ throw new Error(`"${ref}" is not a member of this project. Members: ${members.map((m) => `${m.user.fullName} <${m.user.email}>`).join(', ') || '(none)'}`);
23
+ }
24
+ return hit.userId;
25
+ }
26
+ function assertIsoDay(value) {
27
+ if (!ISO_DAY_RE.test(value))
28
+ throw new Error(`dueDate must be YYYY-MM-DD, got "${value}".`);
29
+ return value;
30
+ }
19
31
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
32
  /**
21
33
  * Resolve an item identifier (1-based index OR UUID) to its UUID.
@@ -39,9 +51,6 @@ async function resolveItemId(client, ticketId, identifier) {
39
51
  }
40
52
  return { itemId: target.id, checklists };
41
53
  }
42
- // ---------------------------------------------------------------------------
43
- // orboto_check / orboto_uncheck
44
- // ---------------------------------------------------------------------------
45
54
  function makeToggleHandler(client, completed) {
46
55
  return async ({ ticketKey, item }) => {
47
56
  const ticket = await resolveTicketByKey(client, ticketKey);
@@ -83,10 +92,6 @@ export const uncheckToolConfig = {
83
92
  };
84
93
  export const makeCheckHandler = (client) => makeToggleHandler(client, true);
85
94
  export const makeUncheckHandler = (client) => makeToggleHandler(client, false);
86
- // ---------------------------------------------------------------------------
87
- // orboto_remove_check (ORB-1095) - agent parity for DELETE
88
- // /checklist-items/:id. The surfaces could append but not remove.
89
- // ---------------------------------------------------------------------------
90
95
  export const removeCheckToolConfig = {
91
96
  title: 'Remove a checklist item',
92
97
  description: 'Delete a checklist item from a ticket. `item` is either a 1-based index (global across all checklists on the ticket, top to bottom) or the item\'s UUID - same semantics as orboto_check. Destructive: the item is gone, not just unchecked.',
@@ -105,22 +110,21 @@ export function makeRemoveCheckHandler(client) {
105
110
  };
106
111
  };
107
112
  }
108
- // ---------------------------------------------------------------------------
109
- // orboto_add_check
110
- // ---------------------------------------------------------------------------
111
113
  export const addCheckToolConfig = {
112
114
  title: 'Add a checklist item',
113
- description: 'Append an item to a checklist on a ticket. By default appends to the FIRST checklist on the ticket; pass `listTitle` to target a specific one. Pass `linkedTicketKey` to make the item track another ticket\'s status (the new item\'s `effectiveCompleted` mirrors that ticket\'s done-status from then on).',
115
+ description: 'Append an item to a checklist on a ticket. By default appends to the FIRST checklist on the ticket; pass `listTitle` to target a specific one. Pass `linkedTicketKey` to make the item track another ticket\'s status (the new item\'s `effectiveCompleted` mirrors that ticket\'s done-status from then on). `assignee` (a project member: email, full name or user id) and `dueDate` (YYYY-MM-DD) give the item its own owner and deadline - the assignee is notified, reminded the day before, and sees the item in their upcoming deadlines.',
114
116
  inputSchema: z.object({
115
117
  ticketKey: z.string().min(3),
116
118
  content: z.string().min(1).max(2000),
117
119
  listTitle: z.string().optional().describe('Target checklist title. Default: first checklist on the ticket.'),
118
120
  linkedTicketKey: z.string().optional().describe('Make this item track another ticket\'s done-status (e.g. "ACME-99").'),
121
+ assignee: z.string().optional().describe('Project member to own this item - email, full name or user id.'),
122
+ dueDate: z.string().optional().describe('Due date of this item, YYYY-MM-DD.'),
119
123
  }).shape,
120
124
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
121
125
  };
122
126
  export function makeAddCheckHandler(client) {
123
- return async ({ ticketKey, content, listTitle, linkedTicketKey }) => {
127
+ return async ({ ticketKey, content, listTitle, linkedTicketKey, assignee, dueDate }) => {
124
128
  const ticket = await resolveTicketByKey(client, ticketKey);
125
129
  const checklists = await client.get(`/tickets/${ticket.id}/checklists`);
126
130
  if (checklists.length === 0) {
@@ -137,11 +141,15 @@ export function makeAddCheckHandler(client) {
137
141
  const linked = await resolveTicketByKey(client, linkedTicketKey);
138
142
  body.linkedTicketId = linked.id;
139
143
  }
144
+ if (assignee)
145
+ body.assignedTo = await resolveMemberId(client, ticket.projectId, assignee);
146
+ if (dueDate)
147
+ body.dueDate = assertIsoDay(dueDate);
140
148
  const item = await client.post(`/tickets/${ticket.id}/checklists/${target.id}/items`, body);
141
149
  return {
142
150
  content: [{
143
151
  type: 'text',
144
- text: `Added to "${target.title}" on [${ticket.ticketKey}]: ${content}${linkedTicketKey ? ` ↪ [${linkedTicketKey}]` : ''}`,
152
+ text: `Added to "${target.title}" on [${ticket.ticketKey}]: ${content}${linkedTicketKey ? ` ↪ [${linkedTicketKey}]` : ''}${assignee ? ` @${assignee}` : ''}${dueDate ? ` due ${dueDate}` : ''}`,
145
153
  }],
146
154
  structuredContent: {
147
155
  ticketKey: ticket.ticketKey,
@@ -149,13 +157,60 @@ export function makeAddCheckHandler(client) {
149
157
  itemId: item.id,
150
158
  content: item.content,
151
159
  linkedTicketKey: linkedTicketKey ?? null,
160
+ assignedTo: body.assignedTo ?? null,
161
+ dueDate: body.dueDate ?? null,
162
+ },
163
+ };
164
+ };
165
+ }
166
+ export const updateCheckToolConfig = {
167
+ title: 'Update a checklist item',
168
+ description: 'Change a checklist item\'s text, assignee or due date. `item` is a 1-based index (global across all checklists on the ticket, top to bottom) or the item\'s UUID - same as orboto_check. `assignee` is a project member (email, full name or user id) or `null` to unassign; `dueDate` is YYYY-MM-DD or `null` to clear. A new assignee is notified; a new due date gets its own day-before reminder.',
169
+ inputSchema: z.object({
170
+ ticketKey: z.string().min(3),
171
+ item: z.union([
172
+ z.number().int().min(1).describe('1-based index, global across all checklists on this ticket'),
173
+ z.string().describe('Item UUID - for callers that already have it'),
174
+ ]),
175
+ content: z.string().min(1).max(2000).optional().describe('New item text.'),
176
+ assignee: z.string().nullable().optional().describe('Project member (email, full name or user id); null unassigns.'),
177
+ dueDate: z.string().nullable().optional().describe('YYYY-MM-DD; null clears the due date.'),
178
+ }).shape,
179
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
180
+ };
181
+ export function makeUpdateCheckHandler(client) {
182
+ return async ({ ticketKey, item, content, assignee, dueDate }) => {
183
+ const ticket = await resolveTicketByKey(client, ticketKey);
184
+ const { itemId, checklists } = await resolveItemId(client, ticket.id, item);
185
+ const before = checklists.flatMap((c) => c.items).find((i) => i.id === itemId);
186
+ const body = {};
187
+ if (content !== undefined)
188
+ body.content = content;
189
+ if (assignee !== undefined)
190
+ body.assignedTo = assignee === null ? null : await resolveMemberId(client, ticket.projectId, assignee);
191
+ if (dueDate !== undefined)
192
+ body.dueDate = dueDate === null ? null : assertIsoDay(dueDate);
193
+ if (Object.keys(body).length === 0)
194
+ throw new Error('Nothing to update - pass content, assignee and/or dueDate.');
195
+ const res = await client.patch(`/checklist-items/${itemId}`, body);
196
+ const after = res.item;
197
+ const parts = [
198
+ after.assigneeName ? `@${after.assigneeName}` : (assignee === null ? 'unassigned' : null),
199
+ after.dueDate ? `due ${after.dueDate}` : (dueDate === null ? 'no due date' : null),
200
+ ].filter(Boolean);
201
+ return {
202
+ content: [{ type: 'text', text: `Updated on [${ticket.ticketKey}]: ${after.content ?? before?.content ?? '(item)'}${parts.length ? ` (${parts.join(', ')})` : ''}` }],
203
+ structuredContent: {
204
+ ticketKey: ticket.ticketKey,
205
+ itemId,
206
+ content: after.content ?? null,
207
+ assignedTo: after.assignedTo ?? null,
208
+ assigneeName: after.assigneeName ?? null,
209
+ dueDate: after.dueDate ?? null,
152
210
  },
153
211
  };
154
212
  };
155
213
  }
156
- // ---------------------------------------------------------------------------
157
- // orboto_new_checklist
158
- // ---------------------------------------------------------------------------
159
214
  export const newChecklistToolConfig = {
160
215
  title: 'Create a new checklist on a ticket',
161
216
  description: 'Create a fresh checklist on a ticket. `triggersDone=true` gates the ticket\'s auto-done transition on this list (the ticket can\'t move to done until every item here is checked). Optional `items` seed the list with content in one call - saves a round-trip per item.',