@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,172 @@
1
+ /**
2
+ * ORB-885 - unit tests for `orboto_update_project`.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
6
+ import { makeUpdateProjectHandler, makeCreateProjectHandler, makeArchiveProjectHandler, } from './update-project.js';
7
+ beforeEach(() => { vi.restoreAllMocks(); });
8
+ afterEach(() => { vi.restoreAllMocks(); });
9
+ function stub(responses) {
10
+ const calls = [];
11
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
12
+ calls.push({
13
+ url: url.toString(),
14
+ method: init?.method ?? 'GET',
15
+ body: init?.body ? JSON.parse(init.body) : undefined,
16
+ });
17
+ const r = responses.shift();
18
+ if (!r)
19
+ throw new Error('unexpected extra fetch');
20
+ return {
21
+ ok: r.ok ?? true,
22
+ status: r.status ?? 200,
23
+ statusText: 'OK',
24
+ json: async () => ('json' in r ? r.json : {}),
25
+ text: async () => '',
26
+ };
27
+ });
28
+ return calls;
29
+ }
30
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
31
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
32
+ describe('orboto_update_project', () => {
33
+ it('PATCHes only the supplied fields', async () => {
34
+ const calls = stub([
35
+ { json: PROJ },
36
+ { json: { ...PROJ, description: 'A new description.' } },
37
+ ]);
38
+ const res = await makeUpdateProjectHandler(client)({
39
+ projectKey: 'ACME', patch: { description: 'A new description.' },
40
+ });
41
+ expect(calls[1]).toMatchObject({
42
+ method: 'PATCH',
43
+ url: 'https://orboto.example.com/projects/p1',
44
+ body: { description: 'A new description.' },
45
+ });
46
+ expect(res.structuredContent).toMatchObject({
47
+ key: 'ACME', description: 'A new description.',
48
+ });
49
+ });
50
+ it('passes null for description to clear it', async () => {
51
+ const calls = stub([
52
+ { json: { ...PROJ, description: 'old' } },
53
+ { json: { ...PROJ, description: null } },
54
+ ]);
55
+ await makeUpdateProjectHandler(client)({
56
+ projectKey: 'ACME', patch: { description: null },
57
+ });
58
+ expect(calls[1].body).toEqual({ description: null });
59
+ });
60
+ it('passes status + name together when both are in the patch', async () => {
61
+ const calls = stub([
62
+ { json: PROJ },
63
+ { json: { ...PROJ, name: 'Acme Renamed', status: 'archived' } },
64
+ ]);
65
+ const res = await makeUpdateProjectHandler(client)({
66
+ projectKey: 'ACME', patch: { name: 'Acme Renamed', status: 'archived' },
67
+ });
68
+ expect(calls[1].body).toEqual({ name: 'Acme Renamed', status: 'archived' });
69
+ expect(res.structuredContent).toMatchObject({ name: 'Acme Renamed', status: 'archived' });
70
+ });
71
+ it('surfaces a 403 on PATCH as OrbotoApiError', async () => {
72
+ stub([
73
+ { json: PROJ },
74
+ { ok: false, status: 403, json: { error: 'forbidden' } },
75
+ ]);
76
+ await expect(makeUpdateProjectHandler(client)({
77
+ projectKey: 'ACME', patch: { description: 'whatever' },
78
+ })).rejects.toBeInstanceOf(OrbotoApiError);
79
+ });
80
+ it('throws a clear error when the project key is unknown', async () => {
81
+ stub([
82
+ { ok: false, status: 404, json: { error: 'not found' } },
83
+ ]);
84
+ await expect(makeUpdateProjectHandler(client)({
85
+ projectKey: 'GHOST', patch: { description: 'x' },
86
+ })).rejects.toThrow(/Project "GHOST" not found/);
87
+ });
88
+ });
89
+ describe('orboto_create_project', () => {
90
+ it('POSTs name + key when both supplied', async () => {
91
+ const calls = stub([
92
+ { json: { id: 'new1', key: 'ACME', name: 'Acme', description: null, status: 'active' } },
93
+ ]);
94
+ const res = await makeCreateProjectHandler(client)({ name: 'Acme', key: 'ACME' });
95
+ expect(calls[0]).toMatchObject({
96
+ method: 'POST',
97
+ url: 'https://orboto.example.com/projects',
98
+ body: { name: 'Acme', key: 'ACME' },
99
+ });
100
+ expect(res.structuredContent).toMatchObject({ key: 'ACME', name: 'Acme', status: 'active' });
101
+ });
102
+ it('omits optional fields when not supplied so the API auto-derives the key', async () => {
103
+ const calls = stub([
104
+ { json: { id: 'new2', key: 'NN', name: 'No Name', description: null, status: 'active' } },
105
+ ]);
106
+ await makeCreateProjectHandler(client)({ name: 'No Name' });
107
+ expect(calls[0].body).toEqual({ name: 'No Name' });
108
+ });
109
+ it('passes description + customerId through verbatim', async () => {
110
+ const calls = stub([
111
+ { json: { id: 'new3', key: 'CUS', name: 'Custom', description: 'hello', status: 'active' } },
112
+ ]);
113
+ await makeCreateProjectHandler(client)({
114
+ name: 'Custom', description: 'hello', customerId: '11111111-2222-3333-4444-555555555555',
115
+ });
116
+ expect(calls[0].body).toEqual({
117
+ name: 'Custom',
118
+ description: 'hello',
119
+ customerId: '11111111-2222-3333-4444-555555555555',
120
+ });
121
+ });
122
+ it('surfaces a 409 (duplicate key) as OrbotoApiError', async () => {
123
+ stub([
124
+ { ok: false, status: 409, json: { error: 'project key already exists' } },
125
+ ]);
126
+ await expect(makeCreateProjectHandler(client)({ name: 'Dup', key: 'ACME' })).rejects.toBeInstanceOf(OrbotoApiError);
127
+ });
128
+ it('surfaces a 403 as OrbotoApiError', async () => {
129
+ stub([
130
+ { ok: false, status: 403, json: { error: 'forbidden' } },
131
+ ]);
132
+ await expect(makeCreateProjectHandler(client)({ name: 'NoPerms' })).rejects.toBeInstanceOf(OrbotoApiError);
133
+ });
134
+ });
135
+ describe('orboto_archive_project', () => {
136
+ it('resolves the project, then PATCHes status=archived', async () => {
137
+ const calls = stub([
138
+ { json: PROJ },
139
+ { json: { ...PROJ, status: 'archived' } },
140
+ ]);
141
+ const res = await makeArchiveProjectHandler(client)({ projectKey: 'ACME' });
142
+ expect(calls[0].url).toContain('/projects/by-key/ACME');
143
+ expect(calls[1]).toMatchObject({
144
+ method: 'PATCH',
145
+ url: 'https://orboto.example.com/projects/p1',
146
+ body: { status: 'archived' },
147
+ });
148
+ expect(res.structuredContent).toMatchObject({ status: 'archived', alreadyArchived: false });
149
+ });
150
+ it('is idempotent - short-circuits with no PATCH when already archived', async () => {
151
+ const calls = stub([
152
+ { json: { ...PROJ, status: 'archived' } },
153
+ ]);
154
+ const res = await makeArchiveProjectHandler(client)({ projectKey: 'ACME' });
155
+ expect(calls).toHaveLength(1); // only the resolve, no PATCH
156
+ expect(res.content[0].text).toMatch(/already archived/);
157
+ expect(res.structuredContent).toMatchObject({ alreadyArchived: true });
158
+ });
159
+ it('throws a clear error when the project is unknown', async () => {
160
+ stub([
161
+ { ok: false, status: 404, json: { error: 'not found' } },
162
+ ]);
163
+ await expect(makeArchiveProjectHandler(client)({ projectKey: 'GHOST' })).rejects.toThrow(/Project "GHOST" not found/);
164
+ });
165
+ it('surfaces a 403 on PATCH as OrbotoApiError', async () => {
166
+ stub([
167
+ { json: PROJ },
168
+ { ok: false, status: 403, json: { error: 'forbidden' } },
169
+ ]);
170
+ await expect(makeArchiveProjectHandler(client)({ projectKey: 'ACME' })).rejects.toBeInstanceOf(OrbotoApiError);
171
+ });
172
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * ORB-855 (LLM-Wiki Phase E) - MCP tools for the LLM-Wiki.
3
+ *
4
+ * Thin REST wrappers over the Phase B/C/D routes so an interactive AI
5
+ * client can ingest sources, query the wiki, lint it, and write back to it
6
+ * mid-task. No license gate (per the soft-cap pricing model). English-only
7
+ * descriptions per the skill/MCP English rule.
8
+ */
9
+ import { z } from 'zod';
10
+ import { resolveDocId } from './docs.js';
11
+ function text(t, structured) {
12
+ return { content: [{ type: 'text', text: t }], ...(structured ? { structuredContent: structured } : {}) };
13
+ }
14
+ // --- orboto_wiki_ingest_url ------------------------------------------------
15
+ export const wikiIngestUrlToolConfig = {
16
+ title: 'Ingest a URL into an LLM-Wiki space',
17
+ description: 'Fetch a public URL, extract its main article as Markdown, and create a SOURCE doc in the space. When the space has the LLM-Wiki enabled, this also enqueues the auto-ingest worker, which curates the source into wiki pages (immediately under auto-apply, or as a pending plan under review-gate). Wraps POST /spaces/:id/docs/ingest-url.',
18
+ inputSchema: z.object({
19
+ spaceId: z.string().uuid().describe('Target doc space (find via orboto_list_doc_spaces).'),
20
+ url: z.string().url().max(2048).describe('Public URL to import.'),
21
+ parentDocId: z.string().uuid().optional().describe('Nest the source under this doc.'),
22
+ }).shape,
23
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
24
+ };
25
+ export function makeWikiIngestUrlHandler(client) {
26
+ return async (input) => {
27
+ const body = { url: input.url };
28
+ if (input.parentDocId)
29
+ body.parentDocId = input.parentDocId;
30
+ const res = await client.post(`/spaces/${input.spaceId}/docs/ingest-url`, body);
31
+ return text(`Ingested source "${res.title}" (docId: ${res.docId}). If the space is LLM-Wiki-enabled the curation worker is now processing it.`, { docId: res.docId, title: res.title });
32
+ };
33
+ }
34
+ // --- orboto_wiki_ask -------------------------------------------------------
35
+ export const wikiAskToolConfig = {
36
+ title: 'Ask a question over the wiki (RAG with citations)',
37
+ description: 'Retrieval-augmented Q&A over the docs corpus, optionally scoped to one space. Returns an answer with numbered citations linking back to source docs. Requires both a chat AND an embedding provider (check orboto_ai_status). Wraps POST /ai/ask-docs.',
38
+ inputSchema: z.object({
39
+ question: z.string().min(1).max(2000).describe('Natural-language question.'),
40
+ spaceId: z.string().uuid().optional().describe('Scope the search to one space; omit for the whole corpus.'),
41
+ limit: z.number().int().min(1).max(20).optional().describe('Max source docs to retrieve (default 5).'),
42
+ }).shape,
43
+ annotations: { readOnlyHint: true, idempotentHint: true },
44
+ };
45
+ export function makeWikiAskHandler(client) {
46
+ return async (input) => {
47
+ const body = { question: input.question };
48
+ if (input.spaceId)
49
+ body.spaceId = input.spaceId;
50
+ if (input.limit)
51
+ body.limit = input.limit;
52
+ const res = await client.post(`/ai/ask-docs`, body);
53
+ const cites = res.citations.map((c) => `[${c.index}] ${c.title} - ${c.link}`).join('\n');
54
+ return text(`${res.answer}\n\n${cites ? `Sources:\n${cites}` : ''}`.trim(), { answer: res.answer, citations: res.citations, mode: res.mode });
55
+ };
56
+ }
57
+ // --- orboto_wiki_lint ------------------------------------------------------
58
+ export const wikiLintToolConfig = {
59
+ title: 'Run the LLM-Wiki lint pass on a space',
60
+ description: 'Scan an LLM-Wiki space for inconsistencies (orphan pages, missing cross-references, stale pages, unprocessed sources, and - when AI is configured - contradictions and undocumented concepts). Returns the open issues, each with a suggested fix. Wraps POST /spaces/:id/llm-wiki/lint.',
61
+ inputSchema: z.object({
62
+ spaceId: z.string().uuid().describe('The LLM-Wiki space to lint.'),
63
+ }).shape,
64
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
65
+ };
66
+ export function makeWikiLintHandler(client) {
67
+ return async (input) => {
68
+ const res = await client.post(`/spaces/${input.spaceId}/llm-wiki/lint`, {});
69
+ const lines = res.issues.length === 0
70
+ ? ['No open lint issues.']
71
+ : res.issues.map((i) => `- [${i.kind}] ${i.message}${i.suggestedFix ? ` (fix: ${i.suggestedFix})` : ''}`);
72
+ return text(`Lint complete: ${res.issues.length} open issue(s), ${res.resolved} auto-resolved.\n${lines.join('\n')}`, { issues: res.issues, detected: res.detected, resolved: res.resolved });
73
+ };
74
+ }
75
+ // --- orboto_wiki_plan_update / apply_plan ----------------------------------
76
+ export const wikiPlanUpdateToolConfig = {
77
+ title: 'Plan a wiki edit (dry-run, no writes)',
78
+ description: 'Turn a natural-language instruction into a concrete set of page operations (create / patch / append) WITHOUT writing anything. Returns a planId valid for 15 minutes plus the proposed ops. Review the ops, then call orboto_wiki_apply_plan to commit. Wraps POST /spaces/:id/docs/plan-update.',
79
+ inputSchema: z.object({
80
+ spaceId: z.string().uuid().describe('The wiki space to edit.'),
81
+ instruction: z.string().min(1).max(4000).describe('What to change, in plain language.'),
82
+ sourceDocId: z.string().uuid().optional().describe('A source doc to draw content from.'),
83
+ }).shape,
84
+ annotations: { readOnlyHint: true, idempotentHint: false },
85
+ };
86
+ export function makeWikiPlanUpdateHandler(client) {
87
+ return async (input) => {
88
+ const body = { instruction: input.instruction };
89
+ if (input.sourceDocId)
90
+ body.sourceDocId = input.sourceDocId;
91
+ const res = await client.post(`/spaces/${input.spaceId}/docs/plan-update`, body);
92
+ const ops = res.ops.map((o, i) => `${i + 1}. ${o.op} ${o.title ?? ''} - ${o.summary}`).join('\n');
93
+ return text(`Plan ${res.planId} (expires ${res.expiresAt}):\n${ops}\n\nApply with orboto_wiki_apply_plan(planId).`, { planId: res.planId, ops: res.ops, expiresAt: res.expiresAt });
94
+ };
95
+ }
96
+ export const wikiApplyPlanToolConfig = {
97
+ title: 'Apply a previously-planned wiki edit',
98
+ description: 'Commit the page operations from a plan created by orboto_wiki_plan_update. Applies every op atomically (each page edit is snapshotted for rollback). Fails with 410 if the plan has expired (>15 min) or was already applied. Wraps POST /spaces/:id/docs/apply-plan.',
99
+ inputSchema: z.object({
100
+ spaceId: z.string().uuid(),
101
+ planId: z.string().uuid().describe('The planId returned by orboto_wiki_plan_update.'),
102
+ }).shape,
103
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
104
+ };
105
+ export function makeWikiApplyPlanHandler(client) {
106
+ return async (input) => {
107
+ const res = await client.post(`/spaces/${input.spaceId}/docs/apply-plan`, { planId: input.planId });
108
+ return text(`Applied plan ${input.planId}: ${res.touchedDocs.length} page(s) updated.`, { touchedDocs: res.touchedDocs });
109
+ };
110
+ }
111
+ // --- orboto_wiki_record (convenience: plan + apply) ------------------------
112
+ export const wikiRecordToolConfig = {
113
+ title: 'Record a wiki update in one step (plan + apply)',
114
+ description: 'Convenience wrapper that plans an edit from your instruction and immediately applies it - use mid-task to capture a fact or update a page without the two-step review loop. Internally calls plan-update then apply-plan. For a reviewable change, use orboto_wiki_plan_update instead.',
115
+ inputSchema: z.object({
116
+ spaceId: z.string().uuid(),
117
+ instruction: z.string().min(1).max(4000).describe('What to record, in plain language.'),
118
+ sourceDocId: z.string().uuid().optional(),
119
+ }).shape,
120
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
121
+ };
122
+ export function makeWikiRecordHandler(client) {
123
+ return async (input) => {
124
+ const planBody = { instruction: input.instruction };
125
+ if (input.sourceDocId)
126
+ planBody.sourceDocId = input.sourceDocId;
127
+ const plan = await client.post(`/spaces/${input.spaceId}/docs/plan-update`, planBody);
128
+ const applied = await client.post(`/spaces/${input.spaceId}/docs/apply-plan`, { planId: plan.planId });
129
+ return text(`Recorded: ${applied.touchedDocs.length} page(s) updated.`, { planId: plan.planId, touchedDocs: applied.touchedDocs });
130
+ };
131
+ }
132
+ // --- orboto_wiki_append_section -------------------------------------------
133
+ export const wikiAppendSectionToolConfig = {
134
+ title: 'Append a section to a wiki page (idempotent)',
135
+ description: 'Append a Markdown section to a doc. Idempotent: appending identical content a second time is a no-op, so this is safe to call repeatedly (e.g. across multiple ingests). Wraps POST /docs/:id/append-section.',
136
+ inputSchema: z.object({
137
+ docId: z.string().min(1).describe('The page to append to - UUID or doc key (ORB-D12 / DOC-5).'),
138
+ content: z.string().min(1).max(50_000).describe('Markdown section to append.'),
139
+ }).shape,
140
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
141
+ };
142
+ export function makeWikiAppendSectionHandler(client) {
143
+ return async (input) => {
144
+ input.docId = await resolveDocId(client, input.docId);
145
+ const res = await client.post(`/docs/${input.docId}/append-section`, { content: input.content });
146
+ return text(res.appended ? 'Section appended.' : 'No change - an identical section is already present.', { appended: res.appended });
147
+ };
148
+ }
149
+ // --- orboto_wiki_save_answer -----------------------------------------------
150
+ export const wikiSaveAnswerToolConfig = {
151
+ title: 'Save a Q&A answer as a wiki page',
152
+ description: 'Turn an answer (e.g. from orboto_wiki_ask) into a curated wiki page with smart-links to its citations. Idempotent per (space, question): re-saving the same question updates the page instead of duplicating it. Wraps POST /ai/save-answer-to-wiki.',
153
+ inputSchema: z.object({
154
+ spaceId: z.string().uuid(),
155
+ question: z.string().min(3).max(1000),
156
+ answer: z.string().min(1).max(50_000),
157
+ title: z.string().max(200).optional().describe('Page title; defaults to the question.'),
158
+ parentDocId: z.string().uuid().optional(),
159
+ citations: z.array(z.object({ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'), title: z.string() })).max(50).optional().describe('Cited docs to smart-link (from orboto_wiki_ask).'),
160
+ }).shape,
161
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
162
+ };
163
+ export function makeWikiSaveAnswerHandler(client) {
164
+ return async (input) => {
165
+ const res = await client.post(`/ai/save-answer-to-wiki`, { ...input, citations: input.citations ?? [] });
166
+ return text(res.created ? `Saved as a new wiki page (docId: ${res.docId}).` : `Updated the existing wiki page (docId: ${res.docId}).`, { docId: res.docId, created: res.created });
167
+ };
168
+ }
169
+ // --- orboto_wiki_flag_stale ------------------------------------------------
170
+ export const wikiFlagStaleToolConfig = {
171
+ title: 'Flag (or unflag) a wiki page as possibly outdated',
172
+ description: 'Set or clear the "may be outdated" flag on a page. The flag surfaces passively as a pill in the UI; it does not change the content. Pass stale=false to clear. Wraps POST /docs/:id/flag-stale.',
173
+ inputSchema: z.object({
174
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
175
+ stale: z.boolean().optional().describe('true to flag (default), false to clear.'),
176
+ }).shape,
177
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
178
+ };
179
+ export function makeWikiFlagStaleHandler(client) {
180
+ return async (input) => {
181
+ input.docId = await resolveDocId(client, input.docId);
182
+ const res = await client.post(`/docs/${input.docId}/flag-stale`, { stale: input.stale ?? true });
183
+ return text(res.staleFlagged ? 'Page flagged as possibly outdated.' : 'Stale flag cleared.', { staleFlagged: res.staleFlagged });
184
+ };
185
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ORB-855 (LLM-Wiki Phase E) - wiki MCP tool tests. The injector is a
3
+ * stubbed fetch; we assert each tool hits the right route with the right
4
+ * body and renders a sensible result, plus one error-path per surface.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
8
+ import { makeWikiIngestUrlHandler, makeWikiAskHandler, makeWikiLintHandler, makeWikiPlanUpdateHandler, makeWikiApplyPlanHandler, makeWikiRecordHandler, makeWikiAppendSectionHandler, makeWikiFlagStaleHandler, makeWikiSaveAnswerHandler, } from './wiki.js';
9
+ beforeEach(() => { vi.restoreAllMocks(); });
10
+ afterEach(() => { vi.restoreAllMocks(); });
11
+ function stubJSON(responses) {
12
+ const calls = [];
13
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
14
+ calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body });
15
+ const r = responses.shift();
16
+ if (!r)
17
+ throw new Error('unexpected extra fetch');
18
+ return { ok: r.ok ?? true, status: r.status ?? 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
19
+ });
20
+ return calls;
21
+ }
22
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
23
+ const SPACE = 's0000000-0000-0000-0000-000000000001';
24
+ const DOC = 'd0000000-0000-0000-0000-000000000001';
25
+ describe('orboto_wiki_ingest_url', () => {
26
+ it('POSTs to ingest-url and reports the new source', async () => {
27
+ const calls = stubJSON([{ status: 201, json: { docId: DOC, title: 'Imported' } }]);
28
+ const res = await makeWikiIngestUrlHandler(client)({ spaceId: SPACE, url: 'https://example.com/a' });
29
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/spaces/${SPACE}/docs/ingest-url`, body: { url: 'https://example.com/a' } });
30
+ expect(res.content[0].text).toContain('Imported');
31
+ });
32
+ });
33
+ describe('orboto_wiki_ask', () => {
34
+ it('POSTs to /ai/ask-docs and renders citations', async () => {
35
+ stubJSON([{ json: { answer: 'It uses MVCC.', citations: [{ index: 1, title: 'Concurrency', link: '/spaces/x/docs/y' }], mode: 'hybrid' } }]);
36
+ const res = await makeWikiAskHandler(client)({ question: 'how does concurrency work?' });
37
+ const txt = res.content[0].text;
38
+ expect(txt).toContain('It uses MVCC.');
39
+ expect(txt).toContain('[1] Concurrency');
40
+ });
41
+ });
42
+ describe('orboto_wiki_lint', () => {
43
+ it('POSTs to lint and lists issues', async () => {
44
+ stubJSON([{ json: { issues: [{ kind: 'orphan', message: 'Page X is orphaned', suggestedFix: 'link it', docId: DOC }], detected: 1, resolved: 0 } }]);
45
+ const res = await makeWikiLintHandler(client)({ spaceId: SPACE });
46
+ expect(res.content[0].text).toContain('[orphan] Page X is orphaned');
47
+ });
48
+ it('propagates a 403 as OrbotoApiError', async () => {
49
+ stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
50
+ await expect(makeWikiLintHandler(client)({ spaceId: SPACE })).rejects.toBeInstanceOf(OrbotoApiError);
51
+ });
52
+ });
53
+ describe('orboto_wiki_plan_update + apply_plan', () => {
54
+ it('plan-update returns a planId + op summary', async () => {
55
+ const calls = stubJSON([{ json: { planId: 'p1', ops: [{ op: 'create', title: 'New', summary: 'add page' }], expiresAt: '2026-01-01T00:15:00Z' } }]);
56
+ const res = await makeWikiPlanUpdateHandler(client)({ spaceId: SPACE, instruction: 'add a page about X' });
57
+ expect(calls[0].url).toBe(`https://orboto.example.com/spaces/${SPACE}/docs/plan-update`);
58
+ expect(res.content[0].text).toContain('Plan p1');
59
+ });
60
+ it('apply-plan reports touched docs', async () => {
61
+ stubJSON([{ json: { touchedDocs: [DOC] } }]);
62
+ const res = await makeWikiApplyPlanHandler(client)({ spaceId: SPACE, planId: 'p1' });
63
+ expect(res.content[0].text).toContain('1 page(s) updated');
64
+ });
65
+ it('apply-plan surfaces a 410 expired plan as OrbotoApiError', async () => {
66
+ stubJSON([{ ok: false, status: 410, json: { error: 'Plan has expired' } }]);
67
+ await expect(makeWikiApplyPlanHandler(client)({ spaceId: SPACE, planId: 'p1' })).rejects.toBeInstanceOf(OrbotoApiError);
68
+ });
69
+ });
70
+ describe('orboto_wiki_record', () => {
71
+ it('chains plan-update then apply-plan', async () => {
72
+ const calls = stubJSON([
73
+ { json: { planId: 'p2' } },
74
+ { json: { touchedDocs: [DOC] } },
75
+ ]);
76
+ const res = await makeWikiRecordHandler(client)({ spaceId: SPACE, instruction: 'note that X happened' });
77
+ expect(calls[0].url).toContain('/plan-update');
78
+ expect(calls[1].url).toContain('/apply-plan');
79
+ expect(res.content[0].text).toContain('1 page(s) updated');
80
+ });
81
+ });
82
+ describe('orboto_wiki_append_section', () => {
83
+ it('reports a no-op when nothing was appended', async () => {
84
+ stubJSON([{ json: { appended: false } }]);
85
+ const res = await makeWikiAppendSectionHandler(client)({ docId: DOC, content: 'x' });
86
+ expect(res.content[0].text).toMatch(/already present/);
87
+ });
88
+ });
89
+ describe('orboto_wiki_save_answer', () => {
90
+ it('POSTs to save-answer-to-wiki and reports created vs updated', async () => {
91
+ const calls = stubJSON([{ json: { docId: DOC, created: true } }]);
92
+ const res = await makeWikiSaveAnswerHandler(client)({ spaceId: SPACE, question: 'how?', answer: 'like this' });
93
+ expect(calls[0]).toMatchObject({ method: 'POST', url: 'https://orboto.example.com/ai/save-answer-to-wiki', body: { spaceId: SPACE, question: 'how?', answer: 'like this', citations: [] } });
94
+ expect(res.content[0].text).toMatch(/new wiki page/);
95
+ });
96
+ });
97
+ describe('orboto_wiki_flag_stale', () => {
98
+ it('defaults stale=true and reports the flag', async () => {
99
+ const calls = stubJSON([{ json: { staleFlagged: true } }]);
100
+ const res = await makeWikiFlagStaleHandler(client)({ docId: DOC });
101
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/docs/${DOC}/flag-stale`, body: { stale: true } });
102
+ expect(res.content[0].text).toMatch(/outdated/);
103
+ });
104
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ORB-1613 - unit tests for orboto_work_next, the collision-aware dispatch
3
+ * pull. Unlike orboto_work_start, this tool never resolves a ticket key
4
+ * client-side - the `projectKey` is passed straight through to
5
+ * POST /work-sessions/next, which resolves it server-side. Every test
6
+ * therefore stubs exactly ONE fetch call.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { OrbotoClient } from '../orboto-client.js';
10
+ import { makeWorkNextHandler } from './work-sessions.js';
11
+ beforeEach(() => { vi.restoreAllMocks(); });
12
+ afterEach(() => { vi.restoreAllMocks(); });
13
+ function stub(responses) {
14
+ const calls = [];
15
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
16
+ calls.push({
17
+ url: url.toString(),
18
+ method: init?.method ?? 'GET',
19
+ body: init?.body ? JSON.parse(init.body) : undefined,
20
+ });
21
+ const r = responses.shift();
22
+ if (!r)
23
+ throw new Error('unexpected extra fetch');
24
+ return {
25
+ ok: r.ok ?? true,
26
+ status: r.status ?? 200,
27
+ statusText: 'OK',
28
+ json: async () => ('json' in r ? r.json : {}),
29
+ text: async () => r.text ?? '',
30
+ };
31
+ });
32
+ return calls;
33
+ }
34
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
35
+ const SESSION = {
36
+ id: 'ws1',
37
+ ticketId: 't1',
38
+ role: 'implementation',
39
+ status: 'active',
40
+ startedAt: '2026-07-24T10:00:00Z',
41
+ leaseUntil: '2026-07-24T10:15:00Z',
42
+ activeTimerId: 'timer1',
43
+ commitSha: null,
44
+ ticketKey: 'ACME-42',
45
+ };
46
+ const RESERVED = {
47
+ session: SESSION,
48
+ reused: false,
49
+ rulesHash: 'abc123',
50
+ rulesUnchanged: false,
51
+ rules: 'Follow the ticket-first rule.',
52
+ primer: { markdown: '# Primer\nProject conventions.', totalTokens: 42 },
53
+ ticket: {
54
+ ticketKey: 'ACME-42',
55
+ title: 'Do the thing',
56
+ description: 'Needs doing.',
57
+ status: 'TODO',
58
+ statusName: 'To Do',
59
+ priority: 'high',
60
+ type: 'task',
61
+ },
62
+ checklists: [],
63
+ dependencies: { blockedBy: [], blocks: [] },
64
+ gitHealth: [],
65
+ siblingSessions: [],
66
+ };
67
+ describe('orboto_work_next', () => {
68
+ it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
69
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3 } }]);
70
+ const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
71
+ expect(calls).toHaveLength(1);
72
+ expect(calls[0].method).toBe('POST');
73
+ expect(calls[0].url).toContain('/work-sessions/next');
74
+ expect(calls[0].body?.projectKey).toBe('ACME');
75
+ expect(String(calls[0].body?.agentSessionToken)).toMatch(/^mcp-/);
76
+ const text = res.content[0].text;
77
+ expect(text).toContain('Reserved ACME-42');
78
+ expect(text).toContain('ws1');
79
+ expect(text).toContain('## Working rules');
80
+ expect(text).toContain('Follow the ticket-first rule.');
81
+ expect(text).toContain('## Project primer');
82
+ expect(text).toContain('## Ticket: ACME-42');
83
+ expect(text).toContain('Needs doing.');
84
+ const structured = res.structuredContent;
85
+ expect(structured.reserved?.ticket.ticketKey).toBe('ACME-42');
86
+ expect(structured.reason).toBeNull();
87
+ });
88
+ it('caches the rules hash across calls and renders "Renewed" on a reused session', async () => {
89
+ const handler = makeWorkNextHandler(client);
90
+ stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
91
+ await handler({ projectKey: 'ACME' });
92
+ const calls2 = stub([{
93
+ json: {
94
+ reserved: { ...RESERVED, reused: true, rulesUnchanged: true, rules: undefined },
95
+ reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1,
96
+ },
97
+ }]);
98
+ const res2 = await handler({ projectKey: 'ACME' });
99
+ expect(calls2[0].body?.knownRulesHash).toBe('abc123');
100
+ const text2 = res2.content[0].text;
101
+ expect(text2).toContain('Unchanged since your last call');
102
+ expect(text2).toContain('Renewed your existing');
103
+ });
104
+ it('renders a structured empty result with a retry hint (all-leased)', async () => {
105
+ stub([{
106
+ json: {
107
+ reserved: null, reason: 'all-leased', retryAfterSeconds: 420,
108
+ earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4,
109
+ },
110
+ }]);
111
+ const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
112
+ expect(res.isError).toBeUndefined();
113
+ const text = res.content[0].text;
114
+ expect(text).toContain('all-leased');
115
+ expect(text).toContain('Retry in ~420s');
116
+ expect(text).toContain('2026-07-24T10:15:00Z');
117
+ expect(text).toContain('Candidates considered: 4');
118
+ const structured = res.structuredContent;
119
+ expect(structured.reserved).toBeNull();
120
+ expect(structured.reason).toBe('all-leased');
121
+ expect(structured.retryAfterSeconds).toBe(420);
122
+ });
123
+ it('renders "no derivable ETA" when the API found nothing to derive a hint from', async () => {
124
+ stub([{
125
+ json: {
126
+ reserved: null, reason: 'none-matching', retryAfterSeconds: null,
127
+ earliestFreeAt: null, candidatesConsidered: 0,
128
+ },
129
+ }]);
130
+ const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
131
+ const text = res.content[0].text;
132
+ expect(text).toContain('none-matching');
133
+ expect(text).toContain('No derivable ETA');
134
+ expect(text).not.toContain('Retry in');
135
+ });
136
+ it('forwards role, leaseSeconds, and resourceClaims to the API', async () => {
137
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
138
+ await makeWorkNextHandler(client)({
139
+ projectKey: 'ACME',
140
+ role: 'review',
141
+ leaseSeconds: 300,
142
+ resourceClaims: [{ kind: 'path', value: 'src/**', mode: 'write' }],
143
+ onConflict: 'queue',
144
+ });
145
+ expect(calls[0].body?.role).toBe('review');
146
+ expect(calls[0].body?.leaseSeconds).toBe(300);
147
+ expect(calls[0].body?.resourceClaims).toEqual([{ kind: 'path', value: 'src/**', mode: 'write' }]);
148
+ expect(calls[0].body?.onConflict).toBe('queue');
149
+ });
150
+ });