@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
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { OrbotoClient } from '../orboto-client.js';
6
- import { makeCheckHandler, makeUncheckHandler, makeAddCheckHandler, makeNewChecklistHandler, makeRemoveCheckHandler, } from './checklist-writes.js';
6
+ import { makeCheckHandler, makeUncheckHandler, makeAddCheckHandler, makeNewChecklistHandler, makeRemoveCheckHandler, makeUpdateCheckHandler, } from './checklist-writes.js';
7
7
  beforeEach(() => { vi.restoreAllMocks(); });
8
8
  afterEach(() => { vi.restoreAllMocks(); });
9
9
  function stub(responses) {
@@ -182,3 +182,71 @@ describe('orboto_new_checklist', () => {
182
182
  await expect(makeNewChecklistHandler(client)({ ticketKey: 'ACME-1', title: 'x' })).rejects.toThrow(/ticket:edit/);
183
183
  });
184
184
  });
185
+ const MEMBERS = [
186
+ { userId: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e', user: { email: 'kim@example.test', fullName: 'Kim Novak' } },
187
+ { userId: 'u-ada', user: { email: 'ada@example.test', fullName: 'Ada Byron' } },
188
+ ];
189
+ describe('orboto_add_check with assignee + dueDate (ORB-235)', () => {
190
+ it('resolves the assignee by email against the project members and passes the due date through', async () => {
191
+ const calls = stub([
192
+ { json: PROJ },
193
+ { json: TICKET },
194
+ { json: [SAMPLE_LIST] },
195
+ { json: MEMBERS },
196
+ { json: { id: 'i3', content: 'Tag the release', assignedTo: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e', dueDate: '2026-10-01' } },
197
+ ]);
198
+ const res = await makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'Tag the release', assignee: 'kim@example.test', dueDate: '2026-10-01' });
199
+ expect(calls[4].body).toEqual({ content: 'Tag the release', assignedTo: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e', dueDate: '2026-10-01' });
200
+ expect(res.structuredContent).toMatchObject({ assignedTo: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e', dueDate: '2026-10-01' });
201
+ expect(res.content[0].text).toContain('due 2026-10-01');
202
+ });
203
+ it('names the members when the assignee is not one, and refuses a malformed date before any write', async () => {
204
+ stub([{ json: PROJ }, { json: TICKET }, { json: [SAMPLE_LIST] }, { json: MEMBERS }]);
205
+ await expect(makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'x', assignee: 'nobody@example.test' })).rejects.toThrow(/not a member.*Kim Novak <kim@example.test>/);
206
+ const calls = stub([{ json: PROJ }, { json: TICKET }, { json: [SAMPLE_LIST] }]);
207
+ await expect(makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'x', dueDate: 'next friday' })).rejects.toThrow(/dueDate must be YYYY-MM-DD/);
208
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
209
+ });
210
+ });
211
+ describe('orboto_update_check (ORB-235)', () => {
212
+ it('resolves the item by index, the assignee by full name, and PATCHes only what was given', async () => {
213
+ const calls = stub([
214
+ { json: PROJ },
215
+ { json: TICKET },
216
+ { json: [SAMPLE_LIST] },
217
+ { json: MEMBERS },
218
+ { json: { item: { id: 'i2', content: 'Docs', assignedTo: 'u-ada', assigneeName: 'Ada Byron', dueDate: '2026-10-02' } } },
219
+ ]);
220
+ const res = await makeUpdateCheckHandler(client)({ ticketKey: 'ACME-1', item: 2, assignee: 'Ada Byron', dueDate: '2026-10-02' });
221
+ expect(calls[4].method).toBe('PATCH');
222
+ expect(calls[4].url).toContain('/checklist-items/i2');
223
+ expect(calls[4].body).toEqual({ assignedTo: 'u-ada', dueDate: '2026-10-02' });
224
+ expect(res.structuredContent).toMatchObject({ itemId: 'i2', assigneeName: 'Ada Byron', dueDate: '2026-10-02' });
225
+ expect(res.content[0].text).toContain('@Ada Byron');
226
+ });
227
+ it('null unassigns and clears the date; a user id that is a member passes through unresolved', async () => {
228
+ const calls = stub([
229
+ { json: PROJ },
230
+ { json: TICKET },
231
+ { json: [SAMPLE_LIST] },
232
+ { json: { item: { id: 'i1', content: 'Tests', assignedTo: null, assigneeName: null, dueDate: null } } },
233
+ ]);
234
+ const res = await makeUpdateCheckHandler(client)({ ticketKey: 'ACME-1', item: 1, assignee: null, dueDate: null, content: 'Tests' });
235
+ expect(calls[3].body).toEqual({ content: 'Tests', assignedTo: null, dueDate: null });
236
+ expect(res.content[0].text).toContain('unassigned');
237
+ const byId = stub([
238
+ { json: PROJ },
239
+ { json: TICKET },
240
+ { json: [SAMPLE_LIST] },
241
+ { json: MEMBERS },
242
+ { json: { item: { id: 'i1', content: 'Tests', assignedTo: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e', assigneeName: 'Kim Novak', dueDate: null } } },
243
+ ]);
244
+ await makeUpdateCheckHandler(client)({ ticketKey: 'ACME-1', item: 1, assignee: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e' });
245
+ expect(byId[4].body).toEqual({ assignedTo: '4b3e2c1d-6f7a-4b8c-9d0e-1f2a3b4c5d6e' });
246
+ });
247
+ it('refuses an empty update without touching the API', async () => {
248
+ const calls = stub([{ json: PROJ }, { json: TICKET }, { json: [SAMPLE_LIST] }]);
249
+ await expect(makeUpdateCheckHandler(client)({ ticketKey: 'ACME-1', item: 1 })).rejects.toThrow(/Nothing to update/);
250
+ expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
251
+ });
252
+ });
@@ -1,45 +1,11 @@
1
1
  /**
2
2
  * ORB-799 - composite ticket-lifecycle tools.
3
3
  *
4
- * Two tools that bundle the most-used wrapper composites:
5
- *
6
- * - orboto_claim ≈ assign_self + move(in_progress) + timer_start
7
- * - orboto_unclaim ≈ unassign_self + move(todo)
8
- *
9
- * The composite-atomic behaviour is the real value here - these are
10
- * the most-typed wrapper commands by agents during ticket pickup. The
11
- * 1:1 mirror of `orboto.mjs claim` semantics matters:
12
- *
13
- * - `--sole` (`sole=true`) - destructive take-over: remove every
14
- * other assignee before adding self. Use sparingly; the API has
15
- * no atomic swap so this is a delete-loop + add.
16
- * - `--force` (`force=true`) - allow re-claiming a `done` ticket
17
- * (the wrapper refuses without it to prevent accidental reopen).
18
- * - `--no-timer` (`noTimer=true`) - skip the timer_start side
19
- * effect. Useful when the agent only wants ownership-by-assignee
20
- * without committing time.
21
- *
22
- * Both tools are **idempotent** on the happy paths:
23
- * - claim of a ticket where the caller is already an assignee and
24
- * statusCategory is already `in_progress` → no PATCH, no extra
25
- * assignee POST (the wrapper still POSTs once and 409s; we skip).
26
- * - unclaim of a ticket where the caller isn't an assignee → 404 on
27
- * DELETE is swallowed, status move still happens.
28
- *
29
- * Timer side-effects mirror ORB-179 (`claim`) + ORB-181 (`close`):
30
- * - If a different ticket has an active timer, stop it first
31
- * (commits the elapsed time-entry under the previous ticket's
32
- * description), then start a fresh timer on this one.
33
- * - Timer failures never roll back the assign/status work - we
34
- * surface them as a `timerWarning` field on the structured
35
- * response, same shape as the wrapper.
4
+ * @see ORB-179, ORB-181
36
5
  */
37
6
  import { z } from 'zod';
38
7
  import { OrbotoApiError } from '../orboto-client.js';
39
8
  import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
40
- // `statusCategory` is the discriminator the API exposes on every
41
- // ticket-read response; we map it (rather than `status` legacy enum)
42
- // because that's what the wrapper inspects too.
43
9
  const CATEGORY_TO_LEGACY = {
44
10
  todo: 'TODO',
45
11
  in_progress: 'IN_PROGRESS',
@@ -47,14 +13,9 @@ const CATEGORY_TO_LEGACY = {
47
13
  done: 'DONE',
48
14
  wont_fix: 'WONT_FIX',
49
15
  };
50
- // ---------------------------------------------------------------------------
51
- // orboto_claim
52
- // ---------------------------------------------------------------------------
53
16
  export const claimToolConfig = {
54
17
  title: 'Claim a ticket (assign self + in_progress + timer)',
55
18
  description: 'Composite of `assign self → move to in_progress → start timer` - the canonical "I am picking this up now" move. Idempotent: re-claiming an already-claimed in_progress ticket is a no-op. Set `sole=true` to remove every other assignee first (destructive take-over). Set `force=true` to allow re-claiming a `done` ticket (otherwise refuses, to prevent accidental reopens). Set `noTimer=true` to skip the timer start (e.g. when you only want ownership, not time tracking). If a different ticket has an active timer, it is stopped first (its elapsed time commits a time entry under the previous ticket). '
56
- // ORB-1805 - moved out of the input schema, which every client pays
57
- // for at connect; orboto_help serves this text in full.
58
19
  + '`agentSessionToken` is a stable per-agent-instance token: on a bot/service account it scopes the timer to YOUR instance - concurrent per-instance timers and NO auto-stop, so you own both start and stop. Omit it on human accounts, which keep the single-timer behaviour.',
59
20
  inputSchema: z.object({
60
21
  ticketKey: z.string().min(3),
@@ -63,9 +24,6 @@ export const claimToolConfig = {
63
24
  noTimer: z.boolean().optional().describe('Skip the timer start.'),
64
25
  agentSessionToken: z.string().optional().describe('Per-agent-instance token; scopes the timer on bot accounts.'),
65
26
  }).shape,
66
- // ORB-1669 - destructive because of `sole=true`, which strips every
67
- // other assignee. Annotations are per-tool and cannot be conditioned on
68
- // an argument, so the hint has to cover the worst call the tool allows.
69
27
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
70
28
  };
71
29
  export function makeClaimHandler(client) {
@@ -78,9 +36,6 @@ export function makeClaimHandler(client) {
78
36
  if (currentCategory === 'done' && !force) {
79
37
  throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
80
38
  }
81
- // --sole: destructive take-over. Remove every other assignee
82
- // first. Each DELETE tolerates a 404 (race condition where another
83
- // process already unassigned them).
84
39
  if (sole) {
85
40
  for (const a of currentAssignees) {
86
41
  if (a.id === me.id)
@@ -94,43 +49,25 @@ export function makeClaimHandler(client) {
94
49
  }
95
50
  }
96
51
  }
97
- // Additive self-add. Skip if we're already on the ticket - POSTing
98
- // again would 409; idempotency here matters because agents call
99
- // claim defensively at the start of every operation.
100
52
  if (!alreadyAssigned) {
101
53
  try {
102
54
  await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
103
55
  }
104
56
  catch (err) {
105
- // 409 = race-added by another process while we were checking.
106
- // Treat as already-assigned success.
107
57
  if (!(err instanceof OrbotoApiError) || err.status !== 409)
108
58
  throw err;
109
59
  }
110
60
  }
111
- // Only PATCH if we're not already in_progress. Saves an audit row
112
- // + websocket broadcast on the common re-claim path.
113
61
  let finalTicket = current;
114
62
  if (currentCategory !== 'in_progress') {
115
63
  finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });
116
64
  }
117
- // Timer side-effects. Mirrors the wrapper's ORB-179 path: stop a
118
- // timer running on a different ticket, then start fresh here. A
119
- // 409 on the start side is captured as a warning rather than
120
- // raised, so the assign/status work still counts as success.
121
65
  let timerStarted = false;
122
66
  let timerWarning = null;
123
- // Bot/service accounts own their timer (per-instance, no auto-stop). Instance
124
- // id: explicit arg > per-connection MCP session id (distinct per client even
125
- // on a shared HTTP server) > per-process id (stdio). Human accounts fall
126
- // through to the legacy auto-stop path.
127
67
  const effectiveToken = agentSessionToken ?? (me.isBot ? mcpInstanceToken(undefined, extra) : undefined);
128
68
  if (!noTimer) {
129
69
  try {
130
70
  if (effectiveToken) {
131
- // ORB-1252 - agent instance owns its timer: per-session, no auto-stop.
132
- // Idempotent on the same ticket; 409 if this session already runs a
133
- // different ticket (surfaced as a warning below).
134
71
  await client.post('/time/timer/start', { ticketId: current.id, agentSessionToken: effectiveToken });
135
72
  timerStarted = true;
136
73
  }
@@ -141,13 +78,10 @@ export function makeClaimHandler(client) {
141
78
  await client.post('/time/timer/stop', {
142
79
  note: `Auto-stopped by claim of ${finalTicket.ticketKey ?? current.id}`,
143
80
  }).catch(() => {
144
- // Swallow - we'll try start below; if start 409s we surface
145
- // the warning then.
146
81
  timerWarning = `Failed to stop timer on ${other}; new timer not started.`;
147
82
  });
148
83
  }
149
84
  else if (active && active.ticketId === current.id) {
150
- // Same ticket - effectively already started.
151
85
  timerStarted = true;
152
86
  }
153
87
  if (!timerStarted && !timerWarning) {
@@ -161,7 +95,6 @@ export function makeClaimHandler(client) {
161
95
  timerWarning = 'A timer is already running on another ticket; could not start one here. Run `orboto_timer_stop` first.';
162
96
  }
163
97
  else {
164
- // Any other timer error → keep the claim, surface the issue.
165
98
  timerWarning = err instanceof Error ? err.message : String(err);
166
99
  }
167
100
  }
@@ -192,9 +125,6 @@ export function makeClaimHandler(client) {
192
125
  };
193
126
  };
194
127
  }
195
- // ---------------------------------------------------------------------------
196
- // orboto_unclaim
197
- // ---------------------------------------------------------------------------
198
128
  export const unclaimToolConfig = {
199
129
  title: 'Unclaim a ticket (composite: unassign self + move to todo)',
200
130
  description: 'Inverse of `orboto_claim`: remove the calling user as an assignee and move the ticket back to `todo`. Idempotent - if the caller wasn\'t an assignee, the unassign step is a no-op and the status move still happens. Does not stop a running timer (use `orboto_timer_stop` if you want that side-effect; staying separate avoids surprising the next claimant).',
@@ -86,8 +86,6 @@ describe('orboto_claim', () => {
86
86
  { json: { ticketId: 't1' } }, // GET /time/timer - already on same ticket
87
87
  ]);
88
88
  const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
89
- // 4 calls total: /users/me, by-key project, by-key ticket, GET /time/timer.
90
- // No POST /assignees, no PATCH status, no POST /timer/start.
91
89
  expect(calls).toHaveLength(4);
92
90
  expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
93
91
  expect(calls.some((c) => c.url.includes('/timer/start'))).toBe(false);
@@ -21,8 +21,6 @@ export function makeCriticalPathHandler(client) {
21
21
  const project = await resolveProjectByKey(client, input.projectKey);
22
22
  const params = new URLSearchParams();
23
23
  if (input.milestone) {
24
- // ORB-1696 - shared resolver: key (ORB-M3), name or UUID, ambiguous
25
- // name -> explicit error. Matches create_ticket/set_milestone/OQL.
26
24
  const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
27
25
  params.set('milestoneId', m.id);
28
26
  }
@@ -37,15 +35,12 @@ export function makeCriticalPathHandler(client) {
37
35
  structuredContent: { cycle: res.cycle, criticalPath: [], projectDurationDays: 0, tickets: [] },
38
36
  };
39
37
  }
40
- // ORB-1614 - flag cross-project neighbours pulled into the graph so the
41
- // reader knows a key like "OVB-55" is not a typo for this project.
42
38
  const externalKeys = new Set(res.tickets.filter((t) => t.external).map((t) => t.ticketKey));
43
39
  const slack = res.tickets
44
40
  .filter((t) => !t.isCritical && t.totalFloat > 0)
45
41
  .sort((a, b) => a.totalFloat - b.totalFloat)
46
42
  .map((t) => ` ${t.ticketKey}${t.external ? ' [external]' : ''}: ${t.totalFloat}d slack`)
47
43
  .join('\n');
48
- // ORB-1459 - surface tickets whose deadline can't be met (negative float).
49
44
  const risks = (res.deadlineRisks ?? [])
50
45
  .map((r) => {
51
46
  const via = r.constrainingTicketKey && r.constrainingTicketKey !== r.ticketKey
@@ -75,9 +75,6 @@ describe('orboto_critical_path (ORB-1028)', () => {
75
75
  expect(sc.deadlineRisks).toHaveLength(2);
76
76
  expect(sc.deadlineRisks[0]).toMatchObject({ ticketKey: 'ACME-1', shortfallDays: 2 });
77
77
  });
78
- // ORB-1614 - a 1-hop cross-project neighbour is marked external:true by
79
- // the API; the tool should flag it in both the text (so "OVB-55" isn't
80
- // mistaken for a typo of this project's key) and the structured output.
81
78
  it('flags a cross-project neighbour with [external] in text and structured output', async () => {
82
79
  const calls = stub([
83
80
  { json: PROJ },
@@ -2,24 +2,10 @@
2
2
  /**
3
3
  * ORB-632 / ORB-945 - Cross-Project Linking MCP tools.
4
4
  *
5
- * - orboto_list_cross_project_links - GET /tickets/:id/cross-project-links
6
- * - orboto_add_cross_project_link - POST /tickets/:id/cross-project-links
7
- * - orboto_update_cross_project_link - PATCH /tickets/:id/cross-project-links/:linkId
8
- * - orboto_remove_cross_project_link - DELETE /tickets/:id/cross-project-links/:linkId
9
- *
10
- * Business-tier feature (`.ee.*`). The API records the EE soft-warn
11
- * event on mutating calls; the tool surface here is identical to
12
- * every other MCP tool and reads the existing license-state via the
13
- * Authorization-header path.
14
- *
15
- * Accepts BOTH a UUID and a ticket-key (`OCP-42`, case-insensitive) on
16
- * every input - the API resolves either form.
5
+ * @see OCP-42
17
6
  */
18
7
  import { z } from 'zod';
19
8
  const RELATION_TYPES = ['counterpart', 'depends_on', 'blocks', 'related'];
20
- // ---------------------------------------------------------------------------
21
- // orboto_list_cross_project_links
22
- // ---------------------------------------------------------------------------
23
9
  export const listCrossProjectLinksToolConfig = {
24
10
  title: 'List cross-project links on a ticket',
25
11
  description: 'Return every cross-project link touching the ticket - both outgoing (this → other) and incoming (other → this). Each row carries the other end\'s project key, ticket key, title, and current status, so the model can see the linked work\'s state without a follow-up tool call. ACL-filtered: rows whose other-end project the caller cannot read are dropped silently.',
@@ -50,9 +36,6 @@ export function makeListCrossProjectLinksHandler(client) {
50
36
  };
51
37
  };
52
38
  }
53
- // ---------------------------------------------------------------------------
54
- // orboto_add_cross_project_link
55
- // ---------------------------------------------------------------------------
56
39
  export const addCrossProjectLinkToolConfig = {
57
40
  title: 'Add a cross-project link between two tickets',
58
41
  description: 'Create a formal relation between two tickets in different projects. Caller must be a member of BOTH the source and target projects. Relation types: counterpart (parallel work in two repos - bidirectional), depends_on (this ticket waits for the other), blocks (the other waits for this), related (loose association). Pass statusSyncEnabled=true to opt into auto-close: when a counterpart-linked ticket moves to done, the other auto-closes too. Off by default.',
@@ -76,9 +59,6 @@ export function makeAddCrossProjectLinkHandler(client) {
76
59
  };
77
60
  };
78
61
  }
79
- // ---------------------------------------------------------------------------
80
- // orboto_update_cross_project_link
81
- // ---------------------------------------------------------------------------
82
62
  export const updateCrossProjectLinkToolConfig = {
83
63
  title: 'Toggle status-sync on a cross-project link',
84
64
  description: 'Flip the statusSyncEnabled flag on an existing cross-project link. When enabled on a counterpart-typed link, the other end auto-closes when this one moves to done (and vice versa). Other relation types ignore the flag at runtime, so toggling it on them is a no-op as far as status-sync goes.',
@@ -101,9 +81,6 @@ export function makeUpdateCrossProjectLinkHandler(client) {
101
81
  };
102
82
  };
103
83
  }
104
- // ---------------------------------------------------------------------------
105
- // orboto_remove_cross_project_link
106
- // ---------------------------------------------------------------------------
107
84
  export const removeCrossProjectLinkToolConfig = {
108
85
  title: 'Remove a cross-project link',
109
86
  description: 'DESTRUCTIVE - drops the cross-project link between two tickets. Caller must be a member of both source and target projects (or super-admin). The underlying tickets are unaffected; only the relation row is deleted.',
@@ -1,20 +1,5 @@
1
1
  /**
2
2
  * ORB-914 - doc-attachments MCP tools (epic ORB-911 Phase 3).
3
- *
4
- * Mirrors the ticket-attachment surface (apps/mcp/src/tools/attach.ts)
5
- * for doc pages. Three tools:
6
- *
7
- * - orboto_upload_doc_attachment - multipart upload + optional embed
8
- * - orboto_list_doc_attachments - flat list with download URLs
9
- * - orboto_delete_doc_attachment - destructive
10
- *
11
- * The embed branch on upload PATCHes the doc body to append a Markdown
12
- * image-or-link line, same pattern as orboto_attach_to_ticket. Useful
13
- * when an agent wants to drop a screenshot into a page in one call.
14
- *
15
- * MIME / extension policy is enforced server-side via `isAttachmentAllowed`
16
- * - the tool just surfaces the 415 as an OrbotoApiError so the caller
17
- * can pick a different file.
18
3
  */
19
4
  import { z } from 'zod';
20
5
  import { resolveDocId } from './docs.js';
@@ -50,9 +35,6 @@ function mimetypeFor(filename) {
50
35
  function isImage(mimetype) {
51
36
  return mimetype.startsWith('image/');
52
37
  }
53
- // ---------------------------------------------------------------------------
54
- // orboto_upload_doc_attachment
55
- // ---------------------------------------------------------------------------
56
38
  export const uploadDocAttachmentToolConfig = {
57
39
  title: 'Upload an attachment to a doc page',
58
40
  description: 'Upload a file as an attachment on a wiki doc and return its Markdown image (or link) line + the stable download URL. Bytes come in as base64 (`contentBase64`) so this works without local FS access on the agent side. Set `embed=true` to also PATCH the doc body to append the Markdown line - useful for dropping a screenshot into a page in one call. MIME / extension policy is enforced server-side: blocked types (HTML, SVG, executables) surface a 415.',
@@ -70,8 +52,6 @@ export function makeUploadDocAttachmentHandler(client) {
70
52
  docId = await resolveDocId(client, docId);
71
53
  let arrayBuffer;
72
54
  try {
73
- // Buffer → fresh ArrayBuffer copy (see attach.ts for the Blob
74
- // type-constraint rationale).
75
55
  const buf = Buffer.from(contentBase64, 'base64');
76
56
  arrayBuffer = new ArrayBuffer(buf.byteLength);
77
57
  new Uint8Array(arrayBuffer).set(buf);
@@ -92,8 +72,6 @@ export function makeUploadDocAttachmentHandler(client) {
92
72
  : `[${alt}](${url})`;
93
73
  let embedded = false;
94
74
  if (embed === true) {
95
- // Read the current body first - PATCH /docs/:id replaces, not
96
- // appends. Then write the new body in one go.
97
75
  const current = await client.get(`/docs/${docId}`);
98
76
  const existing = current.content ?? '';
99
77
  const next = existing ? `${existing}\n\n${markdown}` : markdown;
@@ -118,9 +96,6 @@ export function makeUploadDocAttachmentHandler(client) {
118
96
  };
119
97
  };
120
98
  }
121
- // ---------------------------------------------------------------------------
122
- // orboto_list_doc_attachments
123
- // ---------------------------------------------------------------------------
124
99
  export const listDocAttachmentsToolConfig = {
125
100
  title: 'List attachments on a doc page',
126
101
  description: 'Return the doc\'s attachments newest-first, with stable download URLs the agent can hand to the user. Empty list = no attachments.',
@@ -160,9 +135,6 @@ export function makeListDocAttachmentsHandler(client) {
160
135
  };
161
136
  };
162
137
  }
163
- // ---------------------------------------------------------------------------
164
- // orboto_delete_doc_attachment
165
- // ---------------------------------------------------------------------------
166
138
  export const deleteDocAttachmentToolConfig = {
167
139
  title: 'Delete an attachment from a doc page',
168
140
  description: 'DESTRUCTIVE - drops the file row + deletes the underlying S3 object. Does NOT rewrite the doc body, so embedded Markdown lines pointing at the deleted URL will surface as broken images / links. Walk those by hand if needed.',
@@ -46,9 +46,7 @@ function stubFetch(responses) {
46
46
  });
47
47
  return calls;
48
48
  }
49
- const TINY_PNG_BASE64 =
50
- // 1x1 red png - smallest valid bytes we can pass through
51
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAFhAJ/wlseKgAAAABJRU5ErkJggg==';
49
+ const TINY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAFhAJ/wlseKgAAAABJRU5ErkJggg==';
52
50
  describe('orboto_upload_doc_attachment', () => {
53
51
  it('POSTs multipart to /docs/:id/attachments and returns markdown image line', async () => {
54
52
  const calls = stubFetch([{ status: 201, json: ATTACHMENT }]);
@@ -61,7 +59,6 @@ describe('orboto_upload_doc_attachment', () => {
61
59
  method: 'POST',
62
60
  url: `https://orboto.example.com/docs/${DOC_ID}/attachments`,
63
61
  });
64
- // FormData reaches fetch as a FormData instance.
65
62
  expect(calls[0].body).toBeInstanceOf(FormData);
66
63
  const fd = calls[0].body;
67
64
  expect(fd.get('file')).toBeTruthy();
@@ -1,25 +1,8 @@
1
1
  /**
2
2
  * ORB-917 - doc-comments MCP tools (epic ORB-911 Phase 6).
3
- *
4
- * - orboto_list_doc_comments - GET /docs/:id/comments (cursor-paged, oldest-first)
5
- * - orboto_post_doc_comment - POST /docs/:id/comments
6
- * - orboto_resolve_doc_comment - POST /docs/:id/comments/:cid/resolve
7
- * - orboto_delete_doc_comment - DELETE /docs/:id/comments/:cid
8
- *
9
- * Comments support replies (single-level - replying to a reply lands as
10
- * a sibling of the original reply because the API auto-flattens past
11
- * one level) and optional anchors (a 3-tuple of {text, before, after}
12
- * the frontend uses to re-locate a highlight even after the doc body
13
- * has shifted).
14
- *
15
- * resolve acts on the thread root by design so resolving from a reply
16
- * folds the whole conversation - mirrors the web UI's behaviour.
17
3
  */
18
4
  import { z } from 'zod';
19
5
  import { resolveDocId } from './docs.js';
20
- // ---------------------------------------------------------------------------
21
- // orboto_list_doc_comments
22
- // ---------------------------------------------------------------------------
23
6
  export const listDocCommentsToolConfig = {
24
7
  title: 'List comments on a doc page',
25
8
  description: 'Return comments on a doc page, oldest-first so the reply tree reads top-to-bottom. Each row carries the author name + content + resolved state. Cursor-paged - pass `cursor` from the previous call\'s nextCursor to walk older pages.',
@@ -46,9 +29,6 @@ export function makeListDocCommentsHandler(client) {
46
29
  structuredContent: { comments: [], nextCursor: null },
47
30
  };
48
31
  }
49
- // Build a parent-keyed tree so the Markdown rendering can indent
50
- // replies one level under their root. The API already flattens
51
- // past one level, so depth is always 0 or 1.
52
32
  const byParent = new Map();
53
33
  for (const c of page.items) {
54
34
  const key = c.parentCommentId ?? null;
@@ -62,23 +42,19 @@ export function makeListDocCommentsHandler(client) {
62
42
  const author = c.userName ?? `user ${c.userId.slice(0, 8)}`;
63
43
  const resolved = c.resolvedAt ? ' [resolved]' : '';
64
44
  lines.push(`${indent}- ${author} · ${c.createdAt}${resolved} · id: ${c.id}`);
65
- // Anchor preview helps the model understand what part of the
66
- // doc the comment is anchored to. Truncate to keep the output
67
- // compact.
68
45
  if (c.anchor) {
69
46
  const snippet = c.anchor.text.length > 80 ? c.anchor.text.slice(0, 77) + '...' : c.anchor.text;
70
47
  lines.push(`${indent} anchored on: "${snippet}"`);
71
48
  }
72
49
  const body = c.content.split('\n').map((l) => `${indent} ${l}`).join('\n');
73
50
  lines.push(body);
74
- // Render direct replies one level deeper.
75
51
  const replies = byParent.get(c.id) ?? [];
76
52
  for (const r of replies)
77
53
  renderOne(r, depth + 1);
78
54
  };
79
55
  for (const root of byParent.get(null) ?? []) {
80
56
  renderOne(root, 0);
81
- lines.push(''); // blank line between root threads
57
+ lines.push('');
82
58
  }
83
59
  if (page.nextCursor) {
84
60
  lines.push(`(more available - pass cursor: ${page.nextCursor})`);
@@ -104,9 +80,6 @@ export function makeListDocCommentsHandler(client) {
104
80
  };
105
81
  };
106
82
  }
107
- // ---------------------------------------------------------------------------
108
- // orboto_post_doc_comment
109
- // ---------------------------------------------------------------------------
110
83
  export const postDocCommentToolConfig = {
111
84
  title: 'Post a comment on a doc page (or reply to one)',
112
85
  description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mentions in the content body (`@username`) fire notifications automatically.',
@@ -147,9 +120,6 @@ export function makePostDocCommentHandler(client) {
147
120
  };
148
121
  };
149
122
  }
150
- // ---------------------------------------------------------------------------
151
- // orboto_resolve_doc_comment
152
- // ---------------------------------------------------------------------------
153
123
  export const resolveDocCommentToolConfig = {
154
124
  title: 'Mark a doc comment thread as resolved (or reopen it)',
155
125
  description: 'Toggle a comment thread\'s resolved state. Pass `resolved=true` to fold the conversation, `resolved=false` to reopen. Always acts on the thread ROOT - resolving from a reply folds the whole conversation, same as the web UI.',
@@ -177,9 +147,6 @@ export function makeResolveDocCommentHandler(client) {
177
147
  };
178
148
  };
179
149
  }
180
- // ---------------------------------------------------------------------------
181
- // orboto_update_doc_comment - ORB-933
182
- // ---------------------------------------------------------------------------
183
150
  export const updateDocCommentToolConfig = {
184
151
  title: 'Edit your own doc comment',
185
152
  description: 'Edit a doc comment\'s body. Author OR super-admin can edit; everyone else gets a 403. Doc-comments do not carry a revision history today (unlike ticket comments), so the prior content is overwritten in place. A no-op call (same content as before) returns the unchanged row.',
@@ -204,9 +171,6 @@ export function makeUpdateDocCommentHandler(client) {
204
171
  };
205
172
  };
206
173
  }
207
- // ---------------------------------------------------------------------------
208
- // orboto_delete_doc_comment
209
- // ---------------------------------------------------------------------------
210
174
  export const deleteDocCommentToolConfig = {
211
175
  title: 'Delete a doc comment',
212
176
  description: 'DESTRUCTIVE - drops the comment + (via FK cascade) every reply under it. Only the author can delete their own comments; super-admins can delete anyone\'s. Returns success silently; 403 surfaces as an OrbotoApiError.',
@@ -62,7 +62,7 @@ describe('orboto_list_doc_comments', () => {
62
62
  const res = await makeListDocCommentsHandler(client)({ docId: DOC_ID });
63
63
  const text = res.content[0].text;
64
64
  expect(text.indexOf('Alice')).toBeLessThan(text.indexOf('Bob'));
65
- expect(text).toMatch(/\s\s- Bob/); // reply is indented one level
65
+ expect(text).toMatch(/\s\s- Bob/);
66
66
  expect(text).toContain('Agreed.');
67
67
  const comments = res.structuredContent.comments;
68
68
  expect(comments).toHaveLength(2);
@@ -100,7 +100,6 @@ describe('orboto_post_doc_comment', () => {
100
100
  url: `https://orboto.example.com/docs/${DOC_ID}/comments`,
101
101
  body: { content: 'Looks good to me.' },
102
102
  });
103
- // No parentCommentId in the body for a root comment.
104
103
  expect(calls[0].body.parentCommentId).toBeUndefined();
105
104
  });
106
105
  it('passes parentCommentId through for a reply', async () => {
@@ -1,33 +1,12 @@
1
1
  /**
2
2
  * ORB-1342 (epic ORB-1339) - doc snippet-search + targeted-edit MCP tools.
3
3
  *
4
- * Phase-3 four-way-sync rollout of the two REST primitives shipped in
5
- * ORB-1340 (GET /docs/search) and ORB-1341 (POST /docs/:id/edits). The
6
- * whole point of the feature is context efficiency: an agent finds the
7
- * right passage with one search (snippet + heading anchor, NOT the full
8
- * doc body) and changes it with one edit that ships only the diff in both
9
- * directions. Prefer these over orboto_get_doc + orboto_update_doc for
10
- * small changes to a large doc.
11
- *
12
- * Three tools:
13
- * - orboto_search_docs GET /docs/search
14
- * - orboto_edit_doc POST /docs/:id/edits (string-replace edits)
15
- * - orboto_edit_doc_section POST /docs/:id/edits (heading-addressed ops)
16
- *
17
- * The two edit tools hit the same endpoint with a different half of its
18
- * body, so they share the 409-conflict translation below: a machine-
19
- * readable conflict (no/ambiguous match, stale revision, heading not
20
- * found / ambiguous) comes back as a NON-throwing tool result with
21
- * `isError: true` so the model can self-correct instead of the raw API
22
- * error bubbling up as an opaque failure.
4
+ * @see ORB-1340, ORB-1341
23
5
  */
24
6
  import { z } from 'zod';
25
7
  import { OrbotoApiError } from '../orboto-client.js';
26
8
  import { resolveDocId } from './docs.js';
27
9
  import { resolveProjectByKey } from './shared.js';
28
- // ---------------------------------------------------------------------------
29
- // 409 translation - shared by both edit tools.
30
- // ---------------------------------------------------------------------------
31
10
  /** Turn a 409 from POST /docs/:id/edits into a clear, non-throwing tool
32
11
  * result the model can act on. Returns null for any non-409 so the caller
33
12
  * rethrows (a genuine transport/permission failure should still surface). */
@@ -98,9 +77,6 @@ function renderEditResult(res) {
98
77
  },
99
78
  };
100
79
  }
101
- // ---------------------------------------------------------------------------
102
- // orboto_search_docs (GET /docs/search - ORB-1340)
103
- // ---------------------------------------------------------------------------
104
80
  export const searchDocsToolConfig = {
105
81
  title: 'Search docs for snippets (context-efficient)',
106
82
  description: 'Full-text search across wiki docs that returns a highlighted SNIPPET (matched fragments, <mark>…</mark> around each hit) plus the nearest markdown heading path (section anchor) and char/line offset per hit - WITHOUT echoing the full doc body. Use this instead of orboto_get_doc when you only need to locate a passage: find the right doc + section, then change it with orboto_edit_doc / orboto_edit_doc_section, all without transferring the whole document. ACL-filtered in SQL (you only see docs you can read). Ranked by relevance.',
@@ -157,9 +133,6 @@ export function makeSearchDocsHandler(client) {
157
133
  };
158
134
  };
159
135
  }
160
- // ---------------------------------------------------------------------------
161
- // orboto_edit_doc (POST /docs/:id/edits - string-replace, ORB-1341)
162
- // ---------------------------------------------------------------------------
163
136
  export const editDocToolConfig = {
164
137
  title: 'Targeted string-replace edit of a doc (context-efficient)',
165
138
  description: 'Change a doc by byte-precise string replacement (Edit-tool-like) - ship only the diff, not the whole doc. Each oldString must match EXACTLY ONCE in the current content (add surrounding context to make it unique) unless replaceAll=true (then >=1). The whole batch is atomic: any match failure applies nothing and returns a 409 you can act on. Prefer this over orboto_update_doc for small changes to a large doc. Pass baseRevisionId (from a previous edit\'s revisionId or orboto_list_doc_revisions) for optimistic concurrency - a stale token is rejected with the current revision id instead of clobbering a concurrent change. Returns a short context window around each change, not the full doc.',
@@ -192,9 +165,6 @@ export function makeEditDocHandler(client) {
192
165
  }
193
166
  };
194
167
  }
195
- // ---------------------------------------------------------------------------
196
- // orboto_edit_doc_section (POST /docs/:id/edits - section ops, ORB-1341)
197
- // ---------------------------------------------------------------------------
198
168
  export const editDocSectionToolConfig = {
199
169
  title: 'Edit a doc section by heading path (context-efficient)',
200
170
  description: 'Change a doc by markdown-heading-addressed section operations - ship only the new section content, not the whole doc. A section is addressed by its heading path (e.g. ["Setup","Credentials"] = a "Credentials" heading nested under "Setup"). Heading matching is EXACT + case-sensitive; the leading path elements must be an ordered ancestor chain (not necessarily immediate parents). op=replace swaps the section body, op=append adds to its end, op=insertAfter inserts directly below the heading line (the heading line itself is never removed). A path that matches zero or multiple headings returns a 409 you can act on (narrow the path to disambiguate). Prefer this over orboto_update_doc for section-scoped rewrites. baseRevisionId gives optimistic concurrency. Returns a short context window per op.',
@@ -40,9 +40,6 @@ function stubJSON(responses) {
40
40
  const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
41
41
  const DOC_UUID = '33333333-3333-3333-3333-333333333333';
42
42
  const REV_UUID = '44444444-4444-4444-4444-444444444444';
43
- // ---------------------------------------------------------------------------
44
- // orboto_search_docs
45
- // ---------------------------------------------------------------------------
46
43
  describe('orboto_search_docs', () => {
47
44
  const HIT = {
48
45
  id: DOC_UUID,
@@ -91,9 +88,6 @@ describe('orboto_search_docs', () => {
91
88
  expect(res.content[0].text).toContain('No docs matched');
92
89
  });
93
90
  });
94
- // ---------------------------------------------------------------------------
95
- // orboto_edit_doc - string-replace
96
- // ---------------------------------------------------------------------------
97
91
  describe('orboto_edit_doc', () => {
98
92
  it('POSTs edits to /docs/:id/edits (UUID passthrough) and renders the window', async () => {
99
93
  const calls = stubJSON([{
@@ -158,9 +152,6 @@ describe('orboto_edit_doc', () => {
158
152
  await expect(makeEditDocHandler(client)({ docId: DOC_UUID, edits: [{ oldString: 'a', newString: 'b' }] })).rejects.toThrow(/403/);
159
153
  });
160
154
  });
161
- // ---------------------------------------------------------------------------
162
- // orboto_edit_doc_section - heading-addressed
163
- // ---------------------------------------------------------------------------
164
155
  describe('orboto_edit_doc_section', () => {
165
156
  it('POSTs sectionOps to /docs/:id/edits', async () => {
166
157
  const calls = stubJSON([{