@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,128 @@
1
+ /**
2
+ * ORB-273 Phase F - `orboto_query` MCP tool tests.
3
+ *
4
+ * Confirms the handler:
5
+ * - posts to /query with the right body shape
6
+ * - emits a human-readable text block + structured-content envelope
7
+ * - surfaces nextCursor + a "more pages" hint
8
+ * - propagates the syntax flag (jql vs oql) verbatim
9
+ */
10
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
11
+ import { OrbotoClient } from '../orboto-client.js';
12
+ import { makeQueryHandler } from './query.js';
13
+ beforeEach(() => { vi.restoreAllMocks(); });
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ function mockFetch(json) {
16
+ return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
17
+ ok: true, status: 200, statusText: 'OK',
18
+ json: async () => json,
19
+ });
20
+ }
21
+ describe('tools/query', () => {
22
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
23
+ it('renders matched tickets + structured payload', async () => {
24
+ const spy = mockFetch({
25
+ items: [
26
+ {
27
+ id: 't1',
28
+ ticketKey: 'ORB-1',
29
+ title: 'Fix the bug',
30
+ status: 'IN_PROGRESS',
31
+ statusName: 'In Progress',
32
+ statusCategory: 'in_progress',
33
+ priority: 'high',
34
+ type: 'bug',
35
+ dueDate: '2026-05-10',
36
+ estimatedTimeMinutes: 60,
37
+ loggedMinutes: 15,
38
+ assignees: [{ email: 'a@example.com' }],
39
+ labels: [{ name: 'frontend' }],
40
+ },
41
+ ],
42
+ nextCursor: null,
43
+ });
44
+ const result = await makeQueryHandler(client)({ oql: 'project = ORB' });
45
+ expect(spy).toHaveBeenCalledWith('https://orboto.example.com/query', expect.objectContaining({
46
+ method: 'POST',
47
+ body: JSON.stringify({ oql: 'project = ORB', syntax: 'oql', cursor: undefined, limit: 25 }),
48
+ }));
49
+ expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('ORB-1') });
50
+ // ORB-1699 - the default row is the shared lean projection: no uuid,
51
+ // no labels, no minutes; assignee NAMES (fullName || email).
52
+ expect(result.structuredContent).toMatchObject({
53
+ count: 1,
54
+ nextCursor: null,
55
+ tickets: [
56
+ expect.objectContaining({
57
+ key: 'ORB-1',
58
+ statusCategory: 'in_progress',
59
+ assigneeNames: ['a@example.com'],
60
+ }),
61
+ ],
62
+ });
63
+ const row = result.structuredContent.tickets[0];
64
+ expect(row.id).toBeUndefined();
65
+ expect(row.labels).toBeUndefined();
66
+ expect(row.estimatedTimeMinutes).toBeUndefined();
67
+ });
68
+ it('renders the empty-result text block', async () => {
69
+ mockFetch({ items: [], nextCursor: null });
70
+ const result = await makeQueryHandler(client)({ oql: 'priority = blocker' });
71
+ expect(result.content[0]).toEqual({ type: 'text', text: 'No tickets matched.' });
72
+ });
73
+ it('hints at the next cursor when more pages exist', async () => {
74
+ mockFetch({
75
+ items: [{ id: 't1', ticketKey: 'ORB-1', title: 'a', status: 'TODO', priority: 'normal', type: 'task' }],
76
+ nextCursor: 'opaque-cursor-token',
77
+ });
78
+ const result = await makeQueryHandler(client)({ oql: '' });
79
+ const text = result.content[0].text;
80
+ expect(text).toContain('opaque-cursor-token');
81
+ expect(result.structuredContent).toMatchObject({ nextCursor: 'opaque-cursor-token' });
82
+ });
83
+ it('forwards syntax="jql" verbatim so the API runs the JQL adapter', async () => {
84
+ const spy = mockFetch({ items: [], nextCursor: null });
85
+ await makeQueryHandler(client)({ oql: 'resolution = Done', syntax: 'jql' });
86
+ expect(spy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
87
+ body: JSON.stringify({ oql: 'resolution = Done', syntax: 'jql', cursor: undefined, limit: 25 }),
88
+ }));
89
+ });
90
+ });
91
+ // ORB-1699 - the ONE row builder, asserted from one place: the lean shape
92
+ // omits uuid/timestamps/minutes; verbose restores them. list_tickets,
93
+ // my_tickets and query all consume agentTicketListRow, so this single
94
+ // test pins the row for all three.
95
+ import { agentTicketListRow } from './shared.js';
96
+ describe('agentTicketListRow (ORB-1699)', () => {
97
+ const FULL = {
98
+ id: 'u-u-i-d', projectId: 'p1', milestoneId: null, milestoneName: 'M', ticketKey: 'ORB-9',
99
+ ticketNumber: 9, title: 'T', status: 'TODO', statusName: 'To Do', statusCategory: 'todo',
100
+ type: 'task', priority: 'high', estimatedTimeMinutes: 120, loggedMinutes: 30,
101
+ dueDate: '2026-09-01', isPrivate: false, createdAt: 'c', updatedAt: 'u',
102
+ assignees: [{ id: 'a1', email: 'a@x', fullName: 'Ada' }],
103
+ labels: [{ id: 'l1', name: 'bug' }],
104
+ };
105
+ it('lean row: decision fields only, defaults omitted', () => {
106
+ const row = agentTicketListRow(FULL);
107
+ // priority 'high' and dueDate present -> included; type stays only
108
+ // when it deviates from 'task'; status NAME is verbose-only.
109
+ expect(Object.keys(row).sort()).toEqual(['assigneeNames', 'dueDate', 'key', 'priority', 'statusCategory', 'title']);
110
+ expect(row.assigneeNames).toEqual(['Ada']);
111
+ });
112
+ it('lean row omits default-valued fields entirely', () => {
113
+ const row = agentTicketListRow({ ...FULL, priority: 'normal', type: 'task', dueDate: null, assignees: [] });
114
+ expect(Object.keys(row).sort()).toEqual(['key', 'statusCategory', 'title']);
115
+ });
116
+ it('verbose restores uuid, labels, minutes, timestamps', () => {
117
+ const row = agentTicketListRow(FULL, true);
118
+ expect(row.id).toBe('u-u-i-d');
119
+ expect(row.labels).toEqual(['bug']);
120
+ expect(row.estimatedTimeMinutes).toBe(120);
121
+ expect(row.loggedMinutes).toBe(30);
122
+ expect(row.createdAt).toBe('c');
123
+ });
124
+ it('waitingForGitIngestion appears only when it fires', () => {
125
+ expect(agentTicketListRow(FULL).waitingForGitIngestion).toBeUndefined();
126
+ expect(agentTicketListRow({ ...FULL, waitingForGitIngestion: true }).waitingForGitIngestion).toBe(true);
127
+ });
128
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ORB-1037 - RACI agent surfaces.
3
+ * - orboto_raci : read the RACI matrix (tickets x members x role).
4
+ * - orboto_set_raci : set a person's R/A/C/I role on a ticket.
5
+ * Both wrap the same routes the web UI uses. Setting a second Accountable
6
+ * returns the single-A error from Phase 1 as a friendly message, not a throw.
7
+ */
8
+ import { z } from 'zod';
9
+ import { OrbotoApiError } from '../orboto-client.js';
10
+ import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
11
+ import { resolveMilestoneByNameOrId } from './milestones.js';
12
+ const ROLES = ['R', 'A', 'C', 'I'];
13
+ async function resolveMemberId(client, projectId, email) {
14
+ const members = await client.get(`/projects/${projectId}/members`);
15
+ const m = members.find((x) => x.user.email.toLowerCase() === email.toLowerCase());
16
+ if (!m)
17
+ throw new Error(`No project member with email "${email}".`);
18
+ return m.userId;
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // orboto_raci - read the matrix
22
+ // ---------------------------------------------------------------------------
23
+ export const raciToolConfig = {
24
+ title: 'RACI matrix',
25
+ description: "Read a project's RACI matrix (tickets x members, cells = R/A/C/I). `milestone` scopes to one milestone; `epicsOnly` narrows rows to epics. RACI is OPT-IN per project: only use this when the project has RACI enabled (check `raciEnabled` on orboto_get_project / the primer). Do NOT raise or suggest RACI on projects that haven't enabled it. Returns empty when RACI is off.",
26
+ inputSchema: z.object({
27
+ projectKey: z.string().min(1).describe('Project key (e.g. "ORB").'),
28
+ milestone: z.string().optional().describe('Milestone name to scope the matrix.'),
29
+ epicsOnly: z.boolean().optional().describe('Only include epics as rows.'),
30
+ }).shape,
31
+ annotations: { readOnlyHint: true, idempotentHint: true },
32
+ };
33
+ export function makeRaciHandler(client) {
34
+ return async (input) => {
35
+ const project = await resolveProjectByKey(client, input.projectKey);
36
+ const qs = new URLSearchParams();
37
+ if (input.milestone) {
38
+ // ORB-1696 - shared resolver: key (ORB-M3), name or UUID, ambiguous
39
+ // name -> explicit error. Matches create_ticket/set_milestone/OQL.
40
+ const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
41
+ qs.set('milestoneId', m.id);
42
+ }
43
+ if (input.epicsOnly)
44
+ qs.set('epicsOnly', 'true');
45
+ const data = await client.get(`/projects/${project.id}/raci-matrix${qs.toString() ? `?${qs}` : ''}`);
46
+ if (!data.raciEnabled) {
47
+ return {
48
+ content: [{ type: 'text', text: `RACI is not enabled for ${project.key}. Turn it on in Project Settings.` }],
49
+ structuredContent: { raciEnabled: false, projectKey: project.key, members: [], rows: [] },
50
+ };
51
+ }
52
+ // Compact text rendering: one line per ticket listing each held role.
53
+ const lines = data.rows.map((r) => {
54
+ const byUser = new Map(data.members.map((m) => [m.userId, m.fullName]));
55
+ const held = Object.entries(r.cells)
56
+ .map(([uid, role]) => `${role}:${byUser.get(uid) ?? uid}`)
57
+ .join(', ');
58
+ return `[${r.ticketKey ?? '?'}] ${r.title}${held ? ` - ${held}` : ''}`;
59
+ });
60
+ const text = lines.length ? lines.join('\n') : 'No tickets match the filters.';
61
+ return {
62
+ content: [{ type: 'text', text: `RACI matrix for ${project.key}:\n${text}` }],
63
+ structuredContent: { raciEnabled: true, projectKey: project.key, members: data.members, rows: data.rows },
64
+ };
65
+ };
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // orboto_set_raci - write a role
69
+ // ---------------------------------------------------------------------------
70
+ export const setRaciToolConfig = {
71
+ title: 'Set a RACI role',
72
+ description: "Set a person's RACI role on a ticket: R (Responsible), A (Accountable, max one per ticket), C (Consulted), or I (Informed). RACI is OPT-IN per project: only use this when the project has RACI enabled (`raciEnabled` true) - do NOT suggest or set RACI on projects that haven't opted in. Resolves the user by email within the ticket's project. Requires ticket:manage_raci. A second Accountable is rejected with the current holder's name.",
73
+ inputSchema: z.object({
74
+ ticketKey: z.string().min(3).describe('Ticket key (e.g. "ORB-42").'),
75
+ userEmail: z.string().email().describe('Email of a project member.'),
76
+ role: z.enum(ROLES).describe('R, A, C, or I.'),
77
+ }).shape,
78
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
79
+ };
80
+ export function makeSetRaciHandler(client) {
81
+ return async ({ ticketKey, userEmail, role }) => {
82
+ const ticket = await resolveTicketByKey(client, ticketKey);
83
+ const userId = await resolveMemberId(client, ticket.projectId, userEmail);
84
+ try {
85
+ await client.put(`/projects/${ticket.projectId}/tickets/${ticket.id}/raci/${userId}`, { role });
86
+ }
87
+ catch (err) {
88
+ if (err instanceof OrbotoApiError && err.status === 409) {
89
+ // Single-A violation or RACI-not-enabled - surface the clean message
90
+ // the API put in the response body.
91
+ let msg = err.body;
92
+ try {
93
+ msg = JSON.parse(err.body).error ?? err.body;
94
+ }
95
+ catch { /* keep raw */ }
96
+ return {
97
+ content: [{ type: 'text', text: `Could not set ${role} on [${ticket.ticketKey}]: ${msg}` }],
98
+ structuredContent: { error: 'conflict', ticketKey: ticket.ticketKey, message: msg },
99
+ };
100
+ }
101
+ throw err;
102
+ }
103
+ return {
104
+ content: [{ type: 'text', text: `Set ${userEmail} as ${role} on [${ticket.ticketKey}].` }],
105
+ structuredContent: { ticketKey: ticket.ticketKey, userEmail, role },
106
+ };
107
+ };
108
+ }
@@ -0,0 +1,70 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeRaciHandler, makeSetRaciHandler } from './raci.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stub(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
9
+ calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
10
+ const r = responses.shift();
11
+ if (!r)
12
+ throw new Error(`unexpected extra fetch to ${url}`);
13
+ const status = r.status ?? 200;
14
+ return {
15
+ ok: status < 400,
16
+ status,
17
+ statusText: 'x',
18
+ json: async () => ('json' in r ? r.json : {}),
19
+ text: async () => r.text ?? (r.json ? JSON.stringify(r.json) : ''),
20
+ };
21
+ });
22
+ return calls;
23
+ }
24
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
25
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
26
+ const TICKET = { id: 't1', projectId: 'p1', ticketKey: 'ACME-42', title: 'Thing', type: 'task' };
27
+ const MEMBERS = [{ userId: 'u1', user: { email: 'dana@x.io', fullName: 'Dana' } }];
28
+ describe('orboto_set_raci (ORB-1037)', () => {
29
+ it('resolves ticket + member and PUTs the role', async () => {
30
+ const calls = stub([{ json: PROJ }, { json: TICKET }, { json: MEMBERS }, { json: { ok: true, role: 'A' } }]);
31
+ const res = await makeSetRaciHandler(client)({ ticketKey: 'ACME-42', userEmail: 'dana@x.io', role: 'A' });
32
+ const put = calls.find((c) => c.method === 'PUT');
33
+ expect(put.url).toContain('/projects/p1/tickets/t1/raci/u1');
34
+ expect(res.structuredContent).toMatchObject({ ticketKey: 'ACME-42', userEmail: 'dana@x.io', role: 'A' });
35
+ });
36
+ it('surfaces a single-Accountable 409 as a conflict envelope, not a throw', async () => {
37
+ stub([
38
+ { json: PROJ },
39
+ { json: TICKET },
40
+ { json: MEMBERS },
41
+ { status: 409, json: { error: 'This ticket already has an Accountable (Tom). Change their role first.' } },
42
+ ]);
43
+ const res = await makeSetRaciHandler(client)({ ticketKey: 'ACME-42', userEmail: 'dana@x.io', role: 'A' });
44
+ expect(res.structuredContent).toMatchObject({ error: 'conflict' });
45
+ expect(res.content[0].text).toMatch(/already has an Accountable/);
46
+ });
47
+ });
48
+ describe('orboto_raci (ORB-1037)', () => {
49
+ it('reads the matrix and renders one line per ticket', async () => {
50
+ const calls = stub([
51
+ { json: PROJ },
52
+ {
53
+ json: {
54
+ raciEnabled: true,
55
+ members: [{ userId: 'u1', fullName: 'Dana', email: 'dana@x.io' }],
56
+ rows: [{ ticketId: 't1', ticketKey: 'ACME-42', title: 'Thing', cells: { u1: 'A' } }],
57
+ },
58
+ },
59
+ ]);
60
+ const res = await makeRaciHandler(client)({ projectKey: 'ACME' });
61
+ expect(calls[1].url).toContain('/projects/p1/raci-matrix');
62
+ expect(res.content[0].text).toMatch(/ACME-42/);
63
+ expect(res.content[0].text).toMatch(/A:Dana/);
64
+ });
65
+ it('reports the disabled state', async () => {
66
+ stub([{ json: PROJ }, { json: { raciEnabled: false, members: [], rows: [] } }]);
67
+ const res = await makeRaciHandler(client)({ projectKey: 'ACME' });
68
+ expect(res.structuredContent).toMatchObject({ raciEnabled: false });
69
+ });
70
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * ORB-1409 (epic ORB-1390) - `orboto_requirements_spec`.
3
+ *
4
+ * Generates the requirements specification (Pflichtenheft) as Markdown:
5
+ * numbered functional requirements (FA-1, FA-1.1...) each traceable to its
6
+ * ticket and tagged muss/soll/kann from priority, plus non-functional
7
+ * requirements distilled from primer facts. `outlineVariant` picks the chapter
8
+ * naming/order: `neutral` (default), `industry` (VDI-3694 style), or `software`
9
+ * (IEEE-830 style). Private tickets/milestones are always excluded server-side.
10
+ * Wraps `POST /projects/:id/requirements-spec/generate` with `format: 'markdown'`.
11
+ * Money price mode needs budget:view on top of requirements_spec:generate (the
12
+ * API returns 403 otherwise).
13
+ *
14
+ * The route existed since ORB-1391; ORB-1409 fills in the missing MCP + skill
15
+ * surfaces (the Pflichtenheft feature shipped route + chat only).
16
+ */
17
+ import { z } from 'zod';
18
+ import { OrbotoApiError } from '../orboto-client.js';
19
+ import { resolveProjectByKey } from './shared.js';
20
+ const LOCALES = ['en', 'de', 'fr', 'it', 'es', 'sv'];
21
+ const OUTLINE_VARIANTS = ['neutral', 'industry', 'software'];
22
+ export const requirementsSpecToolConfig = {
23
+ title: 'Requirements specification (Pflichtenheft)',
24
+ description: 'Generate the requirements specification (Pflichtenheft) as Markdown - numbered functional requirements (FA-1, FA-1.1...) each traceable to its ticket and tagged muss/soll/kann from priority, plus non-functional requirements distilled from primer facts. '
25
+ + '`outlineVariant`: "neutral" (default), "industry" (VDI-3694 naming/order), or "software" (IEEE-830 naming/order). '
26
+ + '`locale` sets the document language (en/de/fr/it/es/sv) independently of your own locale; structure labels are catalog-translated. '
27
+ + '`priceMode`: "hours" (estimates, default), "money" (customer rates - needs the budget:view permission), or "lumpSum" (a flat price - pass `lumpSumAmount`). '
28
+ + 'Private tickets and milestones are always excluded, and internal cost/overhead is never included. Requires the requirements_spec:generate permission on the project.',
29
+ inputSchema: z.object({
30
+ projectKey: z.string().min(1).describe('Project key (e.g. "ORB").'),
31
+ outlineVariant: z.enum(OUTLINE_VARIANTS).default('neutral').describe('Outline variant (default "neutral"). "industry" = VDI-3694 style, "software" = IEEE-830 style.'),
32
+ locale: z.enum(LOCALES).default('en').describe('Document language (default "en").'),
33
+ priceMode: z.enum(['hours', 'money', 'lumpSum']).default('hours').describe('Pricing mode (default "hours"). "money" needs budget:view.'),
34
+ lumpSumAmount: z.number().nonnegative().optional().describe('Flat price for priceMode="lumpSum".'),
35
+ lumpSumCurrency: z.string().min(1).max(8).optional().describe('Currency for the lump sum (default EUR).'),
36
+ showAssigneeNames: z.boolean().optional().describe('Include assignee names (opt-in, default off).'),
37
+ showTicketKeys: z.boolean().optional().describe('Show ticket keys in the output (default on).'),
38
+ }).shape,
39
+ annotations: { readOnlyHint: true, idempotentHint: true },
40
+ };
41
+ export function makeRequirementsSpecHandler(client) {
42
+ return async (input) => {
43
+ const project = await resolveProjectByKey(client, input.projectKey);
44
+ const priceMode = input.priceMode ?? 'hours';
45
+ const options = {
46
+ priceMode,
47
+ showAssigneeNames: input.showAssigneeNames ?? false,
48
+ showTicketKeys: input.showTicketKeys ?? true,
49
+ };
50
+ if (priceMode === 'lumpSum') {
51
+ options.lumpSum = {
52
+ amount: input.lumpSumAmount ?? 0,
53
+ currency: input.lumpSumCurrency ?? 'EUR',
54
+ };
55
+ }
56
+ const body = {
57
+ outlineVariant: input.outlineVariant ?? 'neutral',
58
+ locale: input.locale ?? 'en',
59
+ options,
60
+ format: 'markdown',
61
+ };
62
+ let res;
63
+ try {
64
+ res = await client.post(`/projects/${project.id}/requirements-spec/generate`, body);
65
+ }
66
+ catch (err) {
67
+ if (err instanceof OrbotoApiError && err.status === 403 && priceMode === 'money') {
68
+ return {
69
+ content: [{ type: 'text', text: `Not permitted: money price mode needs the budget:view permission on ${project.key}.` }],
70
+ structuredContent: { error: 'forbidden', requiredPermission: 'budget:view' },
71
+ };
72
+ }
73
+ throw err;
74
+ }
75
+ return {
76
+ content: [{ type: 'text', text: res.markdown }],
77
+ structuredContent: {
78
+ projectKey: project.key,
79
+ outlineVariant: res.outlineVariant,
80
+ locale: res.locale,
81
+ priceMode: res.priceMode,
82
+ aiSkipped: res.aiSkipped,
83
+ reviewFlags: res.reviewFlags,
84
+ languageFallback: res.languageFallback,
85
+ languageNote: res.languageNote,
86
+ },
87
+ };
88
+ };
89
+ }
@@ -0,0 +1,62 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeRequirementsSpecHandler } from './requirements-spec.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stub(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
9
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
10
+ calls.push({ url: url.toString(), body });
11
+ const r = responses.shift();
12
+ if (!r)
13
+ throw new Error(`unexpected extra fetch to ${url}`);
14
+ const status = r.status ?? 200;
15
+ return {
16
+ ok: status < 400, status, statusText: 'OK',
17
+ json: async () => ('json' in r ? r.json : {}), text: async () => '',
18
+ };
19
+ });
20
+ return calls;
21
+ }
22
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
23
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
24
+ const MD = {
25
+ markdown: '# Pflichtenheft - Acme\n\n## Functional requirements\n\nFA-1 ...\n',
26
+ outlineVariant: 'neutral', locale: 'de', priceMode: 'hours',
27
+ aiSkipped: null,
28
+ reviewFlags: { executiveSummaryGenerated: false, contentTranslated: false, translatedFields: 0 },
29
+ languageFallback: false,
30
+ languageNote: null,
31
+ };
32
+ describe('orboto_requirements_spec (ORB-1409)', () => {
33
+ it('resolves the project and posts a markdown generate with outlineVariant + locale', async () => {
34
+ const calls = stub([{ json: PROJ }, { json: MD }]);
35
+ const res = await makeRequirementsSpecHandler(client)({ projectKey: 'ACME', outlineVariant: 'neutral', locale: 'de' });
36
+ expect(calls[1].url).toContain('/projects/p1/requirements-spec/generate');
37
+ expect(calls[1].body).toMatchObject({ outlineVariant: 'neutral', locale: 'de', format: 'markdown' });
38
+ expect(res.content[0].text).toContain('Pflichtenheft');
39
+ expect(res.structuredContent).toMatchObject({ projectKey: 'ACME', outlineVariant: 'neutral', locale: 'de', aiSkipped: null });
40
+ });
41
+ it('passes the industry outlineVariant through to the request body', async () => {
42
+ const calls = stub([{ json: PROJ }, { json: { ...MD, outlineVariant: 'industry' } }]);
43
+ await makeRequirementsSpecHandler(client)({ projectKey: 'ACME', outlineVariant: 'industry' });
44
+ expect(calls[1].body).toMatchObject({ outlineVariant: 'industry' });
45
+ });
46
+ it('passes lump-sum amount/currency in the options', async () => {
47
+ const calls = stub([{ json: PROJ }, { json: { ...MD, priceMode: 'lumpSum' } }]);
48
+ await makeRequirementsSpecHandler(client)({ projectKey: 'ACME', priceMode: 'lumpSum', lumpSumAmount: 5000, lumpSumCurrency: 'USD' });
49
+ expect(calls[1].body).toMatchObject({ options: { priceMode: 'lumpSum', lumpSum: { amount: 5000, currency: 'USD' } } });
50
+ });
51
+ it('surfaces a budget:view 403 for money mode as a clean structured error', async () => {
52
+ stub([{ json: PROJ }, { status: 403, json: { error: 'Forbidden' } }]);
53
+ const res = await makeRequirementsSpecHandler(client)({ projectKey: 'ACME', priceMode: 'money' });
54
+ expect(res.structuredContent.requiredPermission).toBe('budget:view');
55
+ expect(res.content[0].text).toMatch(/budget:view/);
56
+ });
57
+ it('defaults to neutral outline variant + hours mode', async () => {
58
+ const calls = stub([{ json: PROJ }, { json: MD }]);
59
+ await makeRequirementsSpecHandler(client)({ projectKey: 'ACME' });
60
+ expect(calls[1].body).toMatchObject({ outlineVariant: 'neutral', options: { priceMode: 'hours' } });
61
+ });
62
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ORB-1697 - `orboto_response_expand`: the way back from a truncated
3
+ * response.
4
+ *
5
+ * The central response budget (`../response-budget.ts`) caps what a
6
+ * single tool result may inject into the session, because a result is
7
+ * re-sent on every later request and therefore costs its size times the
8
+ * turns that follow it. A cap is only acceptable if nothing is lost, so
9
+ * every truncated response carries a handle, and this tool serves the
10
+ * omitted remainder from the in-process store in budget-sized chunks.
11
+ *
12
+ * Deliberately NOT a REST call: the handle addresses the payload the
13
+ * ORIGINAL call already produced, so expanding cannot return anything
14
+ * the caller was not already authorised to see, and it costs no
15
+ * additional API round-trip. The store is per-process and short-lived
16
+ * (15 min, 16 payloads) - an expired handle is answered with "re-run the
17
+ * original tool", never with a wrong or stale chunk.
18
+ */
19
+ import { z } from 'zod';
20
+ import { budgetFor, readPayload, resolvePath, } from '../response-budget.js';
21
+ export const responseExpandToolConfig = {
22
+ title: 'Fetch the omitted part of a truncated response',
23
+ description: 'Return the content a previous tool response omitted. When a result exceeds its response budget it comes back with a `__truncation` block containing a `handle` and the list of cut paths; pass that handle here to read the omitted remainder. Call with `handle` alone to list what is available, then with `path` (e.g. "description" or "ticketBundle.primer.markdown", or "$text" for the human-readable block) to read it, following `nextCursor` while it is not null. Handles live 15 minutes in the MCP server process - if one has expired, re-run the original tool. Read-only; returns nothing the original call did not already return.',
24
+ inputSchema: z.object({
25
+ handle: z.string().min(4).max(64).describe('From a response\'s `__truncation` block.'),
26
+ path: z.string().max(400).optional().describe('One of `__truncation.omitted[].path`, or "$text". Omit to list them.'),
27
+ cursor: z.number().int().min(0).optional().describe('`nextCursor` from the previous chunk.'),
28
+ }).shape,
29
+ annotations: { readOnlyHint: true, idempotentHint: true },
30
+ };
31
+ function expired(handle) {
32
+ const text = `No stored payload for handle "${handle}". Handles live 15 minutes in the MCP server process and only the `
33
+ + '16 most recent are kept, so this one has expired or the server restarted. Re-run the original tool to get a fresh handle.';
34
+ return { isError: true, content: [{ type: 'text', text }] };
35
+ }
36
+ function describeOmitted(entries) {
37
+ if (entries.length === 0)
38
+ return '(nothing recorded as omitted for this handle)';
39
+ return entries
40
+ .map((e) => e.kind === 'string'
41
+ ? `- ${e.path} (string, ${e.omittedChars ?? 0} chars omitted)`
42
+ : `- ${e.path} (array, ${e.omittedItems ?? 0} of ${(e.keptItems ?? 0) + (e.omittedItems ?? 0)} items omitted)`)
43
+ .join('\n');
44
+ }
45
+ export function makeResponseExpandHandler() {
46
+ return async (input) => {
47
+ const stored = readPayload(input.handle);
48
+ if (!stored)
49
+ return expired(input.handle);
50
+ const omitted = stored.omitted ?? [];
51
+ // No path - list what can be expanded. Cheap by construction: paths
52
+ // and counts only, never the content itself.
53
+ if (!input.path) {
54
+ const lines = [
55
+ `# Truncated content available for handle "${input.handle}"`,
56
+ `Original tool: ${stored.toolName}`,
57
+ '',
58
+ describeOmitted(omitted),
59
+ '',
60
+ 'Call again with one of these `path` values to read it.',
61
+ ];
62
+ return {
63
+ content: [{ type: 'text', text: lines.join('\n') }],
64
+ structuredContent: {
65
+ handle: input.handle,
66
+ toolName: stored.toolName,
67
+ omitted,
68
+ },
69
+ };
70
+ }
71
+ const raw = input.path === '$text'
72
+ ? stored.text
73
+ : resolvePath(stored.structuredContent, input.path);
74
+ if (raw === undefined) {
75
+ const text = `Path "${input.path}" does not exist in the stored payload for handle "${input.handle}". `
76
+ + `Available paths:\n${describeOmitted(omitted)}`;
77
+ return { isError: true, content: [{ type: 'text', text }] };
78
+ }
79
+ const full = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 1) ?? '';
80
+ // Leave room for the framing lines so a chunk plus its header still
81
+ // fits this tool's own budget - a continuation tool that gets
82
+ // truncated itself would be a loop, not a fix.
83
+ const chunkSize = Math.max(500, budgetFor('orboto_response_expand') - 800);
84
+ const cursor = Math.min(input.cursor ?? 0, full.length);
85
+ const chunk = full.slice(cursor, cursor + chunkSize);
86
+ const nextCursor = cursor + chunk.length < full.length ? cursor + chunk.length : null;
87
+ const header = `# ${input.path} (chars ${cursor}-${cursor + chunk.length} of ${full.length})`;
88
+ const footer = nextCursor === null
89
+ ? '(end of value)'
90
+ : `(more follows - call again with cursor=${nextCursor})`;
91
+ return {
92
+ content: [{ type: 'text', text: `${header}\n\n${chunk}\n\n${footer}` }],
93
+ structuredContent: {
94
+ handle: input.handle,
95
+ path: input.path,
96
+ chunk,
97
+ cursor,
98
+ nextCursor,
99
+ totalChars: full.length,
100
+ isString: typeof raw === 'string',
101
+ },
102
+ };
103
+ };
104
+ }