@adrata/adrata-mcp 1.0.0 → 1.0.2

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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Space tools for the Adrata MCP server — the nine surfaces a person now
3
+ * chooses between, made reachable by an agent.
4
+ *
5
+ * # Why this file exists
6
+ *
7
+ * The app grew a two-step chooser at `/spaces` over nine spaces in three
8
+ * families (Demand: Story, Market, Sell · Supply: Build, Design, Service ·
9
+ * Company: People, Finance, Cloud). Six of those nine read data this server
10
+ * had no tool for. An agent could not answer "how healthy are our customers",
11
+ * "what content do we have", "what is actually connected", or even "which
12
+ * spaces does this workspace have" — questions a person answers by clicking
13
+ * once.
14
+ *
15
+ * The gap was not that the endpoints were secret. It was that reaching them
16
+ * required `adrata_api_request` with a hand-written path, which is the raw
17
+ * bridge this server's typed tools exist to replace: no shape, no denominator,
18
+ * no idea which of several endpoints a surface actually joins. `/service`
19
+ * reads `expansion/health` AND `companies`; `/market` derives its segments
20
+ * from four collections at once. A tool that knows the join is the difference
21
+ * between an agent answering the question and an agent guessing at it.
22
+ *
23
+ * # Every tool here is a READ
24
+ *
25
+ * Deliberately. The nine spaces introduced no new write endpoints — they are
26
+ * projections over records that already have governed write tools (a deal is
27
+ * still written through the opportunity tools, a task through the action
28
+ * tools). Adding ungoverned writes here to "complete" a space would recreate
29
+ * exactly the defect the `governedWrite` docblock in ../api-bridge.js was
30
+ * written to close. When a space does grow a write, it routes through
31
+ * `governedWrite` like everything else.
32
+ *
33
+ * # Never a confident zero
34
+ *
35
+ * Every count in this file carries its denominator or its basis. A tool that
36
+ * answers `0` with nothing beside it is indistinguishable from a broken
37
+ * integration, an empty workspace, and a renamed column — and that ambiguity
38
+ * has already cost this codebase a real investigation. So `get_customer_health`
39
+ * says "15 of 150 accounts scored" rather than "15", `list_content_files` says
40
+ * whether the store is empty or absent, and any count derived from a list the
41
+ * server truncated says so rather than reporting the page size as the total.
42
+ */
43
+
44
+ /**
45
+ * The nine spaces, their families, and where each one's data actually lives.
46
+ *
47
+ * Kept as data rather than prose because two questions get asked constantly —
48
+ * "what are the spaces" and "which tool reads this one" — and a table answers
49
+ * both without the agent reading source. `dataSource: null` is meaningful, not
50
+ * missing: it records a space that HAS no server-side data, which is a real
51
+ * answer and a very different thing from one whose endpoint is unknown.
52
+ */
53
+ export const SPACES = [
54
+ {
55
+ id: 'story',
56
+ family: 'demand',
57
+ route: '/story',
58
+ reads: ['/api/v1/knowledge/files'],
59
+ tool: 'list_content_files',
60
+ },
61
+ {
62
+ id: 'market',
63
+ family: 'demand',
64
+ route: '/market',
65
+ reads: [
66
+ '/api/v1/icp-profiles',
67
+ '/api/v1/companies',
68
+ '/api/v1/opportunities',
69
+ '/api/v1/campaigns',
70
+ ],
71
+ tool: 'get_market_overview',
72
+ },
73
+ {
74
+ id: 'sell',
75
+ family: 'demand',
76
+ route: '/opportunities',
77
+ reads: ['/api/v1/opportunities', '/api/v1/pipeline-stages'],
78
+ tool: 'search_opportunities / get_pipeline_board',
79
+ },
80
+ {
81
+ id: 'build',
82
+ family: 'supply',
83
+ route: '/board',
84
+ reads: ['/api/v1/work-boards', '/api/v1/work-items', '/api/v1/work-scopes'],
85
+ tool: 'list_work_boards / list_work_scopes',
86
+ },
87
+ {
88
+ id: 'design',
89
+ family: 'supply',
90
+ route: '/design',
91
+ reads: [],
92
+ dataSource: null,
93
+ tool: null,
94
+ unreachable:
95
+ 'Design canvases are stored in the browser\'s localStorage on ONE device (key prefix adrata.starfield.design.<workspaceId>). There is no endpoint, no row, and no sync — so no agent, CLI, or second browser can read them, and this is a property of the feature rather than a missing tool. Reaching them would require the canvas to be persisted server-side first.',
96
+ },
97
+ {
98
+ id: 'service',
99
+ family: 'supply',
100
+ route: '/service',
101
+ reads: ['/api/v1/expansion/health', '/api/v1/companies'],
102
+ tool: 'get_customer_health',
103
+ },
104
+ {
105
+ id: 'people',
106
+ family: 'company',
107
+ route: '/people',
108
+ reads: ['/api/v1/people'],
109
+ tool: 'search_people',
110
+ },
111
+ {
112
+ id: 'finance',
113
+ family: 'company',
114
+ route: '/finance',
115
+ reads: ['/api/v1/opportunities', '/api/v1/usage/limits'],
116
+ tool: 'get_finance_overview',
117
+ },
118
+ {
119
+ id: 'cloud',
120
+ family: 'company',
121
+ route: '/cloud',
122
+ reads: ['/api/v1/providers/workspace-connectors', '/api/v1/oauth/email/providers'],
123
+ tool: 'list_workspace_connectors',
124
+ },
125
+ ];
126
+
127
+ export const SPACE_FAMILIES = ['demand', 'supply', 'company'];
128
+
129
+ /**
130
+ * Read a list payload without inventing a total.
131
+ *
132
+ * The API returns collections in several shapes (`{data: []}`, `{items: []}`,
133
+ * a bare array) and SOMETIMES a separate total. Guessing wrong yields the
134
+ * worst possible answer — a plausible number that is really the page size — so
135
+ * this returns the rows it actually found plus whether a server-side total was
136
+ * present, and the callers say "N returned" instead of "N exist" when it was
137
+ * not.
138
+ */
139
+ export function readCollection(payload) {
140
+ const rows = Array.isArray(payload)
141
+ ? payload
142
+ : Array.isArray(payload?.data)
143
+ ? payload.data
144
+ : Array.isArray(payload?.items)
145
+ ? payload.items
146
+ : Array.isArray(payload?.results)
147
+ ? payload.results
148
+ : null;
149
+
150
+ const total =
151
+ typeof payload?.total === 'number'
152
+ ? payload.total
153
+ : typeof payload?.count === 'number'
154
+ ? payload.count
155
+ : typeof payload?.pagination?.total === 'number'
156
+ ? payload.pagination.total
157
+ : null;
158
+
159
+ return {
160
+ rows: rows ?? [],
161
+ // `shapeRecognized:false` is why a caller can tell "the workspace has none"
162
+ // from "the response was not a collection at all" — the second is a bug
163
+ // report, the first is an answer.
164
+ shapeRecognized: rows !== null,
165
+ total,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Turn a count into a sentence that cannot be mistaken for a broken read.
171
+ *
172
+ * This is the single most reused thing in the file. `0` alone is ambiguous;
173
+ * "0 of 150 accounts have a health score" is an answer, and "the response was
174
+ * not a collection" is a defect the agent should report rather than average
175
+ * into a summary.
176
+ */
177
+ export function describeCount({ found, of, noun, ofNoun, shapeRecognized = true, truncated = false }) {
178
+ if (!shapeRecognized) {
179
+ return `Could not read ${noun}: the endpoint responded, but the body was not a collection. Treat this as a broken read, NOT as zero.`;
180
+ }
181
+ const base =
182
+ typeof of === 'number'
183
+ ? `${found} of ${of} ${ofNoun ?? noun}`
184
+ : `${found} ${noun}${truncated ? ' (page limit reached — more exist)' : ''}`;
185
+ if (found === 0 && typeof of === 'number' && of > 0) {
186
+ return `${base}. The store is reachable and genuinely empty for this workspace — this is a real zero, not a failed read.`;
187
+ }
188
+ if (found === 0) {
189
+ return `${base}. Zero rows AND no denominator available, so this cannot distinguish an empty workspace from an undeployed store — say so rather than reporting "none".`;
190
+ }
191
+ return base;
192
+ }
193
+
194
+ export function registerSpaceTools(server, { z, api, ok }) {
195
+ // ---------------------------------------------------------------- spaces
196
+ server.tool(
197
+ 'list_spaces',
198
+ `The nine spaces this workspace is organised into, in their three families (Demand: Story, Market, Sell · Supply: Build, Design, Service · Company: People, Finance, Cloud), each with the endpoints it reads and the tool that reads them.
199
+
200
+ Start here when a request names a surface rather than a record — "what's in Service", "show me the Story space" — because the space-to-data mapping is not guessable: /service joins customer health to companies, /market derives segments from four collections, and /design has no server-side data AT ALL (canvases live in one browser's localStorage, so nothing can read them remotely — the tool reports that as an unreachable surface rather than an empty one).
201
+
202
+ Entitlements are fetched when available, so this distinguishes "the workspace does not have this space" from "the space is empty".`,
203
+ {
204
+ family: z
205
+ .enum(['demand', 'supply', 'company'])
206
+ .optional()
207
+ .describe('Limit to one family. Omit for all nine spaces.'),
208
+ },
209
+ async ({ family }) => {
210
+ const spaces = family ? SPACES.filter((s) => s.family === family) : SPACES;
211
+
212
+ // Entitlements are advisory here: the space list is a property of the
213
+ // product, so a failed entitlement read must NOT empty it. It degrades
214
+ // to "unknown" and says so.
215
+ let entitlements = null;
216
+ let entitlementsError = null;
217
+ try {
218
+ const raw = await api('GET', '/api/v1/entitlements');
219
+ entitlements = readCollection(raw).shapeRecognized ? readCollection(raw).rows : raw;
220
+ } catch (err) {
221
+ entitlementsError = String(err?.message || err);
222
+ }
223
+
224
+ return ok({
225
+ spaces: spaces.map((s) => ({
226
+ ...s,
227
+ reachableByAgent: s.tool !== null,
228
+ })),
229
+ count: describeCount({
230
+ found: spaces.length,
231
+ of: SPACES.length,
232
+ noun: 'spaces',
233
+ ofNoun: 'spaces',
234
+ }),
235
+ unreachable: SPACES.filter((s) => s.tool === null).map((s) => ({
236
+ space: s.id,
237
+ why: s.unreachable,
238
+ })),
239
+ entitlements:
240
+ entitlementsError === null
241
+ ? entitlements
242
+ : {
243
+ unavailable: true,
244
+ error: entitlementsError,
245
+ note: 'Entitlements could not be read, so "which spaces does this workspace have" is UNKNOWN — not "none". The nine spaces above are the product\'s shape, not a statement about this workspace\'s grants.',
246
+ },
247
+ });
248
+ }
249
+ );
250
+
251
+ // --------------------------------------------------------------- service
252
+ server.tool(
253
+ 'get_customer_health',
254
+ `Customer health scores — the Service space. Every scored account with its health score, risk band, and the signals behind it, joined to the company record so the answer names accounts rather than ids.
255
+
256
+ Reports coverage as "N of M accounts scored", because the number that matters in a health review is usually the accounts with NO score — an unscored account is invisible risk, and a tool that returned only the scored ones would hide exactly the accounts a CSM needs to hear about.`,
257
+ {
258
+ limit: z.number().optional().describe('Max scored accounts to return (default 50).'),
259
+ riskOnly: z
260
+ .boolean()
261
+ .optional()
262
+ .describe('Return only accounts in a declining or at-risk band.'),
263
+ },
264
+ async ({ limit = 50, riskOnly = false }) => {
265
+ const healthRaw = await api('GET', '/api/v1/expansion/health', {
266
+ params: { limit },
267
+ });
268
+ const health = readCollection(healthRaw);
269
+
270
+ // The denominator. Without it "15 scored accounts" is unfalsifiable —
271
+ // it could be full coverage of a 15-account book or 10% of a 150-account
272
+ // one, and those call for opposite actions.
273
+ let companyTotal = null;
274
+ let companyTotalError = null;
275
+ try {
276
+ const companiesRaw = await api('GET', '/api/v1/companies', { params: { limit: 1 } });
277
+ const companies = readCollection(companiesRaw);
278
+ companyTotal = companies.total;
279
+ if (companyTotal === null) {
280
+ companyTotalError =
281
+ 'The companies endpoint returned no server-side total, so coverage cannot be stated as a fraction.';
282
+ }
283
+ } catch (err) {
284
+ companyTotalError = String(err?.message || err);
285
+ }
286
+
287
+ const rows = riskOnly
288
+ ? health.rows.filter((r) => {
289
+ const band = String(r?.riskBand ?? r?.risk_band ?? r?.status ?? '').toLowerCase();
290
+ return band.includes('risk') || band.includes('declin') || band.includes('red');
291
+ })
292
+ : health.rows;
293
+
294
+ return ok({
295
+ accounts: rows,
296
+ coverage: describeCount({
297
+ found: health.rows.length,
298
+ of: companyTotal,
299
+ noun: 'accounts with a health score',
300
+ ofNoun: 'companies',
301
+ shapeRecognized: health.shapeRecognized,
302
+ }),
303
+ ...(companyTotalError
304
+ ? {
305
+ coverageCaveat: `Denominator unavailable: ${companyTotalError} Report the scored count WITHOUT implying it is complete.`,
306
+ }
307
+ : {}),
308
+ ...(riskOnly
309
+ ? {
310
+ filtered: describeCount({
311
+ found: rows.length,
312
+ of: health.rows.length,
313
+ noun: 'at-risk accounts',
314
+ ofNoun: 'scored accounts',
315
+ }),
316
+ }
317
+ : {}),
318
+ });
319
+ }
320
+ );
321
+
322
+ // ----------------------------------------------------------------- story
323
+ server.tool(
324
+ 'list_content_files',
325
+ `Content files — the Story space. Everything the workspace has written or uploaded as narrative material, with type, owner, and linkage.
326
+
327
+ This is the CONTENT store, not the account wiki: use search_knowledge for wiki pages about an account, and this for the assets themselves.`,
328
+ {
329
+ limit: z.number().optional().describe('Max files to return (default 100).'),
330
+ includeDeleted: z.boolean().optional().describe('Include soft-deleted files (default false).'),
331
+ },
332
+ async ({ limit = 100, includeDeleted = false }) => {
333
+ const raw = await api('GET', '/api/v1/knowledge/files', {
334
+ params: { limit, isDeleted: includeDeleted ? undefined : false },
335
+ });
336
+ const files = readCollection(raw);
337
+ return ok({
338
+ files: files.rows,
339
+ count: describeCount({
340
+ found: files.rows.length,
341
+ of: files.total,
342
+ noun: 'content files',
343
+ shapeRecognized: files.shapeRecognized,
344
+ truncated: files.total === null && files.rows.length === limit,
345
+ }),
346
+ });
347
+ }
348
+ );
349
+
350
+ // ---------------------------------------------------------------- market
351
+ server.tool(
352
+ 'get_market_overview',
353
+ `The Market space: ICP profiles, the campaigns running against them, and how the company book distributes across them.
354
+
355
+ The app derives its segments CLIENT-SIDE from four collections at once, so an agent hitting any single endpoint sees a fragment of what the page shows. This tool performs the same join and reports each part's own count, so a partial answer is visible AS partial rather than silently thin.`,
356
+ {
357
+ limit: z.number().optional().describe('Max companies to sample for the distribution (default 250).'),
358
+ },
359
+ async ({ limit = 250 }) => {
360
+ const parts = {};
361
+ const errors = {};
362
+
363
+ // Each read is independent: one failing endpoint must degrade its own
364
+ // section, never blank the overview and never quietly contribute a zero
365
+ // to a total.
366
+ for (const [key, path, params] of [
367
+ ['icpProfiles', '/api/v1/icp-profiles', {}],
368
+ ['campaigns', '/api/v1/campaigns', { limit: 100 }],
369
+ ['companies', '/api/v1/companies', { limit }],
370
+ ['opportunities', '/api/v1/opportunities', { limit: 250 }],
371
+ ]) {
372
+ try {
373
+ const raw = await api('GET', path, { params });
374
+ parts[key] = readCollection(raw);
375
+ } catch (err) {
376
+ errors[key] = String(err?.message || err);
377
+ }
378
+ }
379
+
380
+ const summary = {};
381
+ for (const [key, coll] of Object.entries(parts)) {
382
+ summary[key] = describeCount({
383
+ found: coll.rows.length,
384
+ of: coll.total,
385
+ noun: key,
386
+ shapeRecognized: coll.shapeRecognized,
387
+ truncated: coll.total === null && coll.rows.length >= (key === 'companies' ? limit : 100),
388
+ });
389
+ }
390
+
391
+ return ok({
392
+ icpProfiles: parts.icpProfiles?.rows ?? [],
393
+ campaigns: parts.campaigns?.rows ?? [],
394
+ companySample: parts.companies?.rows?.length ?? 0,
395
+ counts: summary,
396
+ ...(Object.keys(errors).length
397
+ ? {
398
+ partial: true,
399
+ failedReads: errors,
400
+ warning:
401
+ 'One or more of the four collections failed to read. The sections that failed are MISSING, not empty — do not present this as a complete market picture.',
402
+ }
403
+ : {}),
404
+ });
405
+ }
406
+ );
407
+
408
+ // --------------------------------------------------------------- finance
409
+ server.tool(
410
+ 'get_finance_overview',
411
+ `The Finance space: booked and open pipeline value alongside the workspace's plan usage and limits.
412
+
413
+ Money figures are summed from the opportunities actually returned, and the tool says how many that was — a total computed from a truncated page is the classic way a revenue number comes out confidently wrong.`,
414
+ {},
415
+ async () => {
416
+ const parts = {};
417
+ const errors = {};
418
+ for (const [key, path, params] of [
419
+ ['opportunities', '/api/v1/opportunities', { limit: 500, scope: 'all' }],
420
+ ['usage', '/api/v1/usage/limits', {}],
421
+ ]) {
422
+ try {
423
+ parts[key] = await api('GET', path, { params });
424
+ } catch (err) {
425
+ errors[key] = String(err?.message || err);
426
+ }
427
+ }
428
+
429
+ const opps = parts.opportunities ? readCollection(parts.opportunities) : null;
430
+ let openValue = null;
431
+ let openCount = null;
432
+ if (opps?.shapeRecognized) {
433
+ const open = opps.rows.filter((o) => {
434
+ const s = String(o?.status ?? o?.stage ?? '').toLowerCase();
435
+ return !s.includes('won') && !s.includes('lost') && !s.includes('closed');
436
+ });
437
+ openCount = open.length;
438
+ openValue = open.reduce((sum, o) => sum + (Number(o?.value ?? o?.amount ?? 0) || 0), 0);
439
+ }
440
+
441
+ return ok({
442
+ openPipelineValue: openValue,
443
+ openDeals:
444
+ openCount === null
445
+ ? 'unavailable — opportunities did not read as a collection'
446
+ : describeCount({
447
+ found: openCount,
448
+ of: opps.rows.length,
449
+ noun: 'open deals',
450
+ ofNoun: 'opportunities returned',
451
+ }),
452
+ basis:
453
+ opps === null
454
+ ? 'No opportunities were read, so no money figure is stated. This is NOT $0.'
455
+ : describeCount({
456
+ found: opps.rows.length,
457
+ of: opps.total,
458
+ noun: 'opportunities summed',
459
+ shapeRecognized: opps.shapeRecognized,
460
+ truncated: opps.total === null && opps.rows.length >= 500,
461
+ }),
462
+ usage: parts.usage ?? { unavailable: errors.usage },
463
+ ...(Object.keys(errors).length ? { failedReads: errors } : {}),
464
+ });
465
+ }
466
+ );
467
+
468
+ // ----------------------------------------------------------------- cloud
469
+ server.tool(
470
+ 'list_workspace_connectors',
471
+ `The Cloud space: what is ACTUALLY connected in this workspace — the connector registry joined to the live OAuth grants — not the catalogue of what could be connected.
472
+
473
+ That distinction is the whole point of the tool. list_provider_catalog answers "what does Adrata integrate with"; this answers "what does THIS workspace have wired up, and is the grant still good". A connector present in the registry with no matching grant is a broken integration wearing a connected badge, and the tool surfaces that pairing explicitly.`,
474
+ {},
475
+ async () => {
476
+ const parts = {};
477
+ const errors = {};
478
+ for (const [key, path] of [
479
+ ['connectors', '/api/v1/providers/workspace-connectors'],
480
+ ['emailGrants', '/api/v1/oauth/email/providers'],
481
+ ]) {
482
+ try {
483
+ parts[key] = readCollection(await api('GET', path));
484
+ } catch (err) {
485
+ errors[key] = String(err?.message || err);
486
+ }
487
+ }
488
+
489
+ return ok({
490
+ connectors: parts.connectors?.rows ?? [],
491
+ emailGrants: parts.emailGrants?.rows ?? [],
492
+ counts: {
493
+ connectors: parts.connectors
494
+ ? describeCount({
495
+ found: parts.connectors.rows.length,
496
+ of: parts.connectors.total,
497
+ noun: 'workspace connectors',
498
+ shapeRecognized: parts.connectors.shapeRecognized,
499
+ })
500
+ : `unavailable: ${errors.connectors}`,
501
+ emailGrants: parts.emailGrants
502
+ ? describeCount({
503
+ found: parts.emailGrants.rows.length,
504
+ of: parts.emailGrants.total,
505
+ noun: 'connected email accounts',
506
+ shapeRecognized: parts.emailGrants.shapeRecognized,
507
+ })
508
+ : `unavailable: ${errors.emailGrants}`,
509
+ },
510
+ ...(Object.keys(errors).length
511
+ ? {
512
+ partial: true,
513
+ failedReads: errors,
514
+ warning:
515
+ 'A side of the join failed. "Nothing connected" CANNOT be concluded from this response — one of the two sources did not answer.',
516
+ }
517
+ : {}),
518
+ });
519
+ }
520
+ );
521
+
522
+ // -------------------------------------------------------------- calendar
523
+ server.tool(
524
+ 'get_calendar_agenda',
525
+ `The agenda a person sees on /calendar — events in a date window with their attendees and organizer, from the desktop read model (GET /api/v2/events/calendar).
526
+
527
+ Deliberately the v2 path, not /api/v1/events: the calendar page reads v2 and gets attendees and organizer resolved, where the v1 list returns the bare event. An agent briefing someone on their day needs to know WHO is in the room, so this reads the same model the person is looking at.`,
528
+ {
529
+ start: z.string().describe('ISO date/time for the start of the window, e.g. 2026-08-20T00:00:00Z'),
530
+ end: z.string().describe('ISO date/time for the end of the window.'),
531
+ },
532
+ async ({ start, end }) => {
533
+ const raw = await api('GET', '/api/v2/events/calendar', { params: { start, end } });
534
+ const events = readCollection(raw);
535
+ return ok({
536
+ window: { start, end },
537
+ events: events.rows,
538
+ count: describeCount({
539
+ found: events.rows.length,
540
+ of: events.total,
541
+ noun: 'events in this window',
542
+ shapeRecognized: events.shapeRecognized,
543
+ }),
544
+ note:
545
+ events.shapeRecognized && events.rows.length === 0
546
+ ? 'An empty window is a real answer — the calendar read succeeded and there is nothing scheduled between these times. Check the window before concluding the calendar is disconnected.'
547
+ : undefined,
548
+ });
549
+ }
550
+ );
551
+
552
+ // -------------------------------------------------------------- pipeline
553
+ server.tool(
554
+ 'get_pipeline_board',
555
+ `The Deals list as the /opportunities page assembles it: every pipeline stage in order, with the deals sitting in each one.
556
+
557
+ search_opportunities returns deals as a flat list; this returns the BOARD, so "what is stuck in negotiation" and "which stage is empty" are answerable. An empty stage is reported as an empty stage rather than omitted — a missing column and a column with no deals mean very different things to a forecast.`,
558
+ {
559
+ limit: z.number().optional().describe('Max deals to place on the board (default 250).'),
560
+ },
561
+ async ({ limit = 250 }) => {
562
+ const parts = {};
563
+ const errors = {};
564
+ for (const [key, path, params] of [
565
+ ['stages', '/api/v1/pipeline-stages', { limit: 100 }],
566
+ ['opportunities', '/api/v1/opportunities', { limit }],
567
+ ]) {
568
+ try {
569
+ parts[key] = readCollection(await api('GET', path, { params }));
570
+ } catch (err) {
571
+ errors[key] = String(err?.message || err);
572
+ }
573
+ }
574
+
575
+ if (!parts.stages || !parts.opportunities) {
576
+ return ok({
577
+ unavailable: true,
578
+ failedReads: errors,
579
+ warning:
580
+ 'The board needs BOTH stages and deals. One side failed, so no board is returned — an agent must not infer an empty pipeline from this.',
581
+ });
582
+ }
583
+
584
+ const byStage = new Map();
585
+ for (const stage of parts.stages.rows) {
586
+ const id = stage?.id ?? stage?.stageId ?? stage?.name;
587
+ byStage.set(String(id), { stage, deals: [] });
588
+ }
589
+ let unplaced = 0;
590
+ for (const deal of parts.opportunities.rows) {
591
+ const key = String(deal?.stageId ?? deal?.stage_id ?? deal?.stage ?? '');
592
+ const bucket = byStage.get(key);
593
+ if (bucket) bucket.deals.push(deal);
594
+ else unplaced += 1;
595
+ }
596
+
597
+ return ok({
598
+ board: [...byStage.values()].map(({ stage, deals }) => ({
599
+ stage,
600
+ dealCount: deals.length,
601
+ deals,
602
+ })),
603
+ counts: {
604
+ stages: describeCount({
605
+ found: parts.stages.rows.length,
606
+ of: parts.stages.total,
607
+ noun: 'pipeline stages',
608
+ shapeRecognized: parts.stages.shapeRecognized,
609
+ }),
610
+ deals: describeCount({
611
+ found: parts.opportunities.rows.length,
612
+ of: parts.opportunities.total,
613
+ noun: 'deals placed on the board',
614
+ shapeRecognized: parts.opportunities.shapeRecognized,
615
+ truncated: parts.opportunities.total === null && parts.opportunities.rows.length >= limit,
616
+ }),
617
+ },
618
+ // Deals whose stage id matches no known stage would otherwise vanish
619
+ // from the board and quietly shrink the pipeline total.
620
+ ...(unplaced > 0
621
+ ? {
622
+ unplacedDeals: `${unplaced} of ${parts.opportunities.rows.length} deals reference a stage that is not in the stage list. They are NOT on the board above, so the board's deal count is short by that many.`,
623
+ }
624
+ : {}),
625
+ });
626
+ }
627
+ );
628
+ }