@ornncompute/cli 0.2.18 → 0.2.20

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,616 @@
1
+ import { z } from "zod";
2
+ import { defineStaffMcpCapability, preview } from "../capability.js";
3
+ import { seg } from "./staff-shared.js";
4
+ const companyFields = {
5
+ name: z.string().min(1),
6
+ ownerTags: z.string().default(""),
7
+ /** The editable company summary that sits above the comment thread. */
8
+ notes: z.string().optional(),
9
+ };
10
+ /** Narrowing every list accepts, so an agent need not hold the whole table. */
11
+ const listFilterFields = {
12
+ q: z.string().min(1).optional(),
13
+ limit: z.number().int().positive().max(500).optional(),
14
+ };
15
+ function listQuery(parsed) {
16
+ const parts = [];
17
+ if (parsed.q)
18
+ parts.push(`q=${encodeURIComponent(parsed.q)}`);
19
+ if (parsed.limit)
20
+ parts.push(`limit=${parsed.limit}`);
21
+ return parts.length ? `?${parts.join("&")}` : "";
22
+ }
23
+ export const gtmCompanyListInput = z.object({ ...listFilterFields });
24
+ export const gtmCompanyListCapability = defineStaffMcpCapability({
25
+ id: "gtm.company.list",
26
+ domain: "gtm",
27
+ roles: ["reviewer", "admin"],
28
+ description: "GTM staff only: list plaintext CRM companies, their owner tags and summary notes. Narrow with q, cap with limit. A company's running discussion is its comment thread.",
29
+ mutation: "read",
30
+ http: { method: "GET", path: "/internal/v1/gtm/companies" },
31
+ input: gtmCompanyListInput,
32
+ execute: (ctx, input) => ctx.fetch(`/internal/v1/gtm/companies${listQuery(gtmCompanyListInput.parse(input))}`, {
33
+ method: "GET",
34
+ }),
35
+ });
36
+ export const gtmCompanyWriteInput = z.object({
37
+ companyId: z.number().int().positive().optional(),
38
+ ...companyFields,
39
+ confirm: z.boolean().optional(),
40
+ });
41
+ export const gtmCompanyWriteCapability = defineStaffMcpCapability({
42
+ id: "gtm.company.write",
43
+ domain: "gtm",
44
+ roles: ["reviewer", "admin"],
45
+ description: "GTM staff only: create a plaintext CRM company, or replace its name, owner tags and summary notes when companyId is supplied. Requires confirm: true.",
46
+ mutation: "preview-confirm",
47
+ http: { method: "POST", path: "/internal/v1/gtm/companies" },
48
+ input: gtmCompanyWriteInput,
49
+ execute: async (ctx, input) => {
50
+ const parsed = gtmCompanyWriteInput.parse(input);
51
+ const body = { name: parsed.name, owner_tags: parsed.ownerTags };
52
+ if (parsed.notes !== undefined)
53
+ body.notes = parsed.notes;
54
+ if (!ctx.confirmed) {
55
+ return preview("gtm.company.write", { companyId: parsed.companyId, ...body });
56
+ }
57
+ return ctx.fetch(parsed.companyId
58
+ ? `/internal/v1/gtm/companies/${seg(String(parsed.companyId))}`
59
+ : "/internal/v1/gtm/companies", { method: parsed.companyId ? "PATCH" : "POST", body });
60
+ },
61
+ });
62
+ const dealFields = {
63
+ customer: z.string().min(1),
64
+ owner: z.string().min(1),
65
+ supplier: z.string().min(1),
66
+ gpuType: z.string().min(1),
67
+ };
68
+ export const gtmDealListInput = z.object({ ...listFilterFields });
69
+ export const gtmDealListCapability = defineStaffMcpCapability({
70
+ id: "gtm.deal.list",
71
+ domain: "gtm",
72
+ roles: ["reviewer", "admin"],
73
+ description: "GTM staff only: list manually entered deals. Each deal has only customer, Ornn owner, supplier, and GPU type. Narrow with q, cap with limit.",
74
+ mutation: "read",
75
+ http: { method: "GET", path: "/internal/v1/gtm/deals" },
76
+ input: gtmDealListInput,
77
+ execute: (ctx, input) => ctx.fetch(`/internal/v1/gtm/deals${listQuery(gtmDealListInput.parse(input))}`, {
78
+ method: "GET",
79
+ }),
80
+ });
81
+ export const gtmDealWriteInput = z.object({
82
+ dealId: z.number().int().positive().optional(),
83
+ ...dealFields,
84
+ /** The negotiation's one link to Commerce. "" clears it. */
85
+ listingId: z.union([z.string().uuid(), z.literal("")]).optional(),
86
+ /** The negotiation's own bid. "" clears it. Never writes the listing. */
87
+ currentBid: z.string().optional(),
88
+ confirm: z.boolean().optional(),
89
+ });
90
+ export const gtmDealWriteCapability = defineStaffMcpCapability({
91
+ id: "gtm.deal.write",
92
+ domain: "gtm",
93
+ roles: ["reviewer", "admin"],
94
+ description: 'GTM staff only: create a negotiation, or replace it when dealId is supplied. The customer also associates it to that company. listingId links it to one Commerce listing and is the only connection between the two; currentBid records what the buyer offered and never writes the listing. Pass "" to clear either. Requires confirm: true.',
95
+ mutation: "preview-confirm",
96
+ http: { method: "POST", path: "/internal/v1/gtm/deals" },
97
+ input: gtmDealWriteInput,
98
+ execute: async (ctx, input) => {
99
+ const parsed = gtmDealWriteInput.parse(input);
100
+ const body = {
101
+ customer: parsed.customer,
102
+ owner: parsed.owner,
103
+ supplier: parsed.supplier,
104
+ gpu_type: parsed.gpuType,
105
+ };
106
+ // Omitted leaves the stored value alone; "" clears it.
107
+ if (parsed.listingId !== undefined)
108
+ body.listing_id = parsed.listingId;
109
+ if (parsed.currentBid !== undefined)
110
+ body.current_bid = parsed.currentBid;
111
+ if (!ctx.confirmed) {
112
+ return preview("gtm.deal.write", { dealId: parsed.dealId, ...body });
113
+ }
114
+ return ctx.fetch(parsed.dealId
115
+ ? `/internal/v1/gtm/deals/${seg(String(parsed.dealId))}`
116
+ : "/internal/v1/gtm/deals", { method: parsed.dealId ? "PATCH" : "POST", body });
117
+ },
118
+ });
119
+ export const gtmCommentListInput = z.object({
120
+ dealId: z.number().int().positive().optional(),
121
+ companyId: z.number().int().positive().optional(),
122
+ });
123
+ export const gtmCommentListCapability = defineStaffMcpCapability({
124
+ id: "gtm.comment.list",
125
+ domain: "gtm",
126
+ roles: ["reviewer", "admin"],
127
+ description: "GTM staff only: list the plaintext comment thread for one deal or one company, replies included. A reply carries parent_id.",
128
+ mutation: "read",
129
+ http: { method: "GET", path: "/internal/v1/gtm/deals/{dealId}/comments" },
130
+ input: gtmCommentListInput,
131
+ execute: (ctx, input) => {
132
+ const parsed = gtmCommentListInput.parse(input);
133
+ return ctx.fetch(commentThreadPath(parsed), { method: "GET" });
134
+ },
135
+ });
136
+ export const gtmCommentWriteInput = z.object({
137
+ dealId: z.number().int().positive().optional(),
138
+ companyId: z.number().int().positive().optional(),
139
+ commentId: z.number().int().positive().optional(),
140
+ /** Set to reply to an existing comment. Threads are one level deep. */
141
+ parentId: z.number().int().positive().optional(),
142
+ body: z.string().min(1),
143
+ bid: z.boolean().default(false),
144
+ confirm: z.boolean().optional(),
145
+ });
146
+ export const gtmCommentWriteCapability = defineStaffMcpCapability({
147
+ id: "gtm.comment.write",
148
+ domain: "gtm",
149
+ roles: ["reviewer", "admin"],
150
+ description: "GTM staff only: append a plaintext comment to a deal or a company, reply to one with parentId, or replace one when commentId is supplied. bid=true adds only the BID tag and changes no other behavior. Mention a teammate by writing @name in the body. Requires confirm: true.",
151
+ mutation: "preview-confirm",
152
+ http: { method: "POST", path: "/internal/v1/gtm/deals/{dealId}/comments" },
153
+ input: gtmCommentWriteInput,
154
+ execute: async (ctx, input) => {
155
+ const parsed = gtmCommentWriteInput.parse(input);
156
+ if (!parsed.commentId && !parsed.dealId && !parsed.companyId) {
157
+ throw new Error("dealId or companyId is required when creating a comment");
158
+ }
159
+ const body = {
160
+ body: parsed.body,
161
+ tag: parsed.bid ? "BID" : null,
162
+ };
163
+ if (parsed.parentId) {
164
+ body.parent_id = parsed.parentId;
165
+ }
166
+ if (!ctx.confirmed) {
167
+ return preview("gtm.comment.write", {
168
+ dealId: parsed.dealId,
169
+ companyId: parsed.companyId,
170
+ commentId: parsed.commentId,
171
+ ...body,
172
+ });
173
+ }
174
+ return ctx.fetch(parsed.commentId
175
+ ? `/internal/v1/gtm/comments/${seg(String(parsed.commentId))}`
176
+ : commentThreadPath(parsed), { method: parsed.commentId ? "PATCH" : "POST", body });
177
+ },
178
+ });
179
+ /** A comment thread hangs off exactly one subject. */
180
+ function commentThreadPath(parsed) {
181
+ if (parsed.companyId) {
182
+ return `/internal/v1/gtm/companies/${seg(String(parsed.companyId))}/comments`;
183
+ }
184
+ if (parsed.dealId) {
185
+ return `/internal/v1/gtm/deals/${seg(String(parsed.dealId))}/comments`;
186
+ }
187
+ throw new Error("dealId or companyId is required");
188
+ }
189
+ export const gtmMentionDismissInput = z.object({
190
+ commentId: z.number().int().positive(),
191
+ confirm: z.boolean().optional(),
192
+ });
193
+ export const gtmMentionDismissCapability = defineStaffMcpCapability({
194
+ id: "gtm.mention.dismiss",
195
+ domain: "gtm",
196
+ roles: ["reviewer", "admin"],
197
+ description: "GTM staff only: dismiss one mention for the signed-in user. The comment is untouched and everyone else still sees it. Requires confirm: true.",
198
+ mutation: "preview-confirm",
199
+ http: { method: "POST", path: "/internal/v1/gtm/comments/{commentId}/dismiss" },
200
+ input: gtmMentionDismissInput,
201
+ execute: async (ctx, input) => {
202
+ const parsed = gtmMentionDismissInput.parse(input);
203
+ if (!ctx.confirmed) {
204
+ return preview("gtm.mention.dismiss", { commentId: parsed.commentId });
205
+ }
206
+ return ctx.fetch(`/internal/v1/gtm/comments/${seg(String(parsed.commentId))}/dismiss`, {
207
+ method: "POST",
208
+ });
209
+ },
210
+ });
211
+ export const gtmWorkspaceShowInput = z.object({});
212
+ export const gtmWorkspaceShowCapability = defineStaffMcpCapability({
213
+ id: "gtm.workspace.show",
214
+ domain: "gtm",
215
+ roles: ["reviewer", "admin"],
216
+ description: "GTM staff only: show companies tagged to the signed-in user, deals they own, undismissed mentions of them, and tickets assigned to them.",
217
+ mutation: "read",
218
+ http: { method: "GET", path: "/internal/v1/gtm/me" },
219
+ input: gtmWorkspaceShowInput,
220
+ execute: (ctx) => ctx.fetch("/internal/v1/gtm/me", { method: "GET" }),
221
+ });
222
+ export const gtmTaskListInput = z.object({ ...listFilterFields });
223
+ export const gtmTaskListCapability = defineStaffMcpCapability({
224
+ id: "gtm.task.list",
225
+ domain: "gtm",
226
+ roles: ["reviewer", "admin"],
227
+ description: "GTM staff only: list isolated CRM tickets tied to companies. Narrow with q, cap with limit.",
228
+ mutation: "read",
229
+ http: { method: "GET", path: "/internal/v1/gtm/tasks" },
230
+ input: gtmTaskListInput,
231
+ execute: (ctx, input) => ctx.fetch(`/internal/v1/gtm/tasks${listQuery(gtmTaskListInput.parse(input))}`, {
232
+ method: "GET",
233
+ }),
234
+ });
235
+ export const gtmTaskWriteInput = z
236
+ .object({
237
+ taskId: z.number().int().positive().optional(),
238
+ title: z.string().min(1),
239
+ body: z.string().default(""),
240
+ companyId: z.number().int().positive(),
241
+ dealId: z.number().int().positive().nullable().optional(),
242
+ assignee: z.string().min(1),
243
+ status: z.enum(["open", "done"]).optional(),
244
+ confirm: z.boolean().optional(),
245
+ })
246
+ // Creating defaults to open; updating replaces the whole ticket, so an
247
+ // omitted status would be sent as empty and rejected by Commerce. Catch it
248
+ // here instead of after the caller has confirmed.
249
+ .refine((value) => value.taskId === undefined || value.status !== undefined, {
250
+ message: "status is required when updating a ticket",
251
+ path: ["status"],
252
+ });
253
+ export const gtmTaskWriteCapability = defineStaffMcpCapability({
254
+ id: "gtm.task.write",
255
+ domain: "gtm",
256
+ roles: ["reviewer", "admin"],
257
+ description: "GTM staff only: create a company ticket, or replace one when taskId is supplied. Requires confirm: true.",
258
+ mutation: "preview-confirm",
259
+ http: { method: "POST", path: "/internal/v1/gtm/tasks" },
260
+ input: gtmTaskWriteInput,
261
+ execute: async (ctx, input) => {
262
+ const parsed = gtmTaskWriteInput.parse(input);
263
+ const body = {
264
+ title: parsed.title,
265
+ body: parsed.body,
266
+ company_id: parsed.companyId,
267
+ deal_id: parsed.dealId ?? null,
268
+ assignee: parsed.assignee,
269
+ status: parsed.status,
270
+ };
271
+ if (!ctx.confirmed) {
272
+ return preview("gtm.task.write", { taskId: parsed.taskId, ...body });
273
+ }
274
+ return ctx.fetch(parsed.taskId
275
+ ? `/internal/v1/gtm/tasks/${seg(String(parsed.taskId))}`
276
+ : "/internal/v1/gtm/tasks", { method: parsed.taskId ? "PATCH" : "POST", body });
277
+ },
278
+ });
279
+ /**
280
+ * Delete verbs. Each takes the record id, previews first, and maps onto the
281
+ * matching DELETE route. Deleting a deal removes its comments with it; a
282
+ * company refuses while deals or tickets still name it.
283
+ */
284
+ function gtmDeleteCapability(entity, idKey, route, consequence) {
285
+ const input = z.object({
286
+ [idKey]: z.number().int().positive(),
287
+ confirm: z.boolean().optional(),
288
+ });
289
+ const capability = defineStaffMcpCapability({
290
+ id: `gtm.${entity}.delete`,
291
+ domain: "gtm",
292
+ roles: ["reviewer", "admin"],
293
+ description: `GTM staff only: delete one ${entity}. ${consequence} Requires confirm: true.`,
294
+ mutation: "preview-confirm",
295
+ http: { method: "DELETE", path: `${route}/{${idKey}}` },
296
+ input,
297
+ execute: async (ctx, raw) => {
298
+ const parsed = input.parse(raw);
299
+ const id = parsed[idKey];
300
+ if (!ctx.confirmed) {
301
+ return preview(`gtm.${entity}.delete`, { [idKey]: id });
302
+ }
303
+ return ctx.fetch(`${route}/${seg(String(id))}`, { method: "DELETE" });
304
+ },
305
+ });
306
+ return { input, capability };
307
+ }
308
+ const companyDelete = gtmDeleteCapability("company", "companyId", "/internal/v1/gtm/companies", "Refused while the company still has deals or tickets.");
309
+ export const gtmCompanyDeleteInput = companyDelete.input;
310
+ export const gtmCompanyDeleteCapability = companyDelete.capability;
311
+ const dealDelete = gtmDeleteCapability("deal", "dealId", "/internal/v1/gtm/deals", "Its comments are deleted with it; tickets that referenced it are kept.");
312
+ export const gtmDealDeleteInput = dealDelete.input;
313
+ export const gtmDealDeleteCapability = dealDelete.capability;
314
+ const commentDelete = gtmDeleteCapability("comment", "commentId", "/internal/v1/gtm/comments", "Removes only that comment.");
315
+ export const gtmCommentDeleteInput = commentDelete.input;
316
+ export const gtmCommentDeleteCapability = commentDelete.capability;
317
+ const taskDelete = gtmDeleteCapability("task", "taskId", "/internal/v1/gtm/tasks", "Removes only that ticket.");
318
+ export const gtmTaskDeleteInput = taskDelete.input;
319
+ export const gtmTaskDeleteCapability = taskDelete.capability;
320
+ /**
321
+ * Listing verbs. GTM never touches a Commerce table directly: these call
322
+ * Commerce's own listing routes through the gateway, exactly as the staff app
323
+ * does. Nothing here writes a gtm_ row, and nothing in the CRM constrains a
324
+ * listing.
325
+ */
326
+ export const gtmListingListInput = z.object({
327
+ gpuType: z.string().optional(),
328
+ limit: z.number().int().positive().max(200).optional(),
329
+ });
330
+ export const gtmListingListCapability = defineStaffMcpCapability({
331
+ id: "gtm.listing.list",
332
+ domain: "gtm",
333
+ roles: ["reviewer", "admin"],
334
+ description: "GTM staff only: list Commerce listings to apply a bid against, optionally filtered by GPU type. Read-only; this is Commerce's catalog, not CRM data.",
335
+ mutation: "read",
336
+ http: { method: "GET", path: "/internal/v1/listings" },
337
+ input: gtmListingListInput,
338
+ execute: async (ctx, input) => {
339
+ const parsed = gtmListingListInput.parse(input);
340
+ const parts = [`limit=${encodeURIComponent(String(parsed.limit ?? 50))}`];
341
+ if (parsed.gpuType)
342
+ parts.push(`q=${encodeURIComponent(parsed.gpuType)}`);
343
+ const page = (await ctx.fetch(`/internal/v1/listings?${parts.join("&")}`, {
344
+ method: "GET",
345
+ }));
346
+ return { listings: (page.listings ?? []).map(publicListing) };
347
+ },
348
+ });
349
+ export const gtmListingUpdateInput = z.object({
350
+ listingId: z.string().uuid(),
351
+ buyNowRatePerGpuHour: z.string().optional(),
352
+ costPerGpuHour: z.string().optional(),
353
+ /** true = month to month, false = the fixed window the listing already carries. */
354
+ monthToMonth: z.boolean().optional(),
355
+ allowsDepositPlan: z.boolean().optional(),
356
+ downPaymentPct: z.string().optional(),
357
+ capacityNodes: z.number().int().positive().optional(),
358
+ state: z.enum(["draft", "listed", "delisted"]).optional(),
359
+ confirm: z.boolean().optional(),
360
+ });
361
+ export const gtmListingUpdateCapability = defineStaffMcpCapability({
362
+ id: "gtm.listing.update",
363
+ domain: "gtm",
364
+ roles: ["reviewer", "admin"],
365
+ description: "GTM staff only: update a Commerce listing's commercial terms — buy-now rate, cost, term and deposit plan — plus capacity or state. Setting a buy-now rate also enables buy-now, otherwise the rate would be inert. Commerce owns the record; this is its own PATCH, not a CRM write. Requires confirm: true.",
366
+ mutation: "preview-confirm",
367
+ http: { method: "PATCH", path: "/internal/v1/listings/{listingId}" },
368
+ input: gtmListingUpdateInput,
369
+ execute: async (ctx, input) => {
370
+ const parsed = gtmListingUpdateInput.parse(input);
371
+ const body = listingPatchBody(parsed);
372
+ if (Object.keys(body).length === 0) {
373
+ throw new Error("supply at least one field to update");
374
+ }
375
+ if (!ctx.confirmed) {
376
+ return preview("gtm.listing.update", { listingId: parsed.listingId, ...body });
377
+ }
378
+ return patchListing(ctx, parsed.listingId, body);
379
+ },
380
+ });
381
+ /**
382
+ * Strip true operator identity before it reaches an MCP client. Facility and
383
+ * region are allowed; operator ids and provider names are not. Every listing
384
+ * this module returns -- list, update, and the apply result -- goes through it.
385
+ */
386
+ function publicListing(raw) {
387
+ const listing = raw;
388
+ const { operatorId: _operatorId, operatorName: _operatorName, provider: _provider, ...safe } = listing;
389
+ return safe;
390
+ }
391
+ /**
392
+ * The listing PATCH is a compare-and-set: it refuses without the version the
393
+ * row was read at. Read it here so a caller never has to guess, and a listing
394
+ * changed in between fails loudly rather than being clobbered.
395
+ */
396
+ async function patchListing(ctx, listingId, body) {
397
+ const current = (await ctx.fetch(`/internal/v1/listings/${seg(listingId)}`, {
398
+ method: "GET",
399
+ }));
400
+ if (typeof current.version !== "number") {
401
+ throw new Error("listing did not report a version to update against");
402
+ }
403
+ const updated = await ctx.fetch(`/internal/v1/listings/${seg(listingId)}`, {
404
+ method: "PATCH",
405
+ body: { ...body, version: current.version },
406
+ });
407
+ return publicListing(updated);
408
+ }
409
+ function listingPatchBody(parsed) {
410
+ const body = {};
411
+ if (parsed.buyNowRatePerGpuHour !== undefined) {
412
+ body.buyNowRatePerGpuHour = parsed.buyNowRatePerGpuHour;
413
+ // A rate with buy-now still disabled would change nothing a buyer sees.
414
+ body.buyNowEnabled = true;
415
+ }
416
+ if (parsed.costPerGpuHour !== undefined)
417
+ body.costPerGpuHour = parsed.costPerGpuHour;
418
+ if (parsed.monthToMonth !== undefined)
419
+ body.monthToMonth = parsed.monthToMonth;
420
+ if (parsed.allowsDepositPlan !== undefined)
421
+ body.allowsDepositPlan = parsed.allowsDepositPlan;
422
+ if (parsed.downPaymentPct !== undefined)
423
+ body.downPaymentPct = parsed.downPaymentPct;
424
+ if (parsed.capacityNodes !== undefined)
425
+ body.capacityNodes = parsed.capacityNodes;
426
+ if (parsed.state !== undefined)
427
+ body.state = parsed.state;
428
+ return body;
429
+ }
430
+ export const gtmTaskActivityListInput = z.object({
431
+ taskId: z.number().int().positive(),
432
+ });
433
+ export const gtmTaskActivityListCapability = defineStaffMcpCapability({
434
+ id: "gtm.task.activity.list",
435
+ domain: "gtm",
436
+ roles: ["reviewer", "admin"],
437
+ description: "GTM staff only: read one ticket's timeline — the comments left on it and the field changes recorded against it, oldest first.",
438
+ mutation: "read",
439
+ http: { method: "GET", path: "/internal/v1/gtm/tasks/{taskId}/activity" },
440
+ input: gtmTaskActivityListInput,
441
+ execute: (ctx, input) => {
442
+ const parsed = gtmTaskActivityListInput.parse(input);
443
+ return ctx.fetch(`/internal/v1/gtm/tasks/${seg(String(parsed.taskId))}/activity`, {
444
+ method: "GET",
445
+ });
446
+ },
447
+ });
448
+ export const gtmTaskActivityWriteInput = z.object({
449
+ taskId: z.number().int().positive(),
450
+ body: z.string().min(1),
451
+ /** Set to reply to a comment on the same ticket. Threads are one level deep. */
452
+ parentId: z.number().int().positive().optional(),
453
+ confirm: z.boolean().optional(),
454
+ });
455
+ export const gtmTaskActivityWriteCapability = defineStaffMcpCapability({
456
+ id: "gtm.task.activity.write",
457
+ domain: "gtm",
458
+ roles: ["reviewer", "admin"],
459
+ description: "GTM staff only: add a plaintext comment to a ticket, or reply to one with parentId. Mention a teammate by writing @name in the body. Requires confirm: true.",
460
+ mutation: "preview-confirm",
461
+ http: { method: "POST", path: "/internal/v1/gtm/tasks/{taskId}/activity" },
462
+ input: gtmTaskActivityWriteInput,
463
+ execute: async (ctx, input) => {
464
+ const parsed = gtmTaskActivityWriteInput.parse(input);
465
+ const body = { body: parsed.body, parent_id: parsed.parentId ?? null };
466
+ if (!ctx.confirmed) {
467
+ return preview("gtm.task.activity.write", { taskId: parsed.taskId, ...body });
468
+ }
469
+ return ctx.fetch(`/internal/v1/gtm/tasks/${seg(String(parsed.taskId))}/activity`, {
470
+ method: "POST",
471
+ body,
472
+ });
473
+ },
474
+ });
475
+ /**
476
+ * Company cell comments. These hang off one field of one company — the
477
+ * discussion about a name, its owner tags, its summary, or its tickets —
478
+ * rather than off the company's main thread.
479
+ */
480
+ const cellField = z.enum(["name", "owner_tags", "notes", "tickets"]);
481
+ export const gtmCellCommentListInput = z.object({
482
+ companyId: z.number().int().positive(),
483
+ field: cellField,
484
+ });
485
+ export const gtmCellCommentListCapability = defineStaffMcpCapability({
486
+ id: "gtm.cell.comment.list",
487
+ domain: "gtm",
488
+ roles: ["reviewer", "admin"],
489
+ description: "GTM staff only: read the comments attached to one field of one company (name, owner_tags, notes or tickets), replies included.",
490
+ mutation: "read",
491
+ http: { method: "GET", path: "/internal/v1/gtm/companies/{companyId}/cells/{field}/comments" },
492
+ input: gtmCellCommentListInput,
493
+ execute: (ctx, input) => {
494
+ const parsed = gtmCellCommentListInput.parse(input);
495
+ return ctx.fetch(`/internal/v1/gtm/companies/${seg(String(parsed.companyId))}/cells/${seg(parsed.field)}/comments`, { method: "GET" });
496
+ },
497
+ });
498
+ export const gtmCellCommentWriteInput = z.object({
499
+ companyId: z.number().int().positive().optional(),
500
+ field: cellField.optional(),
501
+ /** Set to replace an existing cell comment; companyId and field are then unused. */
502
+ commentId: z.number().int().positive().optional(),
503
+ parentId: z.number().int().positive().optional(),
504
+ body: z.string().min(1),
505
+ confirm: z.boolean().optional(),
506
+ });
507
+ export const gtmCellCommentWriteCapability = defineStaffMcpCapability({
508
+ id: "gtm.cell.comment.write",
509
+ domain: "gtm",
510
+ roles: ["reviewer", "admin"],
511
+ description: "GTM staff only: comment on one field of one company, reply to such a comment with parentId, or replace one when commentId is supplied. Requires confirm: true.",
512
+ mutation: "preview-confirm",
513
+ http: { method: "POST", path: "/internal/v1/gtm/companies/{companyId}/cells/{field}/comments" },
514
+ input: gtmCellCommentWriteInput,
515
+ execute: async (ctx, input) => {
516
+ const parsed = gtmCellCommentWriteInput.parse(input);
517
+ if (!parsed.commentId && (!parsed.companyId || !parsed.field)) {
518
+ throw new Error("companyId and field are required unless commentId is supplied");
519
+ }
520
+ if (!ctx.confirmed) {
521
+ return preview("gtm.cell.comment.write", {
522
+ companyId: parsed.companyId,
523
+ field: parsed.field,
524
+ commentId: parsed.commentId,
525
+ parentId: parsed.parentId,
526
+ body: parsed.body,
527
+ });
528
+ }
529
+ if (parsed.commentId) {
530
+ return ctx.fetch(`/internal/v1/gtm/company-comments/${seg(String(parsed.commentId))}`, {
531
+ method: "PATCH",
532
+ body: { body: parsed.body },
533
+ });
534
+ }
535
+ return ctx.fetch(`/internal/v1/gtm/companies/${seg(String(parsed.companyId))}/cells/${seg(parsed.field)}/comments`, { method: "POST", body: { body: parsed.body, parent_id: parsed.parentId ?? null } });
536
+ },
537
+ });
538
+ const cellCommentDelete = gtmDeleteCapability("cell.comment", "commentId", "/internal/v1/gtm/company-comments", "Removes that field comment and its replies.");
539
+ export const gtmCellCommentDeleteInput = cellCommentDelete.input;
540
+ export const gtmCellCommentDeleteCapability = cellCommentDelete.capability;
541
+ /**
542
+ * Bulk owner-tag reassignment. This is the one GTM edit that is genuinely
543
+ * N-at-a-time — a book of companies moving to a new owner — and doing it one
544
+ * confirmation at a time is both slow and easy to half-finish. Every other
545
+ * bulk case is a loop over the single-record verbs.
546
+ */
547
+ export const gtmCompanyBulkRetagInput = z.object({
548
+ companyIds: z.array(z.number().int().positive()).min(1).max(200),
549
+ ownerTags: z.string(),
550
+ confirm: z.boolean().optional(),
551
+ });
552
+ export const gtmCompanyBulkRetagCapability = defineStaffMcpCapability({
553
+ id: "gtm.company.bulk_retag",
554
+ domain: "gtm",
555
+ roles: ["reviewer", "admin"],
556
+ description: "GTM staff only: set the same owner tags on many companies at once, for moving a book to a new owner. Names, notes and comments are untouched. The preview lists every company it would change. Requires confirm: true.",
557
+ mutation: "preview-confirm",
558
+ http: { method: "PATCH", path: "/internal/v1/gtm/companies/{companyId}" },
559
+ input: gtmCompanyBulkRetagInput,
560
+ execute: async (ctx, input) => {
561
+ const parsed = gtmCompanyBulkRetagInput.parse(input);
562
+ const ids = [...new Set(parsed.companyIds)];
563
+ if (!ctx.confirmed) {
564
+ return preview("gtm.company.bulk_retag", { companyIds: ids, ownerTags: parsed.ownerTags });
565
+ }
566
+ // PATCH replaces the company's name as well, so the current names are read
567
+ // once up front rather than per row. Omitting notes preserves them.
568
+ // Commerce owns no bulk route, so partial progress is reported rather than
569
+ // rolled back; re-running is safe.
570
+ const all = (await ctx.fetch("/internal/v1/gtm/companies", { method: "GET" }));
571
+ const names = new Map(all.map((company) => [company.id, company.name]));
572
+ const updated = [];
573
+ const failed = [];
574
+ for (const companyId of ids) {
575
+ const name = names.get(companyId);
576
+ if (name === undefined) {
577
+ failed.push({ companyId, error: "no such company" });
578
+ continue;
579
+ }
580
+ try {
581
+ updated.push(await ctx.fetch(`/internal/v1/gtm/companies/${seg(String(companyId))}`, {
582
+ method: "PATCH",
583
+ body: { name, owner_tags: parsed.ownerTags },
584
+ }));
585
+ }
586
+ catch (error) {
587
+ failed.push({ companyId, error: error instanceof Error ? error.message : String(error) });
588
+ }
589
+ }
590
+ return { updated, failed };
591
+ },
592
+ });
593
+ export const staffGTMCapabilities = [
594
+ gtmCompanyListCapability,
595
+ gtmCompanyWriteCapability,
596
+ gtmCompanyDeleteCapability,
597
+ gtmCompanyBulkRetagCapability,
598
+ gtmDealListCapability,
599
+ gtmDealWriteCapability,
600
+ gtmDealDeleteCapability,
601
+ gtmCommentListCapability,
602
+ gtmCommentWriteCapability,
603
+ gtmCommentDeleteCapability,
604
+ gtmCellCommentListCapability,
605
+ gtmCellCommentWriteCapability,
606
+ gtmCellCommentDeleteCapability,
607
+ gtmTaskListCapability,
608
+ gtmTaskWriteCapability,
609
+ gtmTaskDeleteCapability,
610
+ gtmTaskActivityListCapability,
611
+ gtmTaskActivityWriteCapability,
612
+ gtmListingListCapability,
613
+ gtmListingUpdateCapability,
614
+ gtmMentionDismissCapability,
615
+ gtmWorkspaceShowCapability,
616
+ ];
@@ -1,6 +1,6 @@
1
1
  import type { ZodType } from "zod";
