@orboto/mcp 0.178.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 (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * ORB-799 - composite ticket-lifecycle tools.
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.
36
+ */
37
+ import { z } from 'zod';
38
+ import { OrbotoApiError } from '../orboto-client.js';
39
+ 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
+ const CATEGORY_TO_LEGACY = {
44
+ todo: 'TODO',
45
+ in_progress: 'IN_PROGRESS',
46
+ in_review: 'IN_REVIEW',
47
+ done: 'DONE',
48
+ wont_fix: 'WONT_FIX',
49
+ };
50
+ // ---------------------------------------------------------------------------
51
+ // orboto_claim
52
+ // ---------------------------------------------------------------------------
53
+ export const claimToolConfig = {
54
+ title: 'Claim a ticket (assign self + in_progress + timer)',
55
+ 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
+ + '`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
+ inputSchema: z.object({
60
+ ticketKey: z.string().min(3),
61
+ sole: z.boolean().optional().describe('Take-over: remove every other assignee first.'),
62
+ force: z.boolean().optional().describe('Allow re-claiming a `done` ticket.'),
63
+ noTimer: z.boolean().optional().describe('Skip the timer start.'),
64
+ agentSessionToken: z.string().optional().describe('Per-agent-instance token; scopes the timer on bot accounts.'),
65
+ }).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
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
70
+ };
71
+ export function makeClaimHandler(client) {
72
+ return async ({ ticketKey, sole, force, noTimer, agentSessionToken }, extra) => {
73
+ const me = await client.get('/users/me');
74
+ const current = await resolveTicketByKey(client, ticketKey);
75
+ const currentAssignees = current.assignees ?? [];
76
+ const alreadyAssigned = currentAssignees.some((a) => a.id === me.id);
77
+ const currentCategory = current.statusCategory;
78
+ if (currentCategory === 'done' && !force) {
79
+ throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
80
+ }
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
+ if (sole) {
85
+ for (const a of currentAssignees) {
86
+ if (a.id === me.id)
87
+ continue;
88
+ try {
89
+ await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.id}`);
90
+ }
91
+ catch (err) {
92
+ if (!(err instanceof OrbotoApiError) || err.status !== 404)
93
+ throw err;
94
+ }
95
+ }
96
+ }
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
+ if (!alreadyAssigned) {
101
+ try {
102
+ await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
103
+ }
104
+ catch (err) {
105
+ // 409 = race-added by another process while we were checking.
106
+ // Treat as already-assigned success.
107
+ if (!(err instanceof OrbotoApiError) || err.status !== 409)
108
+ throw err;
109
+ }
110
+ }
111
+ // Only PATCH if we're not already in_progress. Saves an audit row
112
+ // + websocket broadcast on the common re-claim path.
113
+ let finalTicket = current;
114
+ if (currentCategory !== 'in_progress') {
115
+ finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });
116
+ }
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
+ let timerStarted = false;
122
+ 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
+ const effectiveToken = agentSessionToken ?? (me.isBot ? mcpInstanceToken(undefined, extra) : undefined);
128
+ if (!noTimer) {
129
+ try {
130
+ 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
+ await client.post('/time/timer/start', { ticketId: current.id, agentSessionToken: effectiveToken });
135
+ timerStarted = true;
136
+ }
137
+ else {
138
+ const active = await client.get('/time/timer').catch(() => null);
139
+ if (active && active.ticketId && active.ticketId !== current.id) {
140
+ const other = active.ticketTitle ? `"${active.ticketTitle}"` : active.ticketId;
141
+ await client.post('/time/timer/stop', {
142
+ note: `Auto-stopped by claim of ${finalTicket.ticketKey ?? current.id}`,
143
+ }).catch(() => {
144
+ // Swallow - we'll try start below; if start 409s we surface
145
+ // the warning then.
146
+ timerWarning = `Failed to stop timer on ${other}; new timer not started.`;
147
+ });
148
+ }
149
+ else if (active && active.ticketId === current.id) {
150
+ // Same ticket - effectively already started.
151
+ timerStarted = true;
152
+ }
153
+ if (!timerStarted && !timerWarning) {
154
+ await client.post('/time/timer/start', { ticketId: current.id });
155
+ timerStarted = true;
156
+ }
157
+ }
158
+ }
159
+ catch (err) {
160
+ if (err instanceof OrbotoApiError && err.status === 409) {
161
+ timerWarning = 'A timer is already running on another ticket; could not start one here. Run `orboto_timer_stop` first.';
162
+ }
163
+ else {
164
+ // Any other timer error → keep the claim, surface the issue.
165
+ timerWarning = err instanceof Error ? err.message : String(err);
166
+ }
167
+ }
168
+ }
169
+ const noop = alreadyAssigned &&
170
+ currentCategory === 'in_progress' &&
171
+ !sole &&
172
+ (noTimer || timerStarted);
173
+ const lines = [
174
+ `Claimed [${finalTicket.ticketKey}] ${finalTicket.title}`,
175
+ ` status: ${finalTicket.statusName ?? finalTicket.status}`,
176
+ noTimer ? ' timer: skipped (noTimer=true)' : ` timer: ${timerStarted ? 'started' : 'not started'}`,
177
+ timerWarning ? ` warning: ${timerWarning}` : null,
178
+ noop ? ' (no-op - already claimed + in_progress)' : null,
179
+ ].filter((l) => l !== null);
180
+ return {
181
+ content: [{ type: 'text', text: lines.join('\n') }],
182
+ structuredContent: {
183
+ ticketKey: finalTicket.ticketKey,
184
+ status: finalTicket.statusName ?? finalTicket.status,
185
+ statusCategory: finalTicket.statusCategory,
186
+ assignedSelf: true,
187
+ soleTakeover: sole === true,
188
+ timerStarted,
189
+ timerWarning,
190
+ noop,
191
+ },
192
+ };
193
+ };
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // orboto_unclaim
197
+ // ---------------------------------------------------------------------------
198
+ export const unclaimToolConfig = {
199
+ title: 'Unclaim a ticket (composite: unassign self + move to todo)',
200
+ 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).',
201
+ inputSchema: z.object({
202
+ ticketKey: z.string().min(3),
203
+ }).shape,
204
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
205
+ };
206
+ export function makeUnclaimHandler(client) {
207
+ return async ({ ticketKey }) => {
208
+ const me = await client.get('/users/me');
209
+ const ticket = await resolveTicketByKey(client, ticketKey);
210
+ let alreadyUnassigned = false;
211
+ try {
212
+ await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${me.id}`);
213
+ }
214
+ catch (err) {
215
+ if (err instanceof OrbotoApiError && err.status === 404) {
216
+ alreadyUnassigned = true;
217
+ }
218
+ else {
219
+ throw err;
220
+ }
221
+ }
222
+ const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: CATEGORY_TO_LEGACY.todo });
223
+ return {
224
+ content: [{
225
+ type: 'text',
226
+ text: `Unclaimed [${updated.ticketKey}] - moved to ${updated.statusName ?? updated.status}${alreadyUnassigned ? ' (was not assigned)' : ''}.`,
227
+ }],
228
+ structuredContent: {
229
+ ticketKey: updated.ticketKey,
230
+ status: updated.statusName ?? updated.status,
231
+ statusCategory: updated.statusCategory,
232
+ alreadyUnassigned,
233
+ },
234
+ };
235
+ };
236
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ORB-799 - `orboto_claim` / `orboto_unclaim` unit tests.
3
+ *
4
+ * Covers the composite happy paths, the idempotent re-claim shortcut,
5
+ * the `sole=true` destructive take-over, the `force=true` reopen-done
6
+ * guard, and the `noTimer=true` skip. The timer-warning branch (where
7
+ * a 409 surfaces from `/time/timer/start`) is exercised in the
8
+ * dedicated test below.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
12
+ import { makeClaimHandler, makeUnclaimHandler } from './claim.js';
13
+ beforeEach(() => { vi.restoreAllMocks(); });
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ function stub(responses) {
16
+ const calls = [];
17
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
18
+ calls.push({
19
+ url: url.toString(),
20
+ method: init?.method ?? 'GET',
21
+ body: init?.body ? JSON.parse(init.body) : undefined,
22
+ });
23
+ const r = responses.shift();
24
+ if (!r)
25
+ throw new Error(`unexpected extra fetch ${init?.method ?? 'GET'} ${url}`);
26
+ return {
27
+ ok: r.ok ?? true,
28
+ status: r.status ?? 200,
29
+ statusText: 'OK',
30
+ json: async () => ('json' in r ? r.json : {}),
31
+ text: async () => '',
32
+ };
33
+ });
34
+ return calls;
35
+ }
36
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
37
+ const ME = { id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' };
38
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
39
+ const TICKET_TODO = {
40
+ id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1,
41
+ title: 'Bug', description: null, status: 'TODO', statusName: 'To Do',
42
+ statusCategory: 'todo', type: 'bug', priority: 'normal',
43
+ estimatedTimeMinutes: 0, dueDate: null, isPrivate: false, assignees: [],
44
+ };
45
+ describe('orboto_claim', () => {
46
+ it('happy path: assigns self + moves to in_progress + starts timer (no active timer)', async () => {
47
+ const calls = stub([
48
+ { json: ME }, // /users/me
49
+ { json: PROJ }, // resolveTicketByKey: by-key project
50
+ { json: TICKET_TODO }, // resolveTicketByKey: ticket
51
+ { json: {} }, // POST assignees/me
52
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } }, // PATCH status
53
+ { json: null }, // GET /time/timer (no active)
54
+ { json: { id: 'tm1', ticketId: 't1', startedAt: '2026-05-14T16:00:00Z' } }, // POST timer/start
55
+ ]);
56
+ const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
57
+ expect(calls[3]).toMatchObject({
58
+ method: 'POST',
59
+ url: 'https://orboto.example.com/projects/p1/tickets/t1/assignees/me1',
60
+ });
61
+ expect(calls[4]).toMatchObject({
62
+ method: 'PATCH',
63
+ url: 'https://orboto.example.com/projects/p1/tickets/t1',
64
+ body: { status: 'IN_PROGRESS' },
65
+ });
66
+ expect(calls[6]).toMatchObject({
67
+ method: 'POST',
68
+ url: 'https://orboto.example.com/time/timer/start',
69
+ body: { ticketId: 't1' },
70
+ });
71
+ expect(res.structuredContent).toMatchObject({
72
+ ticketKey: 'ACME-1',
73
+ timerStarted: true,
74
+ timerWarning: null,
75
+ noop: false,
76
+ });
77
+ });
78
+ it('idempotent: skip assign POST + status PATCH when already-in-progress + already-assigned', async () => {
79
+ const calls = stub([
80
+ { json: ME },
81
+ { json: PROJ },
82
+ { json: {
83
+ ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress',
84
+ assignees: [{ id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' }],
85
+ } },
86
+ { json: { ticketId: 't1' } }, // GET /time/timer - already on same ticket
87
+ ]);
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
+ expect(calls).toHaveLength(4);
92
+ expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
93
+ expect(calls.some((c) => c.url.includes('/timer/start'))).toBe(false);
94
+ expect(res.structuredContent).toMatchObject({
95
+ ticketKey: 'ACME-1',
96
+ noop: true,
97
+ });
98
+ });
99
+ it('sole=true removes every other assignee before adding self', async () => {
100
+ const calls = stub([
101
+ { json: ME },
102
+ { json: PROJ },
103
+ { json: { ...TICKET_TODO, assignees: [
104
+ { id: 'other1', email: 'a@x', fullName: 'A' },
105
+ { id: 'other2', email: 'b@x', fullName: 'B' },
106
+ ] } },
107
+ { ok: true, status: 204 }, // DELETE other1
108
+ { ok: true, status: 204 }, // DELETE other2
109
+ { json: {} }, // POST assignees/me
110
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
111
+ { json: null }, // GET timer
112
+ { json: {} }, // POST timer/start
113
+ ]);
114
+ await makeClaimHandler(client)({ ticketKey: 'ACME-1', sole: true });
115
+ expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(2);
116
+ expect(calls[3].url).toContain('/assignees/other1');
117
+ expect(calls[4].url).toContain('/assignees/other2');
118
+ });
119
+ it('refuses to claim a done ticket without force=true', async () => {
120
+ stub([
121
+ { json: ME },
122
+ { json: PROJ },
123
+ { json: { ...TICKET_TODO, status: 'DONE', statusName: 'Done', statusCategory: 'done' } },
124
+ ]);
125
+ await expect(makeClaimHandler(client)({ ticketKey: 'ACME-1' })).rejects.toThrow(/Refusing to claim/);
126
+ });
127
+ it('force=true claims a done ticket and re-opens to in_progress', async () => {
128
+ const calls = stub([
129
+ { json: ME },
130
+ { json: PROJ },
131
+ { json: { ...TICKET_TODO, status: 'DONE', statusName: 'Done', statusCategory: 'done' } },
132
+ { json: {} }, // POST assignees/me
133
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
134
+ { json: null },
135
+ { json: {} },
136
+ ]);
137
+ await makeClaimHandler(client)({ ticketKey: 'ACME-1', force: true });
138
+ expect(calls.find((c) => c.method === 'PATCH')?.body).toEqual({ status: 'IN_PROGRESS' });
139
+ });
140
+ it('noTimer=true skips the timer interaction', async () => {
141
+ const calls = stub([
142
+ { json: ME },
143
+ { json: PROJ },
144
+ { json: TICKET_TODO },
145
+ { json: {} },
146
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
147
+ ]);
148
+ const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1', noTimer: true });
149
+ expect(calls.some((c) => c.url.includes('/timer'))).toBe(false);
150
+ expect(res.structuredContent).toMatchObject({ timerStarted: false });
151
+ });
152
+ it('timer 409 → assign/status succeed, timerWarning surfaces', async () => {
153
+ stub([
154
+ { json: ME },
155
+ { json: PROJ },
156
+ { json: TICKET_TODO },
157
+ { json: {} },
158
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
159
+ { json: null }, // GET /time/timer - empty
160
+ { ok: false, status: 409, json: { error: 'timer already running' } }, // POST /timer/start fails
161
+ ]);
162
+ const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
163
+ expect(res.structuredContent).toMatchObject({
164
+ ticketKey: 'ACME-1',
165
+ timerStarted: false,
166
+ });
167
+ expect(res.structuredContent.timerWarning).toMatch(/timer is already running/i);
168
+ });
169
+ });
170
+ describe('orboto_unclaim', () => {
171
+ it('unassigns self + moves to todo', async () => {
172
+ const calls = stub([
173
+ { json: ME },
174
+ { json: PROJ },
175
+ { json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
176
+ { ok: true, status: 204 }, // DELETE assignees/me
177
+ { json: { ...TICKET_TODO, status: 'TODO', statusName: 'To Do', statusCategory: 'todo' } },
178
+ ]);
179
+ const res = await makeUnclaimHandler(client)({ ticketKey: 'ACME-1' });
180
+ expect(calls[3]).toMatchObject({ method: 'DELETE', url: 'https://orboto.example.com/projects/p1/tickets/t1/assignees/me1' });
181
+ expect(calls[4]).toMatchObject({ method: 'PATCH', body: { status: 'TODO' } });
182
+ expect(res.structuredContent).toMatchObject({
183
+ ticketKey: 'ACME-1',
184
+ alreadyUnassigned: false,
185
+ });
186
+ });
187
+ it('idempotent: 404 on unassign is treated as alreadyUnassigned', async () => {
188
+ stub([
189
+ { json: ME },
190
+ { json: PROJ },
191
+ { json: TICKET_TODO },
192
+ { ok: false, status: 404, json: { error: 'not assigned' } },
193
+ { json: { ...TICKET_TODO, statusCategory: 'todo' } },
194
+ ]);
195
+ const res = await makeUnclaimHandler(client)({ ticketKey: 'ACME-1' });
196
+ expect(res.structuredContent).toMatchObject({ alreadyUnassigned: true });
197
+ });
198
+ it('surfaces non-404 errors from the unassign delete', async () => {
199
+ stub([
200
+ { json: ME },
201
+ { json: PROJ },
202
+ { json: TICKET_TODO },
203
+ { ok: false, status: 500, json: { error: 'boom' } },
204
+ ]);
205
+ await expect(makeUnclaimHandler(client)({ ticketKey: 'ACME-1' })).rejects.toBeInstanceOf(OrbotoApiError);
206
+ });
207
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ORB-1028 - `orboto_critical_path`. Phase 4 agent surface for the CPM
3
+ * endpoint shipped in Phase 1. Wraps GET /projects/:id/critical-path,
4
+ * resolving the project key (and optional milestone name) for the caller.
5
+ */
6
+ import { z } from 'zod';
7
+ import { resolveProjectByKey } from './shared.js';
8
+ import { resolveMilestoneByNameOrId } from './milestones.js';
9
+ export const criticalPathToolConfig = {
10
+ title: 'Critical path (CPM)',
11
+ description: "Compute a project's (or one milestone's) critical path via the Critical Path Method: the longest finish-to-start dependency chain that sets the delivery date, plus each ticket's slack (total float, in working days). Durations come from `estimatedTimeMinutes` (8h/day, floored at 1 day). Deadline-aware (ORB-1459): a ticket's due date (or its milestone's, when tighter) seeds the backward pass, so total float can go NEGATIVE when a chain cannot meet its deadline - those tickets are reported under `deadlineRisks` with the shortfall in working days. ORB-1614: a dependency edge to a ticket in ANOTHER project is followed one hop (that ticket's own further cross-project blockers are not) when you can read it, and marked `external: true` in the result - an edge to a foreign ticket you cannot read is silently absent from the graph, same as any other out-of-window ticket. Returns the critical path (ticket keys in order), the total working-day duration, per-ticket float, and the deadline risks. A dependency cycle (including one spanning projects) returns the tangled ticket keys instead of a path. By default tickets in completed/archived milestones are excluded (matching the board); set includeClosedMilestones to include them.",
12
+ inputSchema: z.object({
13
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
14
+ milestone: z.string().optional().describe('Milestone name to scope to. Omit for the whole project.'),
15
+ includeClosedMilestones: z.boolean().optional().describe('Include tickets from completed/archived milestones. Default false (hidden, matching the board).'),
16
+ }).shape,
17
+ annotations: { readOnlyHint: true, idempotentHint: true },
18
+ };
19
+ export function makeCriticalPathHandler(client) {
20
+ return async (input) => {
21
+ const project = await resolveProjectByKey(client, input.projectKey);
22
+ const params = new URLSearchParams();
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
+ const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
27
+ params.set('milestoneId', m.id);
28
+ }
29
+ if (input.includeClosedMilestones)
30
+ params.set('includeClosedMilestones', 'true');
31
+ const qs = params.toString();
32
+ const url = `/projects/${project.id}/critical-path${qs ? `?${qs}` : ''}`;
33
+ const res = await client.get(url);
34
+ if (res.cycle) {
35
+ return {
36
+ content: [{ type: 'text', text: `Dependency cycle - the critical path is undefined until it's broken. Tickets involved: ${res.cycle.ticketKeys.join(', ')}` }],
37
+ structuredContent: { cycle: res.cycle, criticalPath: [], projectDurationDays: 0, tickets: [] },
38
+ };
39
+ }
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
+ const externalKeys = new Set(res.tickets.filter((t) => t.external).map((t) => t.ticketKey));
43
+ const slack = res.tickets
44
+ .filter((t) => !t.isCritical && t.totalFloat > 0)
45
+ .sort((a, b) => a.totalFloat - b.totalFloat)
46
+ .map((t) => ` ${t.ticketKey}${t.external ? ' [external]' : ''}: ${t.totalFloat}d slack`)
47
+ .join('\n');
48
+ // ORB-1459 - surface tickets whose deadline can't be met (negative float).
49
+ const risks = (res.deadlineRisks ?? [])
50
+ .map((r) => {
51
+ const via = r.constrainingTicketKey && r.constrainingTicketKey !== r.ticketKey
52
+ ? ` (via ${r.constrainingTicketKey}${r.dueDate ? ` due ${r.dueDate}` : ''})`
53
+ : r.dueDate ? ` (due ${r.dueDate})` : '';
54
+ return ` ${r.ticketKey}: ${r.shortfallDays}d short${via}`;
55
+ })
56
+ .join('\n');
57
+ const pathDisplay = res.criticalPath.length
58
+ ? res.criticalPath.map((k) => (externalKeys.has(k) ? `${k} [external]` : k)).join(' -> ')
59
+ : '(none)';
60
+ const text = `Critical path (${res.projectDurationDays} working day${res.projectDurationDays === 1 ? '' : 's'}): ${pathDisplay}` +
61
+ (slack ? `\nSlack on non-critical tickets:\n${slack}` : '\nAll in-scope tickets are on the critical path.') +
62
+ (risks ? `\nDEADLINE RISKS (negative float - cannot meet the deadline):\n${risks}` : '');
63
+ return {
64
+ content: [{ type: 'text', text }],
65
+ structuredContent: {
66
+ criticalPath: res.criticalPath,
67
+ projectDurationDays: res.projectDurationDays,
68
+ tickets: res.tickets.map((t) => ({
69
+ ticketKey: t.ticketKey,
70
+ isCritical: t.isCritical,
71
+ deadlineCritical: t.deadlineCritical ?? false,
72
+ bindingConstraint: t.bindingConstraint ?? 'project_end',
73
+ totalFloat: t.totalFloat,
74
+ durationDays: t.durationDays,
75
+ external: t.external ?? false,
76
+ externalProjectId: t.externalProjectId ?? null,
77
+ })),
78
+ deadlineRisks: res.deadlineRisks ?? [],
79
+ cycle: null,
80
+ },
81
+ };
82
+ };
83
+ }
@@ -0,0 +1,114 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeCriticalPathHandler } from './critical-path.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stub(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
9
+ calls.push(url.toString());
10
+ const r = responses.shift();
11
+ if (!r)
12
+ throw new Error(`unexpected extra fetch to ${url}`);
13
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
14
+ });
15
+ return calls;
16
+ }
17
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
18
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
19
+ describe('orboto_critical_path (ORB-1028)', () => {
20
+ it('resolves the project and returns the path + per-ticket float', async () => {
21
+ const calls = stub([
22
+ { json: PROJ },
23
+ { json: {
24
+ tickets: [
25
+ { ticketKey: 'ACME-1', title: 'A', durationDays: 1, totalFloat: 0, isCritical: true },
26
+ { ticketKey: 'ACME-2', title: 'B', durationDays: 1, totalFloat: 2, isCritical: false },
27
+ ],
28
+ criticalPath: ['ACME-1'],
29
+ dependencies: [],
30
+ projectDurationDays: 1,
31
+ cycle: null,
32
+ } },
33
+ ]);
34
+ const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
35
+ expect(calls[1]).toContain('/projects/p1/critical-path');
36
+ expect(calls[1]).not.toContain('milestoneId');
37
+ const sc = res.structuredContent;
38
+ expect(sc.criticalPath).toEqual(['ACME-1']);
39
+ expect(sc.tickets.find((t) => t.ticketKey === 'ACME-2')).toMatchObject({ isCritical: false, totalFloat: 2 });
40
+ expect(res.content[0].text).toContain('ACME-1');
41
+ });
42
+ it('resolves a milestone name to its id query param', async () => {
43
+ const calls = stub([
44
+ { json: PROJ },
45
+ { json: [{ id: 'm2', name: 'Sprint 7' }] },
46
+ { json: { tickets: [], criticalPath: [], dependencies: [], projectDurationDays: 0, cycle: null } },
47
+ ]);
48
+ await makeCriticalPathHandler(client)({ projectKey: 'ACME', milestone: 'Sprint 7' });
49
+ expect(calls[2]).toContain('milestoneId=m2');
50
+ });
51
+ it('surfaces deadline risks (negative float) in text + structured output', async () => {
52
+ const calls = stub([
53
+ { json: PROJ },
54
+ { json: {
55
+ tickets: [
56
+ { ticketKey: 'ACME-1', title: 'A', durationDays: 3, totalFloat: -2, isCritical: true, deadlineCritical: true, bindingConstraint: 'successors' },
57
+ { ticketKey: 'ACME-2', title: 'B', durationDays: 2, totalFloat: -2, isCritical: true, deadlineCritical: true, bindingConstraint: 'due_date' },
58
+ ],
59
+ criticalPath: ['ACME-1', 'ACME-2'],
60
+ deadlineRisks: [
61
+ { ticketKey: 'ACME-1', constrainingTicketKey: 'ACME-2', dueDate: '2026-07-18', shortfallDays: 2 },
62
+ { ticketKey: 'ACME-2', constrainingTicketKey: 'ACME-2', dueDate: '2026-07-18', shortfallDays: 2 },
63
+ ],
64
+ dependencies: [],
65
+ projectDurationDays: 5,
66
+ cycle: null,
67
+ } },
68
+ ]);
69
+ const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
70
+ expect(calls[1]).toContain('/projects/p1/critical-path');
71
+ const text = res.content[0].text;
72
+ expect(text).toMatch(/DEADLINE RISKS/);
73
+ expect(text).toContain('ACME-2: 2d short');
74
+ const sc = res.structuredContent;
75
+ expect(sc.deadlineRisks).toHaveLength(2);
76
+ expect(sc.deadlineRisks[0]).toMatchObject({ ticketKey: 'ACME-1', shortfallDays: 2 });
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
+ it('flags a cross-project neighbour with [external] in text and structured output', async () => {
82
+ const calls = stub([
83
+ { json: PROJ },
84
+ { json: {
85
+ tickets: [
86
+ { ticketKey: 'ACME-1', title: 'A', durationDays: 1, totalFloat: 0, isCritical: true, external: false },
87
+ { ticketKey: 'OVB-6', title: 'Foreign blocker', durationDays: 2, totalFloat: 0, isCritical: true, external: true, externalProjectId: 'p2' },
88
+ ],
89
+ criticalPath: ['OVB-6', 'ACME-1'],
90
+ dependencies: [{ ticketId: 'ACME-1', dependsOnId: 'OVB-6' }],
91
+ projectDurationDays: 3,
92
+ cycle: null,
93
+ } },
94
+ ]);
95
+ const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
96
+ expect(calls[1]).toContain('/projects/p1/critical-path');
97
+ const text = res.content[0].text;
98
+ expect(text).toContain('OVB-6 [external]');
99
+ const sc = res.structuredContent;
100
+ const foreign = sc.tickets.find((t) => t.ticketKey === 'OVB-6');
101
+ expect(foreign).toMatchObject({ external: true, externalProjectId: 'p2' });
102
+ const local = sc.tickets.find((t) => t.ticketKey === 'ACME-1');
103
+ expect(local).toMatchObject({ external: false, externalProjectId: null });
104
+ });
105
+ it('surfaces a dependency cycle', async () => {
106
+ stub([
107
+ { json: PROJ },
108
+ { json: { tickets: [], criticalPath: [], dependencies: [], projectDurationDays: 0, cycle: { ticketKeys: ['ACME-1', 'ACME-2'] } } },
109
+ ]);
110
+ const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
111
+ expect(res.content[0].text).toMatch(/cycle/i);
112
+ expect(res.structuredContent.cycle.ticketKeys).toEqual(['ACME-1', 'ACME-2']);
113
+ });
114
+ });