@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,358 @@
1
+ /**
2
+ * ORB-1609 - unit tests for the work-session tools.
3
+ *
4
+ * The behaviours worth pinning are the ones an agent's control flow
5
+ * depends on: the 409 must surface the HOLDER (otherwise a colliding
6
+ * agent cannot decide what to do next without another call), and the
7
+ * idempotent finish must read as success rather than as an error.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoClient } from '../orboto-client.js';
11
+ import { makeWorkSessionStartHandler, makeWorkSessionFinishHandler, makeWorkSessionsHandler, makeWorkSessionClaimsAddHandler, makeWorkSessionClaimsReleaseHandler, makeWorkFinishHandler, } from './work-sessions.js';
12
+ beforeEach(() => { vi.restoreAllMocks(); });
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ function stub(responses) {
15
+ const calls = [];
16
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
17
+ calls.push({
18
+ url: url.toString(),
19
+ method: init?.method ?? 'GET',
20
+ body: init?.body ? JSON.parse(init.body) : undefined,
21
+ });
22
+ const r = responses.shift();
23
+ if (!r)
24
+ throw new Error('unexpected extra fetch');
25
+ return {
26
+ ok: r.ok ?? true,
27
+ status: r.status ?? 200,
28
+ statusText: 'OK',
29
+ json: async () => ('json' in r ? r.json : {}),
30
+ text: async () => r.text ?? '',
31
+ };
32
+ });
33
+ return calls;
34
+ }
35
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
36
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
37
+ const TICKET = { id: 't1', ticketKey: 'ACME-42', title: 'Do the thing', projectId: 'p1' };
38
+ const SESSION = {
39
+ id: 'ws1',
40
+ ticketId: 't1',
41
+ role: 'implementation',
42
+ status: 'active',
43
+ startedAt: '2026-07-24T10:00:00Z',
44
+ leaseUntil: '2026-07-24T10:15:00Z',
45
+ activeTimerId: 'timer1',
46
+ commitSha: null,
47
+ ticketKey: 'ACME-42',
48
+ };
49
+ describe('orboto_work_session_start', () => {
50
+ it('acquires the lease and reports the session id + expiry', async () => {
51
+ const calls = stub([
52
+ { json: [PROJ] },
53
+ { json: TICKET },
54
+ { json: { session: SESSION, reused: false } },
55
+ ]);
56
+ const res = await makeWorkSessionStartHandler(client)({ ticketKey: 'ACME-42' });
57
+ const post = calls[calls.length - 1];
58
+ expect(post.method).toBe('POST');
59
+ expect(post.url).toContain('/work-sessions');
60
+ expect(post.body?.ticketId).toBe('t1');
61
+ // The instance token must be sent unprompted - without it the lease has
62
+ // no owner to renew against.
63
+ expect(String(post.body?.agentSessionToken)).toMatch(/^mcp-/);
64
+ const text = res.content[0].text;
65
+ expect(text).toContain('Started a implementation work session on ACME-42');
66
+ expect(text).toContain('ws1');
67
+ expect(text).toContain('Timer running.');
68
+ expect(res.structuredContent.reused).toBe(false);
69
+ });
70
+ it('reports a renewal (not a fresh start) when the same instance re-acquires', async () => {
71
+ stub([
72
+ { json: [PROJ] },
73
+ { json: TICKET },
74
+ { json: { session: SESSION, reused: true } },
75
+ ]);
76
+ const res = await makeWorkSessionStartHandler(client)({ ticketKey: 'ACME-42' });
77
+ expect(res.content[0].text).toContain('Renewed your existing');
78
+ });
79
+ it('surfaces the holder on a 409 instead of throwing', async () => {
80
+ const holder = {
81
+ sessionId: 'other-session',
82
+ userEmail: 'rival@orboto.test',
83
+ userFullName: 'Rival Bot',
84
+ role: 'implementation',
85
+ startedAt: '2026-07-24T09:00:00Z',
86
+ leaseUntil: '2026-07-24T09:15:00Z',
87
+ };
88
+ stub([
89
+ { json: [PROJ] },
90
+ { json: TICKET },
91
+ {
92
+ ok: false,
93
+ status: 409,
94
+ text: JSON.stringify({ error: 'held', errorKey: 'errors.work_sessions.lease_held', holder }),
95
+ },
96
+ ]);
97
+ const res = await makeWorkSessionStartHandler(client)({ ticketKey: 'ACME-42' });
98
+ expect(res.isError).toBe(true);
99
+ const text = res.content[0].text;
100
+ expect(text).toContain('Rival Bot');
101
+ expect(text).toContain('takeover=true');
102
+ expect(res.structuredContent.holder.sessionId).toBe('other-session');
103
+ });
104
+ it('passes role, lease and takeover through', async () => {
105
+ const calls = stub([
106
+ { json: [PROJ] },
107
+ { json: TICKET },
108
+ { json: { session: { ...SESSION, role: 'review', activeTimerId: null }, reused: false } },
109
+ ]);
110
+ const res = await makeWorkSessionStartHandler(client)({
111
+ ticketKey: 'ACME-42', role: 'review', leaseSeconds: 3600, takeover: true,
112
+ });
113
+ const post = calls[calls.length - 1];
114
+ expect(post.body).toMatchObject({ role: 'review', leaseSeconds: 3600, takeover: true });
115
+ // A review session attaches without booking time by default.
116
+ expect(res.content[0].text).toContain('No timer started for this role.');
117
+ });
118
+ });
119
+ describe('orboto_work_session_finish', () => {
120
+ it('reports the booked time and the freed lease', async () => {
121
+ const calls = stub([
122
+ { json: { session: { ...SESSION, status: 'finished' }, durationMinutes: 23, changed: true } },
123
+ ]);
124
+ const res = await makeWorkSessionFinishHandler(client)({
125
+ sessionId: 'ws1', commitSha: 'deadbeef', verification: { build: true, tests: true },
126
+ });
127
+ expect(calls[0].url).toContain('/work-sessions/ws1/finish');
128
+ expect(calls[0].body).toMatchObject({ commitSha: 'deadbeef', verification: { build: true, tests: true } });
129
+ expect(res.content[0].text).toContain('Booked 23 min');
130
+ });
131
+ it('treats an already-finished session as success, not an error', async () => {
132
+ stub([{ json: { session: { ...SESSION, status: 'finished' }, durationMinutes: 0, changed: false } }]);
133
+ const res = await makeWorkSessionFinishHandler(client)({ sessionId: 'ws1' });
134
+ expect(res.isError).toBeUndefined();
135
+ expect(res.content[0].text).toContain('Idempotent finish');
136
+ });
137
+ });
138
+ describe('ORB-1612 - orboto_work_finish', () => {
139
+ it('reports the ticket transition and the completion note', async () => {
140
+ const calls = stub([
141
+ {
142
+ json: {
143
+ session: { ...SESSION, status: 'finished', commitSha: 'deadbeef', commitVerified: false },
144
+ durationMinutes: 23,
145
+ changed: true,
146
+ ticketTransitioned: true,
147
+ ticketStatusCategory: 'done',
148
+ noteCommented: true,
149
+ },
150
+ },
151
+ ]);
152
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1', commitSha: 'deadbeef', targetCategory: 'done' });
153
+ expect(calls[0].url).toContain('/work-sessions/ws1/finish-work');
154
+ expect(calls[0].body).toMatchObject({ commitSha: 'deadbeef', targetCategory: 'done' });
155
+ const text = res.content[0].text;
156
+ expect(text).toContain('Booked 23 min');
157
+ expect(text).toContain('attested - pending git verification');
158
+ expect(text).toContain('Ticket moved to done');
159
+ expect(text).toContain('Completion note posted');
160
+ expect(res.structuredContent.ticketTransitioned).toBe(true);
161
+ });
162
+ it('reports a verified commit distinctly from an attested-but-unverified one', async () => {
163
+ stub([
164
+ {
165
+ json: {
166
+ session: { ...SESSION, status: 'finished', commitSha: 'cafebabe', commitVerified: true },
167
+ durationMinutes: 5, changed: true, ticketTransitioned: true, ticketStatusCategory: 'done', noteCommented: true,
168
+ },
169
+ },
170
+ ]);
171
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1', commitSha: 'cafebabe' });
172
+ expect(res.content[0].text).toContain('verified by git ingestion');
173
+ });
174
+ it('reports a role/outcome that leaves the ticket untouched without treating it as an error', async () => {
175
+ stub([
176
+ {
177
+ json: {
178
+ session: { ...SESSION, role: 'review', status: 'finished' },
179
+ durationMinutes: 10, changed: true, ticketTransitioned: false, ticketStatusCategory: 'todo', noteCommented: true,
180
+ },
181
+ },
182
+ ]);
183
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1' });
184
+ expect(res.isError).toBeUndefined();
185
+ expect(res.content[0].text).toContain('Ticket left at todo - not transitioned this call.');
186
+ });
187
+ it('treats an idempotent no-op re-run as success with no note', async () => {
188
+ stub([
189
+ {
190
+ json: {
191
+ session: { ...SESSION, status: 'finished' },
192
+ durationMinutes: 0, changed: false, ticketTransitioned: false, ticketStatusCategory: 'done', noteCommented: false,
193
+ },
194
+ },
195
+ ]);
196
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1' });
197
+ expect(res.isError).toBeUndefined();
198
+ const text = res.content[0].text;
199
+ expect(text).toContain('Idempotent finish');
200
+ expect(text).toContain('No completion note posted');
201
+ });
202
+ it('surfaces a deliveryModeWarning without failing the call', async () => {
203
+ stub([
204
+ {
205
+ json: {
206
+ session: { ...SESSION, status: 'finished' },
207
+ durationMinutes: 8, changed: true, ticketTransitioned: true, ticketStatusCategory: 'done', noteCommented: true,
208
+ deliveryModeWarning: { code: 'no_commit_linked', message: 'no linked commit' },
209
+ },
210
+ },
211
+ ]);
212
+ const res = await makeWorkFinishHandler(client)({ sessionId: 'ws1' });
213
+ expect(res.isError).toBeUndefined();
214
+ expect(res.content[0].text).toContain('no linked commit');
215
+ });
216
+ });
217
+ describe('orboto_work_sessions', () => {
218
+ it('lists the workspace coordination view by default', async () => {
219
+ const calls = stub([{ json: [{ ...SESSION, userFullName: 'Bot One' }] }]);
220
+ const res = await makeWorkSessionsHandler(client)({});
221
+ expect(calls[0].url).toContain('/work-sessions/active');
222
+ expect(res.content[0].text).toContain('ACME-42 [implementation] Bot One');
223
+ });
224
+ it('scopes to one ticket and can include the closed history', async () => {
225
+ const calls = stub([
226
+ { json: [PROJ] },
227
+ { json: TICKET },
228
+ { json: [] },
229
+ ]);
230
+ const res = await makeWorkSessionsHandler(client)({ ticketKey: 'ACME-42', includeClosed: true });
231
+ expect(calls[2].url).toContain('/tickets/t1/work-sessions?includeClosed=true');
232
+ expect(res.content[0].text).toContain('none.');
233
+ });
234
+ it('scopes to my own sessions', async () => {
235
+ const calls = stub([{ json: [] }]);
236
+ await makeWorkSessionsHandler(client)({ scope: 'mine' });
237
+ expect(calls[0].url).toContain('/work-sessions/mine');
238
+ });
239
+ });
240
+ describe('ORB-1610 - orboto_work_session_start with resourceClaims', () => {
241
+ it('passes resourceClaims + onConflict through and reports queued claims', async () => {
242
+ const calls = stub([
243
+ { json: [PROJ] },
244
+ { json: TICKET },
245
+ {
246
+ json: {
247
+ session: { ...SESSION, resourceClaims: [{ kind: 'path', value: 'src/**', mode: 'write', state: 'waiting' }] },
248
+ reused: false,
249
+ queued: [{ claim: { kind: 'path', value: 'src/**', mode: 'write' }, position: 1, blockedBy: [] }],
250
+ },
251
+ },
252
+ ]);
253
+ const res = await makeWorkSessionStartHandler(client)({
254
+ ticketKey: 'ACME-42',
255
+ resourceClaims: [{ kind: 'path', value: 'src/**', mode: 'write' }],
256
+ onConflict: 'queue',
257
+ });
258
+ const post = calls[calls.length - 1];
259
+ expect(post.body).toMatchObject({
260
+ resourceClaims: [{ kind: 'path', value: 'src/**', mode: 'write' }],
261
+ onConflict: 'queue',
262
+ });
263
+ expect(res.content[0].text).toContain('Queued');
264
+ expect(res.content[0].text).toContain('position 1');
265
+ });
266
+ it('surfaces claim conflicts distinctly from a lease conflict on 409', async () => {
267
+ const conflicts = [{
268
+ claim: { kind: 'named', value: 'unity-editor:main', mode: 'write' },
269
+ holders: [{ sessionId: 's2', ticketKey: 'ACME-9', userEmail: 'rival@orboto.test', userFullName: 'Rival Bot', claim: { kind: 'named', value: 'unity-editor:main', mode: 'write' } }],
270
+ }];
271
+ stub([
272
+ { json: [PROJ] },
273
+ { json: TICKET },
274
+ { ok: false, status: 409, text: JSON.stringify({ error: 'held', errorKey: 'errors.work_sessions.claim_conflict', claimConflicts: conflicts }) },
275
+ ]);
276
+ const res = await makeWorkSessionStartHandler(client)({
277
+ ticketKey: 'ACME-42',
278
+ resourceClaims: [{ kind: 'named', value: 'unity-editor:main', mode: 'write' }],
279
+ });
280
+ expect(res.isError).toBe(true);
281
+ const text = res.content[0].text;
282
+ expect(text).toContain('Rival Bot');
283
+ expect(text).toContain('unity-editor:main');
284
+ expect(res.structuredContent.claimConflict).toBe(true);
285
+ });
286
+ });
287
+ describe('ORB-1610 - orboto_work_session_claims_add', () => {
288
+ it('adds claims and reports the granted count', async () => {
289
+ const calls = stub([
290
+ {
291
+ json: {
292
+ session: { ...SESSION, resourceClaims: [{ kind: 'path', value: 'src/a/**', mode: 'write', state: 'granted' }] },
293
+ queued: [],
294
+ },
295
+ },
296
+ ]);
297
+ const res = await makeWorkSessionClaimsAddHandler(client)({
298
+ sessionId: 'ws1',
299
+ claims: [{ kind: 'path', value: 'src/a/**', mode: 'write' }],
300
+ });
301
+ expect(calls[0].url).toContain('/work-sessions/ws1/claims');
302
+ expect(calls[0].method).toBe('POST');
303
+ expect(calls[0].body).toMatchObject({ claims: [{ kind: 'path', value: 'src/a/**', mode: 'write' }] });
304
+ expect(res.content[0].text).toContain('1 granted claim');
305
+ });
306
+ it('reports queued claims separately from granted ones', async () => {
307
+ stub([
308
+ {
309
+ json: {
310
+ session: { ...SESSION, resourceClaims: [{ kind: 'path', value: 'src/b/**', mode: 'write', state: 'waiting' }] },
311
+ queued: [{ claim: { kind: 'path', value: 'src/b/**', mode: 'write' }, position: 2, blockedBy: [] }],
312
+ },
313
+ },
314
+ ]);
315
+ const res = await makeWorkSessionClaimsAddHandler(client)({
316
+ sessionId: 'ws1',
317
+ claims: [{ kind: 'path', value: 'src/b/**', mode: 'write' }],
318
+ onConflict: 'queue',
319
+ });
320
+ const text = res.content[0].text;
321
+ expect(text).toContain('0 granted claim');
322
+ expect(text).toContain('position 2');
323
+ });
324
+ it('surfaces a claim conflict on 409 without adding anything', async () => {
325
+ const conflicts = [{
326
+ claim: { kind: 'path', value: 'src/c.ts', mode: 'write' },
327
+ holders: [{ sessionId: 's2', ticketKey: 'ACME-9', userEmail: null, userFullName: null, claim: { kind: 'path', value: 'src/c.ts', mode: 'write' } }],
328
+ }];
329
+ stub([{ ok: false, status: 409, text: JSON.stringify({ error: 'held', errorKey: 'errors.work_sessions.claim_conflict', claimConflicts: conflicts }) }]);
330
+ const res = await makeWorkSessionClaimsAddHandler(client)({
331
+ sessionId: 'ws1',
332
+ claims: [{ kind: 'path', value: 'src/c.ts', mode: 'write' }],
333
+ });
334
+ expect(res.isError).toBe(true);
335
+ expect(res.content[0].text).toContain('No claims were added');
336
+ });
337
+ });
338
+ describe('ORB-1610 - orboto_work_session_claims_release', () => {
339
+ it('releases specific claims and reports the remaining count', async () => {
340
+ const calls = stub([{ text: JSON.stringify({ ...SESSION, resourceClaims: [{ kind: 'path', value: 'src/b/**', mode: 'write', state: 'granted' }] }) }]);
341
+ const res = await makeWorkSessionClaimsReleaseHandler(client)({
342
+ sessionId: 'ws1',
343
+ claims: [{ kind: 'path', value: 'src/a/**' }],
344
+ });
345
+ expect(calls[0].url).toContain('/work-sessions/ws1/claims');
346
+ expect(calls[0].method).toBe('DELETE');
347
+ expect(calls[0].body).toMatchObject({ claims: [{ kind: 'path', value: 'src/a/**' }] });
348
+ expect(res.content[0].text).toContain('1 claim(s) remain');
349
+ });
350
+ it('releases everything when claims is omitted, and still sends a body', async () => {
351
+ const calls = stub([{ text: JSON.stringify({ ...SESSION, resourceClaims: [] }) }]);
352
+ const res = await makeWorkSessionClaimsReleaseHandler(client)({ sessionId: 'ws1' });
353
+ // A DELETE with a genuinely absent body 400s on the API's schema
354
+ // (ORB-1610 finding) - the handler must always send at least `{}`.
355
+ expect(calls[0].body).toEqual({});
356
+ expect(res.content[0].text).toContain('Released every claim');
357
+ });
358
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ORB-1520 - manifest triage for the Code-Mode escape hatch (epic
3
+ * ORB-1517).
4
+ *
5
+ * Eager-loading MCP clients (Codex, Cursor, Claude Desktop) pull EVERY
6
+ * tool schema at connect time - with ~190 named tools that is tens of
7
+ * thousands of tokens before the user says a word. The default manifest
8
+ * is therefore the CURATED set below; the long tail stays fully
9
+ * reachable through `orboto_api_search` + `orboto_api_call`, and the
10
+ * complete named manifest is one opt-in away - and since ORB-1805 a
11
+ * SMALLER one is too, for local models with an 8k/16k window:
12
+ *
13
+ * stdio: ORBOTO_MCP_TOOLSET=minimal | curated | full
14
+ * http: .../mcp?toolset=minimal (or header x-orboto-toolset: full;
15
+ * the query param rides on every request, so no session
16
+ * persistence is involved)
17
+ *
18
+ * KEEP-LIST BASIS - measured, not guessed (ORB-1520): 30-day
19
+ * `mcp_call_log` distribution on the core instance, 2026-08-10, 15.5k
20
+ * calls total. Every tool with a meaningful call volume is in; the two
21
+ * additions beyond the data are `orboto_my_tickets` (the natural "what
22
+ * am I working on" entry for interactive desktop clients - the exact
23
+ * audience this manifest serves; agent-dominated prod traffic
24
+ * under-counts it) and the escape-hatch pair itself. The numbers live
25
+ * in the ORB-1520 ticket comment.
26
+ *
27
+ * Tail tools are NOT deleted: their code, tests, chat mirrors and
28
+ * drift-guard entries are untouched - only their REGISTRATION is
29
+ * skipped in curated mode (the chat-tools parity guard reads server.ts
30
+ * SOURCE, so it keeps covering the whole set in both modes).
31
+ */
32
+ /**
33
+ * ORB-1805 - the small-context tier.
34
+ *
35
+ * Measured 2026-09-01: the CURATED manifest alone costs ~9.4k tokens of
36
+ * tool schemas plus ~1k of instructions, so an 8k local model (LM Studio
37
+ * / Ollama defaults) dies before the first user turn:
38
+ * `n_keep: 13533 >= n_ctx: 8192`. `minimal` is the daily loop and
39
+ * nothing else - orient, find, read, write, claim, close, track time -
40
+ * plus the escape-hatch pair, which keeps the ENTIRE REST API reachable
41
+ * from 12 tools.
42
+ *
43
+ * The pair is `claim` + `close_ticket` because that is the loop the
44
+ * skill's own workflow mandates (SKILL.md "Claim = assign + status +
45
+ * timer; stop the timer the moment you finish"), not
46
+ * `work_next`/`work_finish` (those are not even in the curated set).
47
+ *
48
+ * Deliberately OUT, and why the loop still holds without them:
49
+ * - `orboto_whoami` - measured at 357 tokens, 12 % of the whole budget,
50
+ * for data `orboto_session_start` already prints ("You are <name>
51
+ * (<email>)." + the workspace write language). The mandated first call
52
+ * covers it; a second identity tool does not earn a tier this small.
53
+ * - `orboto_check_similar` - `orboto_create_ticket` enforces the
54
+ * duplicate block server-side (ORB-1471), so the mandated pre-create
55
+ * check still happens, just inside the create.
56
+ * - `orboto_my_tickets` / `orboto_list_tickets` - `orboto_session_start`
57
+ * already returns the caller's in-progress work, and `orboto_search`
58
+ * covers "anything about X".
59
+ * - `orboto_move_ticket` - claim moves to in_progress and close moves to
60
+ * done; any other transition goes through `orboto_api_call`.
61
+ * - `orboto_help` / `orboto_response_expand` - both are recovery paths
62
+ * for large payloads, which is the opposite of this tier's problem.
63
+ *
64
+ * MUST stay a subset of CURATED_TOOLS (asserted in toolset.test.ts): a
65
+ * name that is not registered in curated cannot be registered here
66
+ * either, and the tier ordering minimal < curated < full is what the
67
+ * docs promise.
68
+ */
69
+ export const MINIMAL_TOOLS = new Set([
70
+ // Orient (session_start also reports who you are + the write language)
71
+ 'orboto_session_start',
72
+ // Find + read
73
+ 'orboto_search',
74
+ 'orboto_get_ticket',
75
+ // Write
76
+ 'orboto_create_ticket',
77
+ 'orboto_update_ticket',
78
+ 'orboto_comment',
79
+ // The claim -> close loop + its timer
80
+ 'orboto_claim',
81
+ 'orboto_close_ticket',
82
+ 'orboto_timer_start',
83
+ 'orboto_timer_stop',
84
+ // Everything else, on demand
85
+ 'orboto_api_search',
86
+ 'orboto_api_call',
87
+ ]);
88
+ /** Order matches the measured 30d call counts (see ticket comment). */
89
+ export const CURATED_TOOLS = new Set([
90
+ // Session + identity + orientation
91
+ 'orboto_session_start', // 662 calls/30d, and the binding-rules bootstrap
92
+ 'orboto_whoami', // 28
93
+ 'orboto_get_project_primer', // 131
94
+ 'orboto_list_projects', // 27, referenced by the server instructions
95
+ // Reads
96
+ 'orboto_get_ticket', // 1764
97
+ 'orboto_list_tickets', // 161
98
+ 'orboto_my_tickets', // interactive-client staple (see header)
99
+ 'orboto_search', // 420
100
+ 'orboto_query', // 26 - the OQL surface, prior art of this pattern
101
+ 'orboto_check_similar', // 258 - mandated pre-create dup check
102
+ 'orboto_get_doc', // 86
103
+ // Ticket writes (the strict claim->commit->close loop)
104
+ 'orboto_create_ticket', // 2069
105
+ 'orboto_bulk_create_tickets', // ORB-1694 - 48 measured runs of consecutive creates
106
+ 'orboto_bulk_add_ticket_dependencies', // ORB-1694 - longest dependency run: 27 calls
107
+ 'orboto_update_ticket', // 250
108
+ 'orboto_comment', // 1838
109
+ 'orboto_claim', // 1305
110
+ 'orboto_move_ticket', // 1010
111
+ 'orboto_close_ticket', // 889
112
+ 'orboto_set_parent', // 44
113
+ 'orboto_add_ticket_dependency', // 314 - the mandated blocker primitive
114
+ 'orboto_check', // 103 - acceptance-criteria checklists
115
+ 'orboto_create_milestone', // 42 - "milestone FIRST" rule for big features
116
+ // Time tracking (the timer rules)
117
+ 'orboto_timer_start', // 34
118
+ 'orboto_timer_stop', // 772
119
+ 'orboto_log_time', // 130
120
+ 'orboto_get_timer', // 43
121
+ // Transport plumbing + the escape hatch itself
122
+ 'orboto_response_expand',
123
+ // ORB-1741 - the way back from one-line manifest summaries: full
124
+ // guidance on demand. Must ride every manifest the summaries ride.
125
+ 'orboto_help', // ORB-1697 - the way back from a budget cut
126
+ 'orboto_api_search', // ORB-1518 - discovery half of the escape hatch
127
+ 'orboto_api_call', // ORB-1519 - execute half of the escape hatch
128
+ ]);
129
+ /**
130
+ * Resolve the effective toolset. `explicit` (per-connection: URL query /
131
+ * header / BuildServerOptions) beats `envValue` (process-wide default);
132
+ * anything unrecognized falls through, so a typo degrades to the safe
133
+ * default instead of surprising with 190 tools.
134
+ */
135
+ export function resolveToolset(explicit, envValue) {
136
+ const parse = (v) => v === 'full' ? 'full'
137
+ : v === 'curated' ? 'curated'
138
+ : v === 'minimal' ? 'minimal'
139
+ : null;
140
+ return parse(explicit) ?? parse(envValue) ?? 'curated';
141
+ }
142
+ /** Whether a tool registers under the given toolset. */
143
+ export function toolInToolset(toolName, toolset) {
144
+ if (toolset === 'full')
145
+ return true;
146
+ if (toolset === 'minimal')
147
+ return MINIMAL_TOOLS.has(toolName);
148
+ return CURATED_TOOLS.has(toolName);
149
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ORB-1520 - manifest triage tests: the curated default, the full
3
+ * opt-in, and the resolution precedence.
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { buildOrbotoMcpServer } from './server.js';
7
+ import { CURATED_TOOLS, MINIMAL_TOOLS, resolveToolset, toolInToolset } from './toolset.js';
8
+ beforeEach(() => {
9
+ vi.restoreAllMocks();
10
+ // buildOrbotoMcpServer fetches /agent-instructions at connect; offline
11
+ // in unit tests - the catch keeps the fallback rules, which is fine.
12
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
13
+ delete process.env.ORBOTO_MCP_TOOLSET;
14
+ });
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ delete process.env.ORBOTO_MCP_TOOLSET;
18
+ });
19
+ function registeredTools(server) {
20
+ // The SDK keeps its registry on `_registeredTools` (verified against
21
+ // @modelcontextprotocol/sdk 1.29). If a bump renames it, this helper
22
+ // fails loudly rather than passing on an empty object.
23
+ const tools = server._registeredTools;
24
+ if (!tools || Object.keys(tools).length === 0) {
25
+ throw new Error('SDK _registeredTools not found or empty - did the SDK internals change?');
26
+ }
27
+ return Object.keys(tools).sort();
28
+ }
29
+ describe('resolveToolset', () => {
30
+ it('defaults to curated', () => {
31
+ expect(resolveToolset(undefined, undefined)).toBe('curated');
32
+ expect(resolveToolset(null, null)).toBe('curated');
33
+ });
34
+ it('explicit beats env; unknown values fall through', () => {
35
+ expect(resolveToolset('full', 'curated')).toBe('full');
36
+ expect(resolveToolset('curated', 'full')).toBe('curated');
37
+ expect(resolveToolset('bogus', 'full')).toBe('full');
38
+ expect(resolveToolset('bogus', 'nonsense')).toBe('curated');
39
+ });
40
+ // ORB-1805 - the small-context tier resolves on both surfaces and,
41
+ // like the others, a typo still degrades to curated rather than
42
+ // silently handing an 8k model 175 tools.
43
+ it('minimal resolves from either surface and keeps the safe fallback', () => {
44
+ expect(resolveToolset('minimal', undefined)).toBe('minimal');
45
+ expect(resolveToolset(undefined, 'minimal')).toBe('minimal');
46
+ expect(resolveToolset('minimal', 'full')).toBe('minimal');
47
+ expect(resolveToolset('minimalist', undefined)).toBe('curated');
48
+ });
49
+ });
50
+ describe('toolInToolset', () => {
51
+ it('full admits everything, curated only the keep-list', () => {
52
+ expect(toolInToolset('orboto_wiki_ask', 'full')).toBe(true);
53
+ expect(toolInToolset('orboto_wiki_ask', 'curated')).toBe(false);
54
+ expect(toolInToolset('orboto_claim', 'curated')).toBe(true);
55
+ expect(toolInToolset('orboto_api_call', 'curated')).toBe(true);
56
+ });
57
+ it('minimal admits only the daily loop + the escape hatch', () => {
58
+ expect(toolInToolset('orboto_claim', 'minimal')).toBe(true);
59
+ expect(toolInToolset('orboto_api_search', 'minimal')).toBe(true);
60
+ expect(toolInToolset('orboto_api_call', 'minimal')).toBe(true);
61
+ // In curated, out of minimal.
62
+ expect(toolInToolset('orboto_list_projects', 'minimal')).toBe(false);
63
+ expect(toolInToolset('orboto_help', 'minimal')).toBe(false);
64
+ expect(toolInToolset('orboto_wiki_ask', 'minimal')).toBe(false);
65
+ });
66
+ });
67
+ describe('ORB-1805 - the tiers nest', () => {
68
+ it('MINIMAL_TOOLS is a strict subset of CURATED_TOOLS', () => {
69
+ // A name that is not registered in curated cannot register in
70
+ // minimal either - the tier ordering minimal < curated < full is
71
+ // what the docs promise, and a typo here would silently ship an
72
+ // empty slot.
73
+ const notInCurated = [...MINIMAL_TOOLS].filter((t) => !CURATED_TOOLS.has(t));
74
+ expect(notInCurated).toEqual([]);
75
+ expect(MINIMAL_TOOLS.size).toBeLessThan(CURATED_TOOLS.size);
76
+ });
77
+ it('the daily loop is complete: orient, find, read, write, claim, close, time, escape hatch', () => {
78
+ for (const name of [
79
+ 'orboto_session_start', 'orboto_search', 'orboto_get_ticket',
80
+ 'orboto_create_ticket', 'orboto_update_ticket', 'orboto_comment',
81
+ 'orboto_claim', 'orboto_close_ticket',
82
+ 'orboto_timer_start', 'orboto_timer_stop',
83
+ 'orboto_api_search', 'orboto_api_call',
84
+ ])
85
+ expect(MINIMAL_TOOLS.has(name)).toBe(true);
86
+ expect(MINIMAL_TOOLS.size).toBe(12);
87
+ });
88
+ });
89
+ describe('buildOrbotoMcpServer toolset gating', () => {
90
+ const baseOpts = { baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' };
91
+ it('curated (default) registers exactly the keep-list', async () => {
92
+ const server = await buildOrbotoMcpServer(baseOpts);
93
+ const tools = registeredTools(server);
94
+ expect(tools).toEqual([...CURATED_TOOLS].sort());
95
+ });
96
+ it('full registers the whole manifest (superset, >150 tools)', async () => {
97
+ const server = await buildOrbotoMcpServer({ ...baseOpts, toolset: 'full' });
98
+ const tools = registeredTools(server);
99
+ expect(tools.length).toBeGreaterThan(150);
100
+ for (const name of CURATED_TOOLS)
101
+ expect(tools).toContain(name);
102
+ });
103
+ it('ORBOTO_MCP_TOOLSET=full flips the default without an explicit option', async () => {
104
+ process.env.ORBOTO_MCP_TOOLSET = 'full';
105
+ const server = await buildOrbotoMcpServer(baseOpts);
106
+ expect(registeredTools(server).length).toBeGreaterThan(150);
107
+ });
108
+ it('curated instructions point at the escape hatch; full keeps get_checklists', async () => {
109
+ const curated = await buildOrbotoMcpServer(baseOpts);
110
+ const full = await buildOrbotoMcpServer({ ...baseOpts, toolset: 'full' });
111
+ // `_instructions` verified against @modelcontextprotocol/sdk 1.29
112
+ // (server/index.js). Assert non-empty first so an SDK rename fails
113
+ // this test loudly instead of green-skipping.
114
+ const instructionsOf = (s) => String(s.server._instructions ?? '');
115
+ const curatedText = instructionsOf(curated);
116
+ const fullText = instructionsOf(full);
117
+ expect(curatedText.length).toBeGreaterThan(100);
118
+ expect(fullText.length).toBeGreaterThan(100);
119
+ expect(curatedText).toContain('orboto_api_search');
120
+ expect(curatedText).toContain('CURATED');
121
+ expect(fullText).toContain('orboto_get_checklists');
122
+ expect(fullText).not.toContain('CURATED tool manifest');
123
+ });
124
+ it('ORBOTO_MCP_TOOLSET=minimal registers exactly the minimal set', async () => {
125
+ process.env.ORBOTO_MCP_TOOLSET = 'minimal';
126
+ const server = await buildOrbotoMcpServer(baseOpts);
127
+ expect(registeredTools(server)).toEqual([...MINIMAL_TOOLS].sort());
128
+ });
129
+ it('minimal instructions keep the escape hatch + session_start and drop the rules block', async () => {
130
+ const minimal = await buildOrbotoMcpServer({ ...baseOpts, toolset: 'minimal' });
131
+ const text = String(minimal.server._instructions ?? '');
132
+ expect(text.length).toBeGreaterThan(100);
133
+ // The two things a minimal-tier agent cannot work without.
134
+ expect(text).toContain('orboto_session_start');
135
+ expect(text).toContain('orboto_api_search');
136
+ expect(text).toContain('MINIMAL');
137
+ // ORB-1805 - the workspace rules block is deliberately absent; it
138
+ // arrives via orboto_session_start, on the caller's budget.
139
+ expect(text).not.toContain('Working rules for this workspace:');
140
+ });
141
+ it('minimal drops the display title; curated keeps it', async () => {
142
+ const shown = async (toolset) => {
143
+ const s = await buildOrbotoMcpServer({ ...baseOpts, toolset });
144
+ const reg = s._registeredTools;
145
+ return reg.orboto_create_ticket?.title;
146
+ };
147
+ expect(await shown('minimal')).toBeUndefined();
148
+ expect(await shown('curated')).toBe('Create a ticket');
149
+ });
150
+ });