@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,568 @@
1
+ /**
2
+ * ORB-1093 - session-start composes 4 reads into a re-orientation
3
+ * digest. Assert it hits the right endpoints and folds the rules +
4
+ * in-progress work + timer into the output.
5
+ *
6
+ * ORB-1607 - also covers the rules-hash ack (per-connection cache) and
7
+ * the optional `ticketKey` one-shot bundle.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoClient } from '../orboto-client.js';
11
+ import { makeSessionStartHandler } from './session-start.js';
12
+ import { makeResponseExpandHandler } from './response-expand.js';
13
+ import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
14
+ beforeEach(() => { vi.restoreAllMocks(); });
15
+ afterEach(() => { vi.restoreAllMocks(); });
16
+ function stubByPath(map) {
17
+ const calls = [];
18
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
19
+ const u = new URL(url.toString());
20
+ calls.push(u.pathname + u.search);
21
+ const key = Object.keys(map).find((k) => (u.pathname + u.search).startsWith(k));
22
+ return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
23
+ });
24
+ return calls;
25
+ }
26
+ /** Like `stubByPath`, but a given path can also 404 (OrbotoApiError). */
27
+ function stubByPathWithFailures(map, notFound) {
28
+ const calls = [];
29
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
30
+ const u = new URL(url.toString());
31
+ const pathAndSearch = u.pathname + u.search;
32
+ calls.push(pathAndSearch);
33
+ if (notFound.some((p) => pathAndSearch.startsWith(p))) {
34
+ return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({ error: 'not_found' }), text: async () => 'not found' };
35
+ }
36
+ const key = Object.keys(map).find((k) => pathAndSearch.startsWith(k));
37
+ return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
38
+ });
39
+ return calls;
40
+ }
41
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
42
+ describe('orboto_session_start (ORB-1093)', () => {
43
+ it('composes identity + rules + in-progress tickets + timer', async () => {
44
+ const calls = stubByPath({
45
+ '/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
46
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
47
+ '/agent-instructions': { instructions: 'claim -> commit -> close' },
48
+ '/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
49
+ });
50
+ const res = await makeSessionStartHandler(client)();
51
+ const text = res.content[0].text;
52
+ expect(text).toContain('claim -> commit -> close');
53
+ expect(text).toContain('ORB-42');
54
+ expect(text).toContain('Running on ORB-42');
55
+ expect(calls.some((c) => c.startsWith('/agent-instructions'))).toBe(true);
56
+ expect(calls.some((c) => c.startsWith('/time/timer'))).toBe(true);
57
+ // ORB-1330 - the briefing must only ask for OPEN work; DONE tickets
58
+ // must never reach the "in-progress" section. Assert the status
59
+ // filter is on the wire so a future refactor can't silently widen it.
60
+ const assignedCall = calls.find((c) => c.startsWith('/users/me/assigned-tickets'));
61
+ expect(assignedCall).toBeDefined();
62
+ expect(assignedCall).toContain('statuses=IN_PROGRESS,IN_REVIEW');
63
+ });
64
+ it('handles the empty case (no tickets, no timer) without throwing', async () => {
65
+ stubByPath({
66
+ '/users/me/assigned-tickets': { items: [] },
67
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
68
+ '/agent-instructions': { instructions: 'rules here' },
69
+ '/time/timer': {},
70
+ });
71
+ const res = await makeSessionStartHandler(client)();
72
+ const text = res.content[0].text;
73
+ expect(text).toContain('No tickets currently assigned');
74
+ expect(text).toContain('No timer running');
75
+ });
76
+ // ORB-1605 - session-start fans out to GET /projects/:id/git-health for
77
+ // every distinct project the caller has open work in, and surfaces a
78
+ // warning when a connection is unhealthy.
79
+ it('warns when a project git connection is unhealthy', async () => {
80
+ stubByPath({
81
+ '/users/me/assigned-tickets': {
82
+ items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
83
+ },
84
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
85
+ '/agent-instructions': { instructions: 'rules here' },
86
+ '/time/timer': {},
87
+ '/projects/proj-1/git-health': {
88
+ connections: [{
89
+ connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
90
+ connected: false, healthy: false, lastEventAt: null, reason: 'connection_inactive',
91
+ }],
92
+ },
93
+ });
94
+ const res = await makeSessionStartHandler(client)();
95
+ const text = res.content[0].text;
96
+ expect(text).toContain('Git connection health - WARNING');
97
+ expect(text).toContain('orboto/orboto');
98
+ expect(text).toContain('connection is deactivated');
99
+ // ORB-1697 - unhealthy connections only; a healthy one is 11 fields
100
+ // the agent never acts on, so it is reduced to a count.
101
+ const structured = res.structuredContent;
102
+ expect(structured.gitHealth.unhealthy).toHaveLength(1);
103
+ expect(structured.gitHealth.unhealthy[0].projectId).toBe('proj-1');
104
+ expect(structured.gitHealth.healthyCount).toBe(0);
105
+ });
106
+ it('omits the git health section when every connection is healthy', async () => {
107
+ stubByPath({
108
+ '/users/me/assigned-tickets': {
109
+ items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
110
+ },
111
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
112
+ '/agent-instructions': { instructions: 'rules here' },
113
+ '/time/timer': {},
114
+ '/projects/proj-1/git-health': {
115
+ connections: [{
116
+ connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
117
+ connected: true, healthy: true, lastEventAt: '2026-07-20T00:00:00Z', reason: null,
118
+ }],
119
+ },
120
+ });
121
+ const res = await makeSessionStartHandler(client)();
122
+ const text = res.content[0].text;
123
+ expect(text).not.toContain('Git connection health - WARNING');
124
+ });
125
+ });
126
+ // ORB-1607 - rules-hash ack: the handler remembers the last rulesHash it
127
+ // saw FOR THE LIFETIME OF ONE `makeSessionStartHandler(client)` CLOSURE
128
+ // (mirrors one MCP connection) and passes it back as `knownRulesHash` on
129
+ // every subsequent call.
130
+ describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
131
+ it('sends no knownRulesHash on the first call, then passes the received hash back on the second', async () => {
132
+ const calls = [];
133
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
134
+ const u = new URL(url.toString());
135
+ calls.push(u.pathname + u.search);
136
+ if (u.pathname === '/agent-instructions') {
137
+ const known = u.searchParams.get('knownRulesHash');
138
+ const body = known === 'abc123def456'
139
+ ? { rulesHash: 'abc123def456', rulesUnchanged: true }
140
+ : { instructions: 'claim -> commit -> close', rulesHash: 'abc123def456' };
141
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
142
+ }
143
+ const fallback = {
144
+ '/users/me/assigned-tickets': { items: [] },
145
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
146
+ '/time/timer': {},
147
+ };
148
+ const key = Object.keys(fallback).find((k) => (u.pathname + u.search).startsWith(k));
149
+ return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? fallback[key] : {}), text: async () => '' };
150
+ });
151
+ const handler = makeSessionStartHandler(client);
152
+ const first = await handler();
153
+ const firstCall = calls.find((c) => c.startsWith('/agent-instructions'));
154
+ expect(firstCall).toBe('/agent-instructions');
155
+ const firstText = first.content[0].text;
156
+ expect(firstText).toContain('claim -> commit -> close');
157
+ const firstStructured = first.structuredContent;
158
+ expect(firstStructured.rulesHash).toBe('abc123def456');
159
+ expect(firstStructured.rulesUnchanged).toBe(false);
160
+ calls.length = 0;
161
+ const second = await handler();
162
+ const secondCall = calls.find((c) => c.startsWith('/agent-instructions'));
163
+ expect(secondCall).toBe('/agent-instructions?knownRulesHash=abc123def456');
164
+ const secondText = second.content[0].text;
165
+ // ORB-1697 - the ack must not assert that the CALLER still holds the
166
+ // rules (a stdio server outlives /clear and every compaction), and it
167
+ // must name the way to get them back.
168
+ expect(secondText).toContain('Unchanged since this connection last delivered them');
169
+ expect(secondText).toContain('forceRules=true');
170
+ expect(secondText).not.toContain('keep following what you already loaded');
171
+ expect(secondText).not.toContain('claim -> commit -> close');
172
+ const secondStructured = second.structuredContent;
173
+ expect(secondStructured.rulesHash).toBe('abc123def456');
174
+ expect(secondStructured.rulesUnchanged).toBe(true);
175
+ });
176
+ it('a fresh handler (new connection) starts with no cached hash again', async () => {
177
+ const calls = stubByPath({
178
+ '/users/me/assigned-tickets': { items: [] },
179
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
180
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'hash1' },
181
+ '/time/timer': {},
182
+ });
183
+ await makeSessionStartHandler(client)();
184
+ const call = calls.find((c) => c.startsWith('/agent-instructions'));
185
+ expect(call).toBe('/agent-instructions');
186
+ });
187
+ });
188
+ // ORB-1607 - the optional `ticketKey` one-shot bundle: project primer,
189
+ // full ticket (incl. dependencies + checklists), git health, and active
190
+ // sessions folded into the same response.
191
+ describe('orboto_session_start - ticketKey bundle (ORB-1607)', () => {
192
+ const bundleStubs = {
193
+ '/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
194
+ '/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug', status: 'IN_PROGRESS' },
195
+ '/projects/proj-1/tickets/tick-1/dependencies': {
196
+ blockedBy: [{ ticketKey: 'ORB-10', title: 'Prereq', statusName: 'Done', statusCategory: 'done' }],
197
+ blocks: [],
198
+ },
199
+ '/tickets/tick-1/checklists': [{
200
+ title: 'Acceptance', triggersDone: true, progress: { done: 1, total: 2 },
201
+ items: [
202
+ { content: 'Write tests', effectiveCompleted: true, linkedTicketKey: null, linkedTicketTitle: null, linkedTicketStatusCategory: null },
203
+ { content: 'Update docs', effectiveCompleted: false, linkedTicketKey: null, linkedTicketTitle: null, linkedTicketStatusCategory: null },
204
+ ],
205
+ }],
206
+ '/projects/proj-1/ai-primer': { markdown: '# orboto primer\n\nActive milestones: 2', totalTokens: 42, truncatedSections: [] },
207
+ '/projects/proj-1/git-health': { connections: [] },
208
+ '/v1/agent/presence': [{ userId: 'u2', userEmail: 'other@x.io', userFullName: 'Other Dev', status: 'working', workingOnTicket: { id: 'tick-1', key: 'ORB-42', title: 'Bug' }, lastSeenAt: '2026-07-23T10:00:00Z' }],
209
+ };
210
+ const baseStubs = {
211
+ '/users/me/assigned-tickets': { items: [] },
212
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
213
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'h1' },
214
+ '/time/timer': {},
215
+ // Re-fetch of the enriched by-id row (mirrors orboto_get_ticket).
216
+ '/projects/proj-1/tickets/tick-1': {
217
+ id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug',
218
+ status: 'IN_PROGRESS', statusName: 'In Progress', priority: 'high', type: 'bug',
219
+ description: 'Something is broken.',
220
+ },
221
+ };
222
+ it('folds primer + ticket + dependencies + checklists + git health + active sessions into one response', async () => {
223
+ // bundleStubs first - the merged map is matched by `startsWith`, and
224
+ // baseStubs' bare `/projects/proj-1/tickets/tick-1` would otherwise
225
+ // shadow bundleStubs' longer `/projects/proj-1/tickets/tick-1/dependencies`
226
+ // if it came first in iteration order.
227
+ const calls = stubByPath({ ...bundleStubs, ...baseStubs });
228
+ const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
229
+ const text = res.content[0].text;
230
+ expect(text).toContain('## Ticket bundle: ORB-42');
231
+ expect(text).toContain('orboto primer');
232
+ expect(text).toContain('[ORB-42] Bug');
233
+ expect(text).toContain('Something is broken.');
234
+ expect(text).toContain('Write tests');
235
+ expect(text).toContain('[ORB-10] Prereq');
236
+ expect(text).toContain('Other Dev');
237
+ const structured = res.structuredContent;
238
+ expect(structured.ticketBundle.ticketKey).toBe('ORB-42');
239
+ expect(structured.ticketBundle.checklists).toHaveLength(1);
240
+ expect(structured.ticketBundle.dependencies.blockedBy).toHaveLength(1);
241
+ expect(structured.ticketBundle.activeSessions).toHaveLength(1);
242
+ // Replaces what would otherwise be several separate calls.
243
+ expect(calls.some((c) => c.startsWith('/projects/proj-1/ai-primer'))).toBe(true);
244
+ expect(calls.some((c) => c.startsWith('/tickets/tick-1/checklists'))).toBe(true);
245
+ expect(calls.some((c) => c.startsWith('/projects/proj-1/tickets/tick-1/dependencies'))).toBe(true);
246
+ });
247
+ it('an unresolvable/unauthorized ticket key surfaces a clean error section instead of throwing', async () => {
248
+ stubByPathWithFailures({ ...baseStubs, '/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' } }, ['/projects/proj-1/tickets/by-key/999']);
249
+ const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-999' });
250
+ const text = res.content[0].text;
251
+ expect(text).toContain('## Ticket bundle: ORB-999');
252
+ expect(text).toContain('Could not load this ticket');
253
+ // The rest of the digest (rules, in-progress work, timer) still renders.
254
+ expect(text).toContain('## Working rules');
255
+ expect(text).toContain('## Timer');
256
+ const structured = res.structuredContent;
257
+ expect(structured.ticketBundle.error).toBeTruthy();
258
+ });
259
+ });
260
+ /**
261
+ * ORB-1697 - the response-budget-driven changes to session_start.
262
+ *
263
+ * The digest is the most expensive tool in the workspace (measured 18.620
264
+ * characters average, fired on every session start AND after every
265
+ * compaction), so what it puts in the payload has to earn its place.
266
+ */
267
+ describe('orboto_session_start - context cost (ORB-1697)', () => {
268
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
269
+ const rulesStub = {
270
+ '/users/me/assigned-tickets': { items: [] },
271
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
272
+ '/agent-instructions': { instructions: 'THE FULL RULE TEXT', rulesHash: 'h9' },
273
+ '/time/timer': {},
274
+ };
275
+ it('forceRules re-sends the rules in full even when this connection already delivered them', async () => {
276
+ const calls = stubByPath(rulesStub);
277
+ const handler = makeSessionStartHandler(client);
278
+ await handler(); // primes the per-connection hash
279
+ calls.length = 0;
280
+ const forced = await handler({ forceRules: true });
281
+ // No knownRulesHash means the API cannot answer with an ack.
282
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
283
+ const text = forced.content[0].text;
284
+ expect(text).toContain('THE FULL RULE TEXT');
285
+ expect(text).not.toContain('forceRules=true');
286
+ });
287
+ it('without forceRules the ack path is still used - the saving is not lost', async () => {
288
+ const calls = stubByPath(rulesStub);
289
+ const handler = makeSessionStartHandler(client);
290
+ await handler();
291
+ calls.length = 0;
292
+ await handler();
293
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions?knownRulesHash=h9');
294
+ });
295
+ it('a healthy git connection costs a count, not eleven fields', async () => {
296
+ stubByPath({
297
+ '/users/me/assigned-tickets': {
298
+ items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
299
+ },
300
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
301
+ '/agent-instructions': { instructions: 'rules here' },
302
+ '/time/timer': {},
303
+ '/projects/proj-1/git-health': {
304
+ connections: [{
305
+ connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
306
+ connected: true, healthy: true, lastEventAt: '2026-08-09T00:00:00Z', reason: null,
307
+ outboundReachable: true, inboundDelivering: true, deliveryError: null, lastProbeAt: '2026-08-09T00:00:00Z',
308
+ }],
309
+ },
310
+ });
311
+ const res = await makeSessionStartHandler(client)();
312
+ const structured = res.structuredContent;
313
+ expect(structured.gitHealth.unhealthy).toEqual([]);
314
+ expect(structured.gitHealth.healthyCount).toBe(1);
315
+ // And the connection object itself is nowhere in the payload.
316
+ expect(JSON.stringify(structured)).not.toContain('lastProbeAt');
317
+ });
318
+ it('with a ticketKey, open work in OTHER projects becomes a count instead of 20 rows', async () => {
319
+ stubByPath({
320
+ '/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
321
+ '/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
322
+ '/projects/proj-1/tickets/tick-1': {
323
+ id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug',
324
+ status: 'IN_PROGRESS', statusName: 'In Progress', priority: 'high', type: 'bug',
325
+ },
326
+ // Longest path first - the stub map is matched by startsWith.
327
+ '/users/me/assigned-tickets': {
328
+ items: [
329
+ { ticketKey: 'ORB-42', title: 'Same project', statusName: 'In Progress', projectId: 'proj-1' },
330
+ { ticketKey: 'HIVE-818', title: 'Another project entirely', statusName: 'In Progress', projectId: 'proj-2' },
331
+ { ticketKey: 'HIVE-814', title: 'And another', statusName: 'In Review', projectId: 'proj-2' },
332
+ ],
333
+ },
334
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
335
+ '/agent-instructions': { instructions: 'rules here' },
336
+ '/time/timer': {},
337
+ '/v1/agent/presence': [],
338
+ });
339
+ const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
340
+ const structured = res.structuredContent;
341
+ expect(structured.inProgress.map((t) => t.ticketKey)).toEqual(['ORB-42']);
342
+ expect(structured.inProgressElsewhereCount).toBe(2);
343
+ const text = res.content[0].text;
344
+ // Nothing is hidden: the count is stated, with the way to list them.
345
+ expect(text).toContain('2 open ticket(s) assigned to you in other projects');
346
+ expect(text).toContain('orboto_my_tickets');
347
+ expect(text).not.toContain('Another project entirely');
348
+ });
349
+ it('without a ticketKey the full cross-project list is kept - the scoping is opt-in by context, not a silent filter', async () => {
350
+ stubByPath({
351
+ '/users/me/assigned-tickets': {
352
+ items: [
353
+ { ticketKey: 'ORB-42', title: 'One', statusName: 'In Progress', projectId: 'proj-1' },
354
+ { ticketKey: 'HIVE-818', title: 'Two', statusName: 'In Progress', projectId: 'proj-2' },
355
+ ],
356
+ },
357
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
358
+ '/agent-instructions': { instructions: 'rules here' },
359
+ '/time/timer': {},
360
+ });
361
+ const res = await makeSessionStartHandler(client)();
362
+ const structured = res.structuredContent;
363
+ expect(structured.inProgress.map((t) => t.ticketKey)).toEqual(['ORB-42', 'HIVE-818']);
364
+ expect(structured.inProgressElsewhereCount).toBeUndefined();
365
+ });
366
+ });
367
+ /**
368
+ * ORB-1697 - the class the ORB-1697 tests kept tripping over: every
369
+ * optional read here is guarded with `.catch`, which covers a REJECTED
370
+ * request but not a 200 whose body has a different shape (an older
371
+ * instance, a wrapped/paginated response, a field rename). Three separate
372
+ * spots in this file would have thrown and taken the whole digest down
373
+ * over an optional section - at exactly the moment an agent has the least
374
+ * context. One test pins all of them.
375
+ */
376
+ describe('orboto_session_start - a 200 with an unexpected body never kills the digest (ORB-1697)', () => {
377
+ const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
378
+ it('renders the digest when every optional endpoint answers 200 with {}', async () => {
379
+ // Only the paths the digest cannot work without return real data; every
380
+ // optional read answers `{}`, which is what the fallthrough produces.
381
+ stubByPath({
382
+ '/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
383
+ '/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
384
+ '/users/me/assigned-tickets': { items: [] },
385
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
386
+ '/agent-instructions': { instructions: 'rules here' },
387
+ });
388
+ const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
389
+ const text = res.content[0].text;
390
+ expect(res.isError).toBeUndefined();
391
+ expect(text).toContain('## Working rules');
392
+ expect(text).toContain('## Ticket bundle: ORB-42');
393
+ expect(text).toContain('### Dependencies');
394
+ expect(text).toContain('### Checklists');
395
+ const structured = res.structuredContent;
396
+ expect(structured.ticketBundle.dependencies.blockedBy).toEqual([]);
397
+ expect(structured.ticketBundle.dependencies.blocks).toEqual([]);
398
+ expect(structured.ticketBundle.checklists).toEqual([]);
399
+ expect(structured.ticketBundle.activeSessions).toEqual([]);
400
+ });
401
+ });
402
+ describe('ORB-1753 - rule targeting passthrough', () => {
403
+ afterEach(() => {
404
+ delete process.env.ORBOTO_AGENT_KIND;
405
+ delete process.env.ORBOTO_MODEL_TIER;
406
+ });
407
+ it('explicit agentKind/modelTier ride the rules fetch; env fills in when absent; explicit beats env', async () => {
408
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
409
+ let calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h1' } });
410
+ let handler = makeSessionStartHandler(client);
411
+ await handler({ agentKind: 'runner', modelTier: 'small' });
412
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=runner');
413
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('modelTier=small');
414
+ vi.restoreAllMocks();
415
+ process.env.ORBOTO_AGENT_KIND = 'Coding';
416
+ process.env.ORBOTO_MODEL_TIER = 'frontier';
417
+ calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h2' } });
418
+ handler = makeSessionStartHandler(client);
419
+ await handler({});
420
+ // env applies, lowercase-normalized client-side.
421
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=coding');
422
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('modelTier=frontier');
423
+ vi.restoreAllMocks();
424
+ calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h3' } });
425
+ handler = makeSessionStartHandler(client);
426
+ await handler({ agentKind: 'reviewer' });
427
+ // explicit input beats the env default.
428
+ expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=reviewer');
429
+ });
430
+ });
431
+ /**
432
+ * ORB-1818 - the rules INDEX. Production measured the last 15 calls of
433
+ * this tool at 21.8k-29.3k characters, 89 % of it the assembled rule
434
+ * text, none ever truncated. The default answer must now fit the plain
435
+ * 4k budget, with the full text one call away and byte-identical.
436
+ */
437
+ describe('ORB-1818 - rules index instead of the full rule text', () => {
438
+ /** 27 blocks of realistic size - the ORB workspace on 2026-09-03 had
439
+ * 27 enabled blocks and 17.746 characters of rule body. */
440
+ const BLOCK_TITLES = Array.from({ length: 27 }, (_, i) => `Rule block number ${i + 1} - a realistic operator-authored title`);
441
+ const RULES_TEXT = BLOCK_TITLES.map((t, i) => `${t}\n${'x'.repeat(650)}${i}`).join('\n\n');
442
+ const RULES_INDEX = BLOCK_TITLES.map((title) => ({ title, chars: 651 }));
443
+ const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length };
444
+ function stubDigest(rules) {
445
+ return stubByPath({
446
+ '/users/me/assigned-tickets': {
447
+ items: [
448
+ { ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress', projectId: 'proj-1' },
449
+ { ticketKey: 'ORB-43', title: 'Landed but idle', statusName: 'In Progress', projectId: 'proj-1', landedIdle: true, landedIdleWorkingDays: 3 },
450
+ ],
451
+ },
452
+ '/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
453
+ '/agent-instructions': rules,
454
+ '/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-09-03T10:00:00Z' },
455
+ '/projects/proj-1/git-health': { connections: [] },
456
+ '/v1/agent/messages': { messages: [] },
457
+ });
458
+ }
459
+ it('the default answer fits the 4k response budget and carries index + hash + handle, not the text', async () => {
460
+ stubDigest(rulesFull);
461
+ const res = await makeSessionStartHandler(client)();
462
+ const budgeted = applyResponseBudget('orboto_session_start', res, {});
463
+ // The whole point: no truncation, because there is nothing big left.
464
+ expect(budgeted.truncatedChars).toBe(0);
465
+ expect(budgeted.responseChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
466
+ // ...and the tool no longer buys itself a special budget.
467
+ expect(budgetFor('orboto_session_start', {})).toBe(DEFAULT_BUDGET_CHARS);
468
+ const structured = res.structuredContent;
469
+ expect(structured.rulesDelivery).toBe('index');
470
+ expect(structured.rules).toBe('');
471
+ // One line per enabled block, in delivery order.
472
+ expect(structured.rulesIndex).toEqual(BLOCK_TITLES);
473
+ expect(structured.rulesChars).toBe(RULES_TEXT.length);
474
+ // The way back must live in the STRUCTURED half too - Claude Code
475
+ // keeps that one and drops the text block.
476
+ expect(structured.rulesHowToRead).toContain('rulesOnly');
477
+ expect(structured.rulesHandle).toBeTruthy();
478
+ // The rest of the digest is unchanged (ORB-1799 landed-idle included).
479
+ expect(structured.inProgress).toHaveLength(2);
480
+ expect(structured.inProgress[1].landedIdle).toBe(true);
481
+ expect(structured.timer?.ticketKey).toBe('ORB-42');
482
+ const text = res.content[0].text;
483
+ expect(text).toContain('27 rule(s) bind you');
484
+ expect(text).toContain('1. Rule block number 1');
485
+ expect(text).toContain('27. Rule block number 27');
486
+ expect(text).not.toContain('xxxxxxxxxx');
487
+ });
488
+ it('the handle expands to the byte-identical rule text', async () => {
489
+ stubDigest(rulesFull);
490
+ const res = await makeSessionStartHandler(client)();
491
+ const { rulesHandle } = res.structuredContent;
492
+ const expand = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules' });
493
+ const chunks = [];
494
+ let cursor = 0;
495
+ let guard = 0;
496
+ while (cursor !== null && guard++ < 20) {
497
+ const page = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules', cursor });
498
+ const s = page.structuredContent;
499
+ chunks.push(s.chunk);
500
+ cursor = s.nextCursor;
501
+ }
502
+ expect(expand.structuredContent.totalChars).toBe(RULES_TEXT.length);
503
+ expect(chunks.join('')).toBe(RULES_TEXT);
504
+ });
505
+ it('an acked hash returns neither the text NOR the index', async () => {
506
+ const handler = makeSessionStartHandler(client);
507
+ stubDigest(rulesFull);
508
+ await handler();
509
+ vi.restoreAllMocks();
510
+ stubDigest({ rulesHash: 'hash1818aaaa', rulesUnchanged: true });
511
+ const second = await handler();
512
+ const structured = second.structuredContent;
513
+ expect(structured.rulesDelivery).toBe('ack');
514
+ expect(structured.rulesIndex).toBeUndefined();
515
+ expect(structured.rules).toBe('');
516
+ const text = second.content[0].text;
517
+ expect(text).toContain('Unchanged since this connection last delivered them');
518
+ expect(text).toContain('rulesOnly=true');
519
+ expect(text).not.toContain('Rule block number 1');
520
+ });
521
+ it('forceRules returns the full text inline, and the budget never cuts it', async () => {
522
+ stubDigest(rulesFull);
523
+ const res = await makeSessionStartHandler(client)({ forceRules: true });
524
+ const text = res.content[0].text;
525
+ expect(text).toContain(RULES_TEXT);
526
+ expect(res.structuredContent.rules).toBe(RULES_TEXT);
527
+ expect(res.structuredContent.rulesDelivery).toBe('full');
528
+ const budgeted = applyResponseBudget('orboto_session_start', res, {});
529
+ expect(budgeted.truncatedChars).toBe(0);
530
+ // Both halves survive: the structured one via PROTECTED_PATHS, the
531
+ // text one via the per-call PROTECT_TEXT_META flag...
532
+ expect(budgeted.result.structuredContent.rules).toBe(RULES_TEXT);
533
+ expect(budgeted.result.content[0].text).toContain(RULES_TEXT);
534
+ // ...and no misleading "response truncated" notice is appended.
535
+ expect(budgeted.result.content[0].text).not.toContain('Response truncated');
536
+ // The marker itself never reaches the wire.
537
+ expect(budgeted.result._meta).toBeUndefined();
538
+ });
539
+ it('rulesOnly returns just the rules - no tickets, no timer, no other calls', async () => {
540
+ const calls = stubDigest(rulesFull);
541
+ const res = await makeSessionStartHandler(client)({ rulesOnly: true });
542
+ expect(calls).toEqual(['/agent-instructions']);
543
+ const text = res.content[0].text;
544
+ expect(text).toContain(RULES_TEXT);
545
+ expect(text).not.toContain('## Your in-progress work');
546
+ const structured = res.structuredContent;
547
+ expect(structured.rules).toBe(RULES_TEXT);
548
+ expect(structured.rulesDelivery).toBe('full');
549
+ expect(structured.rulesChars).toBe(RULES_TEXT.length);
550
+ const budgeted = applyResponseBudget('orboto_session_start', res, {});
551
+ expect(budgeted.truncatedChars).toBe(0);
552
+ });
553
+ it('rulesOnly ignores the cached ack - the caller asked because it does not hold them', async () => {
554
+ const handler = makeSessionStartHandler(client);
555
+ stubDigest(rulesFull);
556
+ await handler();
557
+ vi.restoreAllMocks();
558
+ const calls = stubDigest(rulesFull);
559
+ await handler({ rulesOnly: true });
560
+ expect(calls[0]).toBe('/agent-instructions');
561
+ });
562
+ it('an API too old to send an index still delivers the full rules inline', async () => {
563
+ stubDigest({ instructions: RULES_TEXT, rulesHash: 'oldapi' });
564
+ const res = await makeSessionStartHandler(client)();
565
+ expect(res.structuredContent.rulesDelivery).toBe('full');
566
+ expect(res.content[0].text).toContain(RULES_TEXT);
567
+ });
568
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ORB-799 (gap-cluster 8) - re-parent existing tickets.
3
+ *
4
+ * `orboto_update_ticket` accepts most ticket fields but NOT
5
+ * `parentTicketId` / `parentTicketKey`, mirroring an API choice made
6
+ * during ORB-309: parent assignment was treated as a creation-time
7
+ * decision (set via `orboto_create_ticket`'s `parentTicketKey`) and the
8
+ * patch surface was deliberately narrowed.
9
+ *
10
+ * In practice that left a gap: re-parenting an existing ticket under a
11
+ * new epic (e.g. discovering during cluster-formation that ORB-600
12
+ * belongs under ORB-800 as a sub-ticket) had no MCP-side path and
13
+ * required falling back to the Bash wrapper's raw `patch /projects/...`
14
+ * route - documented in `feedback_mcp_reparent_limitation.md`.
15
+ *
16
+ * Design choice: separate `orboto_set_parent` tool rather than
17
+ * extending `orboto_update_ticket`'s patch shape. Symmetric to
18
+ * `orboto_set_milestone` which exists for the same reason - a
19
+ * dedicated tool with clearer semantics, easier permission gating
20
+ * thinking, and a natural `parentTicketKey: null` path for detaching.
21
+ */
22
+ import { z } from 'zod';
23
+ import { OrbotoApiError } from '../orboto-client.js';
24
+ import { resolveTicketByKey } from './shared.js';
25
+ export const setParentToolConfig = {
26
+ title: 'Set or clear a ticket\'s parent',
27
+ description: 'Re-parent an existing ticket. Pass `parentTicketKey` to make this ticket a sub-ticket of another, or pass `parentTicketKey: null` to detach from any parent (back to top-level). Both tickets must live in the same project. Cycle detection + self-parenting are rejected by the API. Symmetric to `orboto_set_milestone` - use this rather than `orboto_update_ticket` for parent moves.',
28
+ inputSchema: z.object({
29
+ ticketKey: z.string().min(3),
30
+ parentTicketKey: z.string().nullable().describe('New parent ticket key (e.g. "ACME-10"), or null to detach.'),
31
+ }).shape,
32
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
33
+ };
34
+ export function makeSetParentHandler(client) {
35
+ return async ({ ticketKey, parentTicketKey }) => {
36
+ const ticket = await resolveTicketByKey(client, ticketKey);
37
+ let parentTicketId = null;
38
+ let parentKey = null;
39
+ if (parentTicketKey !== null && parentTicketKey !== undefined) {
40
+ const parent = await resolveTicketByKey(client, parentTicketKey);
41
+ if (parent.projectId !== ticket.projectId) {
42
+ throw new Error(`Cross-project parenting is not allowed: ${ticket.ticketKey} is in a different project than ${parent.ticketKey}.`);
43
+ }
44
+ if (parent.id === ticket.id) {
45
+ throw new Error(`A ticket cannot be its own parent (${ticket.ticketKey}).`);
46
+ }
47
+ parentTicketId = parent.id;
48
+ parentKey = parent.ticketKey;
49
+ }
50
+ let updated;
51
+ try {
52
+ updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { parentTicketId });
53
+ }
54
+ catch (err) {
55
+ // Surface the API's own cycle-detection error verbatim if it
56
+ // catches a deeper cycle we couldn't see from one hop above.
57
+ if (err instanceof OrbotoApiError && err.status === 400) {
58
+ throw new Error(`Re-parent rejected by the API: ${err.body || 'cycle or constraint violation'}.`);
59
+ }
60
+ throw err;
61
+ }
62
+ return {
63
+ content: [{
64
+ type: 'text',
65
+ text: parentTicketId
66
+ ? `Re-parented [${updated.ticketKey}] under [${parentKey}].`
67
+ : `Detached [${updated.ticketKey}] from its parent (now top-level).`,
68
+ }],
69
+ structuredContent: {
70
+ ticketKey: updated.ticketKey,
71
+ parentTicketKey: parentKey,
72
+ parentTicketId,
73
+ },
74
+ };
75
+ };
76
+ }