2
2
  import type { ClientKind, Role } from "./role.ts";
3
- export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing", "observability"];
3
+ export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing", "observability", "gtm"];
4
4
  export type Domain = (typeof DOMAINS)[number];
5
5
  export type Mutation = "read" | "preview-confirm" | "local";
6
6
  export type HttpSpec = {
@@ -65,7 +65,7 @@ export type Capability<I = unknown, O = unknown> = CapabilityBindings & {
65
65
  execute: (ctx: CapabilityContext, input: I) => Promise<O>;
66
66
  };
67
67
  export type CapabilitySpec<I = unknown, O = unknown> = Omit<Capability<I, O>, "cli" | "mcp" | "slack"> & {
68
- slack?: {
68
+ slack?: false | {
69
69
  parameters?: JsonSchema;
70
70
  };
71
71
  };
@@ -15,6 +15,7 @@ export const DOMAINS = [
15
15
  "vpn",
16
16
  "billing",
17
17
  "observability",
18
+ "gtm",
18
19
  ];
19
20
  /**
20
21
  * Build a preview-confirm capability's un-executed result.
@@ -31,14 +32,19 @@ export function preview(action, payload) {
31
32
  /** Fill CLI/MCP/Slack names from `id`. Slack parameters stay optional. */
32
33
  export function defineCapability(spec) {
33
34
  const names = namesFromId(spec.id);
35
+ const { slack, ...rest } = spec;
34
36
  return {
35
- ...spec,
37
+ ...rest,
36
38
  cli: { command: names.cli },
37
39
  mcp: { name: names.mcp },
38
- slack: {
39
- name: names.slack,
40
- ...(spec.slack?.parameters ? { parameters: spec.slack.parameters } : {}),
41
- },
40
+ ...(slack === false
41
+ ? {}
42
+ : {
43
+ slack: {
44
+ name: names.slack,
45
+ ...(slack?.parameters ? { parameters: slack.parameters } : {}),
46
+ },
47
+ }),
42
48
  };
43
49
  }
44
50
  /**