@adrata/adrata-mcp 1.0.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 (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,758 @@
1
+ /**
2
+ * Work-board tools for the Adrata MCP Server.
3
+ *
4
+ * These let a coding agent (Claude Code, Codex, Cursor, …) read and reorganise
5
+ * a Starfield board through the SAME governed endpoints the UI calls. There is
6
+ * no second path into the data: `/api/v1/work-boards`, `/api/v1/work-items`,
7
+ * and `/api/v1/work-board-rollups` are the only surface, so workspace scoping,
8
+ * validation, and the transactional move all hold identically whether a human
9
+ * dragged the card or an agent moved it.
10
+ *
11
+ * Reads (list boards, read a board, read a card, read a roll-up) are ordinary.
12
+ *
13
+ * WRITES ARE NOT. Moving a card and changing a tag are the exact case the
14
+ * repo's workspace rules exist for — an agent reorganising somebody's backlog —
15
+ * so they follow the same contract as every other governed write here:
16
+ *
17
+ * dryRun defaults to TRUE. A live write needs dryRun:false AND approved:true
18
+ * AND a reason AND an idempotencyKey.
19
+ *
20
+ * The idempotency key is not ceremony. A retried move that appended a second
21
+ * transition would make the card look like it had bounced between columns, and
22
+ * the stage timer and any cycle-time reporting are derived from exactly those
23
+ * rows. Reuse the same key on retry and the server replays instead.
24
+ *
25
+ * Tier: enterprise (OAuth workspace connection), same as the other tools that
26
+ * read and mutate real workspace records.
27
+ */
28
+
29
+ /**
30
+ * Reasons that are technically present and practically worthless.
31
+ *
32
+ * The board's own drag default used to be "Moved to another column", and a
33
+ * history full of that line is worse than an empty one: it looks like an audit
34
+ * trail, so nobody goes looking for the real answer. These are the ones an agent
35
+ * reaches for when it has nothing to say — and an agent that has just done the
36
+ * work always has something to say.
37
+ */
38
+ const PLACEHOLDER_REASONS = new Set([
39
+ 'move',
40
+ 'moved',
41
+ 'moving',
42
+ 'moving card',
43
+ 'moved card',
44
+ 'moved to another column',
45
+ 'move to another column',
46
+ 'column change',
47
+ 'board update',
48
+ 'agent move',
49
+ 'update',
50
+ 'updated',
51
+ 'change',
52
+ 'changes',
53
+ 'fix',
54
+ 'fixed',
55
+ 'done',
56
+ 'complete',
57
+ 'completed',
58
+ 'finished',
59
+ 'wip',
60
+ 'work in progress',
61
+ 'triage',
62
+ 'triaged',
63
+ 'cleanup',
64
+ 'housekeeping',
65
+ 'as requested',
66
+ 'per request',
67
+ 'no reason',
68
+ 'n/a',
69
+ 'na',
70
+ 'test',
71
+ 'testing',
72
+ ]);
73
+
74
+ /** The shortest reason that could plausibly name a branch, a PR, or a change. */
75
+ const MIN_AUDITABLE_REASON = 12;
76
+
77
+ /**
78
+ * Why this reason would be useless in the card's history, or `null` if it is
79
+ * fine.
80
+ *
81
+ * The governed contract already REQUIRES a reason; this is about the reason
82
+ * being worth the row it is stored on. `work_item_column_history.reason` is
83
+ * rendered on the card, and it is the only thing that answers "why did this take
84
+ * a week" three weeks later — the branch name will not.
85
+ *
86
+ * An empty reason is deliberately NOT this function's business: the bridge
87
+ * validator refuses that with its own message, and duplicating it here would
88
+ * give two different errors for one mistake.
89
+ */
90
+ export function describeUnauditableReason(reason) {
91
+ const trimmed = String(reason ?? '').trim();
92
+ if (!trimmed) return null;
93
+
94
+ const normalized = trimmed
95
+ .toLowerCase()
96
+ .replace(/[.!]+$/, '')
97
+ .replace(/\s+/g, ' ');
98
+ const advice =
99
+ 'Say what a human will need: a branch name, a PR link, or one line naming what changed and how it was verified — e.g. "Fixed the N+1 in the export query on fix/export-n1; added a test at 10k rows, suite green".';
100
+
101
+ if (PLACEHOLDER_REASONS.has(normalized)) {
102
+ return `"${trimmed}" is a placeholder, not a reason. It restates the move the history already records. ${advice}`;
103
+ }
104
+ if (trimmed.length < MIN_AUDITABLE_REASON) {
105
+ return `"${trimmed}" is too short to audit. ${advice}`;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Why a card with no body will not be workable, or `null` if it has one.
112
+ *
113
+ * Deliberately the weakest possible check. The card model says a Task carries
114
+ * acceptance criteria — the artifact with two consumers, the build spec for the
115
+ * agent and the test plan for QA — and today those criteria live in `body`,
116
+ * because there is no `acceptance_criteria` column yet. But "does this prose
117
+ * contain acceptance criteria" is a judgement, not a pattern: a regex looking
118
+ * for the word would pass a heading with nothing under it and fail a perfectly
119
+ * good "the Export button is disabled while an export is running". Asserting it
120
+ * either way would be the tool inventing a verdict.
121
+ *
122
+ * A MISSING body is a fact, so that is what this reports — as a note on the
123
+ * dry-run preview, where a human is already deciding, and never as a refusal.
124
+ * Blocking a thin capture is how a card ends up not written down at all, which
125
+ * is worse than a thin one.
126
+ */
127
+ export function describeMissingBody(body) {
128
+ if (String(body ?? '').trim()) return undefined;
129
+ return 'This card would arrive with a title and nothing else. A card is the unit of QA sign-off, so with no body it carries no acceptance criteria — nobody can validate it and no agent can build from it. Add the criteria to `body` (one checkable outcome per line), or say in the body what is still unknown.';
130
+ }
131
+
132
+ /**
133
+ * Register the work-board tools.
134
+ *
135
+ * @param {McpServer} server - the MCP server instance (already tier-gated)
136
+ * @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
137
+ */
138
+ export function registerWorkBoardTools(
139
+ server,
140
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
141
+ ) {
142
+ const GOVERNED_NOTE =
143
+ ' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey (reuse the SAME key on retry — a duplicate move would read as the card having bounced between columns).';
144
+
145
+ // =========================================================================
146
+ // READS
147
+ // =========================================================================
148
+
149
+ server.tool(
150
+ 'list_my_work_items',
151
+ `The cards that are YOURS, across every board in the workspace, ordered the way a developer actually picks: escalated first (the top band of each card's OWN scheme — a P1 is never equated with a Critical), then by the board's own left-to-right flow so the earliest active stage comes first, then longest-waiting. Cards in a terminal column (Production, Deep backlog) sink to the bottom, because nothing should be picked up from them.
152
+
153
+ "Yours" is TWO things: cards you OWN (you are the assignee, carrying it end to end) and cards whose CURRENT PASS you hold (you took it at a stage — the QA case). Both appear here, and each card carries assignee and handler so you can tell which of the two put it in front of you. A QA person owns none of the cards they are testing, so a queue keyed only on the assignee would tell them they had no work while four cards sat on their bench.
154
+
155
+ "You" is resolved from the authenticated token. There is deliberately NO parameter naming a user, and the endpoint refuses one rather than ignoring it — otherwise one agent could read another developer's queue through the very tool meant to keep them off each other's cards. To see somebody else's work, read their board with get_work_board.
156
+
157
+ Start here for "what should I work on". With includeUnassigned it also returns the cards nobody owns AND nobody is handling, which are the ones free to take (move_work_item with claim:true).`,
158
+ {
159
+ includeUnassigned: z
160
+ .boolean()
161
+ .optional()
162
+ .describe(
163
+ 'Also return the cards nobody owns and nobody is handling — the pool that is genuinely free. Use it when your own queue is empty, instead of pulling whole boards and eyeballing them. Note this is NOT "free QA passes": a card an engineer owns, waiting untaken in a QA column, is free to HANDLE but not free to own, so it stays off this list — read the QA columns with get_work_board for those.'
164
+ ),
165
+ boardId: z.string().optional().describe('Narrow to one board. Omit for every board.'),
166
+ limit: z
167
+ .number()
168
+ .optional()
169
+ .describe('Cap each list. Defaults to 50; a queue that needs a second page is not a queue.'),
170
+ },
171
+ async (args) => {
172
+ const params = new URLSearchParams();
173
+ if (args.includeUnassigned === true) params.set('includeUnassigned', 'true');
174
+ if (args.boardId) params.set('boardId', args.boardId);
175
+ if (args.limit !== undefined) params.set('limit', String(args.limit));
176
+ const query = params.toString();
177
+ const data = await api(
178
+ 'GET',
179
+ `/api/v1/work-items/assigned-to-me${query ? `?${query}` : ''}`
180
+ );
181
+ const queue = data?.data || {};
182
+ const mine = queue.assignedToMe || [];
183
+ return ok({
184
+ me: queue.me,
185
+ count: mine.length,
186
+ assignedToMe: mine,
187
+ unassigned: queue.unassigned,
188
+ // An empty queue is a fact, not a failure — and the two empty answers
189
+ // mean different things to whoever asked.
190
+ note:
191
+ mine.length === 0
192
+ ? args.includeUnassigned
193
+ ? 'You own nothing and are handling no passes. The unassigned list is what is free to take.'
194
+ : 'You own nothing and are handling no passes. Call again with includeUnassigned:true to see what is free to take.'
195
+ : undefined,
196
+ });
197
+ }
198
+ );
199
+
200
+ server.tool(
201
+ 'list_work_boards',
202
+ 'List the work boards in the connected workspace. Returns each board id, name, the company it is run for (if any), and the tag scheme its cards are read under. Start here — every other board tool needs an id from this list.',
203
+ {},
204
+ async () => {
205
+ const data = await api('GET', '/api/v1/work-boards');
206
+ const boards = data?.data || [];
207
+ return ok({
208
+ count: boards.length,
209
+ boards,
210
+ // An empty list is a fact, not a failure. Say which one it is so the
211
+ // agent does not report "no work" when it simply has no boards yet.
212
+ note: boards.length === 0 ? 'This workspace has no boards yet.' : undefined,
213
+ });
214
+ }
215
+ );
216
+
217
+ server.tool(
218
+ 'get_work_board',
219
+ 'Read one board whole: its columns (each with its own staleness policy) and every card on it. The card list carries columnId, enteredColumnAt, position, and the stored tag — everything needed to reason about what is stale and what matters.',
220
+ {
221
+ boardId: z.string().describe('Board id from list_work_boards.'),
222
+ },
223
+ async (args) => {
224
+ const data = await api('GET', `/api/v1/work-boards/${encodeURIComponent(args.boardId)}`);
225
+ const board = data?.data;
226
+ return ok({
227
+ board,
228
+ // Staleness is a property of the COLUMN, not the board — three days in
229
+ // Triage is alarming, three days in In review is a Tuesday. Say so, or
230
+ // an agent will apply one threshold across the whole board.
231
+ howToReadStaleness:
232
+ 'Each column carries its own agingAfterHours/staleAfterHours. A MISSING bound means the column never ages (a Done column) — it does not mean zero. Compare a card\'s enteredColumnAt against ITS OWN column\'s policy.',
233
+ });
234
+ }
235
+ );
236
+
237
+ server.tool(
238
+ 'get_work_item',
239
+ 'Read one card by id: title, body, product, assignee, reporter, creator, its stored tag, and how long it has been in its current column. THREE DIFFERENT PEOPLE can appear on a card and they answer different questions: `assignee` is who is doing it (and changes hands over the card\'s life), `reporterPersonId` is the customer who asked (only on a card ingested from email), and `createdBy` is the teammate who wrote the card — resolved to a name, never changing, and the person to ask what the card meant. An absent `createdBy` means the card predates creator tracking, not that nobody made it.',
240
+ {
241
+ itemId: z.string().describe('Card id.'),
242
+ },
243
+ async (args) => {
244
+ const data = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
245
+ return ok({ item: data?.data });
246
+ }
247
+ );
248
+
249
+ server.tool(
250
+ 'get_work_item_history',
251
+ 'Read a card\'s column transitions, newest first: which column it came from, which it went to, when it entered and left, who moved it, and why. This is the audit trail the stage timer is derived from — use it to answer "how long did this actually take" rather than guessing from the current column.',
252
+ {
253
+ itemId: z.string().describe('Card id.'),
254
+ },
255
+ async (args) => {
256
+ const data = await api(
257
+ 'GET',
258
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/history`
259
+ );
260
+ const transitions = data?.data || [];
261
+ return ok({ count: transitions.length, transitions });
262
+ }
263
+ );
264
+
265
+ server.tool(
266
+ 'get_work_board_rollup',
267
+ 'Read several boards as one prioritisation view. Pass "all" for the implicit roll-up of every board in the workspace (it has no membership rows, so a board created a minute ago is already in it), or a roll-up id for a curated one. Each slice keeps its own board, company, staleness policy, and tag scheme — cards are ranked WITHIN their own scheme and never translated across schemes, so a P1 is never silently equated with a Critical.',
268
+ {
269
+ rollupId: z
270
+ .string()
271
+ .optional()
272
+ .describe('Roll-up id, or "all" (the default) for every board in the workspace.'),
273
+ },
274
+ async (args) => {
275
+ const rollupId = args.rollupId || 'all';
276
+ const data = await api(
277
+ 'GET',
278
+ `/api/v1/work-board-rollups/${encodeURIComponent(rollupId)}`
279
+ );
280
+ return ok({ rollup: data?.data });
281
+ }
282
+ );
283
+
284
+ server.tool(
285
+ 'list_work_board_rollups',
286
+ 'List the curated roll-ups in the workspace. The implicit "all boards" roll-up is NOT in this list because it has no row — read it with get_work_board_rollup using rollupId "all".',
287
+ {},
288
+ async () => {
289
+ const data = await api('GET', '/api/v1/work-board-rollups');
290
+ const rollups = data?.data || [];
291
+ return ok({ count: rollups.length, rollups });
292
+ }
293
+ );
294
+
295
+ server.tool(
296
+ 'get_work_item_comments',
297
+ 'Read what people have SAID about a card, oldest first: each comment\'s author, body, and timestamp. READ THIS BEFORE STARTING WORK, alongside get_work_item_history. The history says which columns a card passed through; the comments say WHY — a card that came back from QA has the reviewer\'s reason here, and repeating a rejected approach is the most expensive mistake available on this board. A comment marked withdrawn is still returned with its text: its author took the claim back, but somebody may already have acted on it, so it is context and not noise. If the card carries a `flag`, the reason it was raised is in this thread too.',
298
+ {
299
+ itemId: z.string().describe('Card id.'),
300
+ },
301
+ async (args) => {
302
+ const data = await api(
303
+ 'GET',
304
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`
305
+ );
306
+ const comments = data?.data || [];
307
+ return ok({
308
+ count: comments.length,
309
+ comments,
310
+ howToRead:
311
+ 'Oldest first, so the thread reads forwards. Read `bodyText` — it is `body` with every @-mention resolved to a name. `body` is the STORED form and carries `<@userId>` tokens; keep it only if you intend to edit the comment, since saving `bodyText` back would turn every mention into a literal name. `withdrawnAt` means the author retracted it — the text is kept because somebody may have acted on it. `edited` means the text changed after posting. `mentions` lists the workspace members the comment names.',
312
+ });
313
+ }
314
+ );
315
+
316
+ // =========================================================================
317
+ // WRITES
318
+ // =========================================================================
319
+
320
+ server.tool(
321
+ 'move_work_item',
322
+ `Move a card to another column on the same board — and, with claim:true, pick it up in the same action. The server does this in ONE transaction: it closes the card's open dwell, appends the transition to the history, records you as the handler of the pass the card is now on, and updates the card. Dropping a card into the column it is already in is a REORDER and deliberately does not restamp the stage timer.
323
+
324
+ A card carries TWO people and they are not interchangeable. The OWNER (assignee) is whoever carries the card end to end — the engineer who builds it, and the person a QA bounce sends it back to. The HANDLER is whoever took the pass the card is on right now, which at a QA gate is the tester and nowhere else is usually the owner. claim:true always takes the pass; it takes ownership ONLY of a card nobody owns. So a QA pick-up on an engineer's card leaves the engineer owning it, which is what makes the two-gate flow work at all.
325
+
326
+ This is both halves of the developer loop. Claiming is a parameter and not a second tool on purpose: picking a card up is one act, and a separate "assign" call is the one that gets skipped — leaving a card in an active column with no owner, which is the exact finding the board's unassigned glyph exists to shout about.${GOVERNED_NOTE}`,
327
+ {
328
+ itemId: z.string().describe('Card id to move.'),
329
+ toColumnId: z
330
+ .string()
331
+ .describe('Target column id. Must be a column on the SAME board — get it from get_work_board.'),
332
+ position: z
333
+ .number()
334
+ .optional()
335
+ .describe('Sort position within the target column. Omit to append to the end.'),
336
+ claim: z
337
+ .boolean()
338
+ .optional()
339
+ .describe(
340
+ 'Pick this card up as part of this move: it records YOU as the handler of the pass the card lands on, and makes you the owner only if the card has no owner. "You" is resolved from the authenticated token — there is no way to claim on somebody else\'s behalf. Use it whenever you are picking work up. Taking a pass on a card SOMEBODY ELSE OWNS is allowed and normal (that is a QA pick-up) and leaves their ownership alone; taking a pass somebody else is already HOLDING is refused (see force). Re-claiming a pass you already hold is a no-op, not an error. The handler is written to the card\'s open dwell, so a same-column claim works too — that is how you take a pass on a card already sitting in your stage.'
341
+ ),
342
+ force: z
343
+ .boolean()
344
+ .optional()
345
+ .describe(
346
+ 'Take over a pass SOMEBODY ELSE IS HOLDING. Requires claim:true and a reason saying why — the reason is the only record that person will have of losing the pass mid-stage. It does NOT take the card off its owner: reassigning a card is the assignee field on update_work_item, a deliberate act, never a side effect of a move. Do not reach for this to work around a refusal; a pass somebody is running is theirs.'
347
+ ),
348
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
349
+ approved: z.boolean().optional().describe('Required true for a live move.'),
350
+ reason: z
351
+ .string()
352
+ .optional()
353
+ .describe(
354
+ 'Required for a live move, and it must be worth reading: a branch name, a PR link, or one line saying what changed and how it was verified. Stored on the transition row and rendered in the card\'s history, so it is what a human reads in three weeks asking why this took a week. Placeholder reasons ("moved", "update") are refused.'
355
+ ),
356
+ idempotencyKey: z
357
+ .string()
358
+ .optional()
359
+ .describe('Required for a live move. Reuse the SAME key on retry; the server replays.'),
360
+ },
361
+ async (args) => {
362
+ if (args.force === true && args.claim !== true) {
363
+ return ok({
364
+ error: true,
365
+ message:
366
+ 'force only means anything alongside claim:true — it is the override for taking a card over from another person, not a general "ignore checks" flag. Drop it, or set claim:true if you really are picking this card up.',
367
+ });
368
+ }
369
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
370
+ if (unauditable) return ok({ error: true, message: unauditable });
371
+
372
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/move`;
373
+ const preview = validateApiBridgeRequest({
374
+ method: 'POST',
375
+ path,
376
+ dryRun: args.dryRun,
377
+ approved: args.approved,
378
+ reason: args.reason,
379
+ idempotencyKey: args.idempotencyKey,
380
+ });
381
+ if (preview?.dryRun) {
382
+ return ok({
383
+ ...preview,
384
+ wouldMove: {
385
+ itemId: args.itemId,
386
+ toColumnId: args.toColumnId,
387
+ position: args.position,
388
+ claim: args.claim === true,
389
+ force: args.force === true,
390
+ },
391
+ });
392
+ }
393
+
394
+ const data = await api('POST', path, {
395
+ body: {
396
+ toColumnId: args.toColumnId,
397
+ position: args.position,
398
+ reason: args.reason,
399
+ idempotencyKey: args.idempotencyKey,
400
+ // Sent only when asked for, so an ordinary move carries no opinion
401
+ // about the assignee at all.
402
+ claim: args.claim === true ? true : undefined,
403
+ force: args.force === true ? true : undefined,
404
+ },
405
+ headers: buildMutationHeaders(args),
406
+ });
407
+ const item = data?.data;
408
+ return ok({
409
+ moved: true,
410
+ claimed: args.claim === true,
411
+ // Read back from the server rather than echoed from the request, because
412
+ // the two halves of a claim are decided server-side and an agent that
413
+ // assumed "claimed" meant "mine now" would report a QA pick-up as having
414
+ // taken the card off the engineer.
415
+ owner: item?.assignee ?? null,
416
+ handler: item?.handler ?? null,
417
+ item,
418
+ });
419
+ }
420
+ );
421
+
422
+ server.tool(
423
+ 'set_work_item_tag',
424
+ `Set a card's URGENCY tag. The scheme must be one the board reads (severity | priority | impact) and the source must be human, model, or rules. A "model" tag REQUIRES a confidence: the board drops a low-confidence model tag, and a model tag with no confidence cannot be held to that floor, so it would be trusted by default — backwards. This does NOT say what kind of work the card is — that is a separate field; use set_work_item_kind, and note that setting one never disturbs the other.${GOVERNED_NOTE}`,
425
+ {
426
+ itemId: z.string().describe('Card id.'),
427
+ schemeId: z
428
+ .enum(['severity', 'priority', 'impact'])
429
+ .describe(
430
+ 'Urgency vocabulary. Use the board\'s own tagScheme unless deliberately changing it. "kind" is no longer a scheme — it is a field, set with set_work_item_kind.'
431
+ ),
432
+ optionId: z
433
+ .string()
434
+ .describe(
435
+ 'Option within the scheme — e.g. critical | non-critical, p0..p3, all-customers | some-customers | internal-only.'
436
+ ),
437
+ source: z
438
+ .enum(['human', 'model', 'rules'])
439
+ .describe(
440
+ 'Who decided. An agent classifying a card is "model" — claiming "human" would launder a guess as a person\'s judgement.'
441
+ ),
442
+ confidence: z
443
+ .number()
444
+ .min(0)
445
+ .max(1)
446
+ .optional()
447
+ .describe('0-1. REQUIRED when source is "model".'),
448
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
449
+ approved: z.boolean().optional().describe('Required true for a live change.'),
450
+ reason: z.string().optional().describe('Required for a live change: why this tag.'),
451
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
452
+ },
453
+ async (args) => {
454
+ if (args.source === 'model' && args.confidence === undefined) {
455
+ return ok({
456
+ error: true,
457
+ message:
458
+ 'confidence is required when source is "model". Without it the tag cannot be held to the board\'s confidence floor, so it would be trusted by default.',
459
+ });
460
+ }
461
+
462
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}`;
463
+ const preview = validateApiBridgeRequest({
464
+ method: 'PATCH',
465
+ path,
466
+ dryRun: args.dryRun,
467
+ approved: args.approved,
468
+ reason: args.reason,
469
+ idempotencyKey: args.idempotencyKey,
470
+ });
471
+ if (preview?.dryRun) {
472
+ return ok({
473
+ ...preview,
474
+ wouldTag: {
475
+ itemId: args.itemId,
476
+ schemeId: args.schemeId,
477
+ optionId: args.optionId,
478
+ source: args.source,
479
+ confidence: args.confidence,
480
+ },
481
+ });
482
+ }
483
+
484
+ const data = await api('PATCH', path, {
485
+ body: {
486
+ tag: {
487
+ schemeId: args.schemeId,
488
+ optionId: args.optionId,
489
+ source: args.source,
490
+ confidence: args.confidence,
491
+ },
492
+ },
493
+ headers: buildMutationHeaders(args),
494
+ });
495
+ return ok({ tagged: true, item: data?.data });
496
+ }
497
+ );
498
+
499
+ server.tool(
500
+ 'set_work_item_kind',
501
+ `Set what KIND of work a card is: bug, story, or chore. This is a FIELD on the card, not a tag — it is orthogonal to urgency, so a card can be a bug AND Critical, and setting the kind never touches the tag (or the assignee, product, or body).
502
+
503
+ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT the same as "chore": a card nobody has categorised has no category, and recording chore would put a decision on the record that nobody made. If you do not know what a card is, leave it alone or clear it — do not guess.${GOVERNED_NOTE}`,
504
+ {
505
+ itemId: z.string().describe('Card id.'),
506
+ kind: z
507
+ .enum(['bug', 'story', 'chore'])
508
+ .nullable()
509
+ .describe(
510
+ 'bug | story | chore, or null to clear it back to untyped. "story", not "feature" — that was the old tag-scheme spelling and the API rejects it.'
511
+ ),
512
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
513
+ approved: z.boolean().optional().describe('Required true for a live change.'),
514
+ reason: z.string().optional().describe('Required for a live change: why this kind.'),
515
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
516
+ },
517
+ async (args) => {
518
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}`;
519
+ const preview = validateApiBridgeRequest({
520
+ method: 'PATCH',
521
+ path,
522
+ dryRun: args.dryRun,
523
+ approved: args.approved,
524
+ reason: args.reason,
525
+ idempotencyKey: args.idempotencyKey,
526
+ });
527
+ if (preview?.dryRun) {
528
+ return ok({
529
+ ...preview,
530
+ wouldSetKind: { itemId: args.itemId, kind: args.kind ?? null },
531
+ });
532
+ }
533
+
534
+ const data = await api('PATCH', path, {
535
+ // `kind` is always present in this body and is either a value or an
536
+ // explicit null. That is deliberate: the server reads three states, and
537
+ // omitting the key would mean "leave it alone" — which is the one thing
538
+ // a tool whose entire job is setting the kind must never send.
539
+ body: { kind: args.kind ?? null },
540
+ headers: buildMutationHeaders(args),
541
+ });
542
+ return ok({ kindSet: true, item: data?.data });
543
+ }
544
+ );
545
+
546
+ server.tool(
547
+ 'create_work_item',
548
+ `Create a card on a board. Lands in the named column, or the board's first column when none is given.
549
+
550
+ WRITE THE ACCEPTANCE CRITERIA IN \`body\`. A card is the unit of QA sign-off, so a card that does not say what "done" means cannot be validated by QA and cannot be built from by the next agent — those are the two readers of the same list. There is no separate criteria field yet: the criteria live in \`body\`, under a short "Acceptance criteria" heading, one checkable outcome per line. If you cannot write them, you do not yet understand the card well enough to file it as workable — file it with what you DO know and say in the body that the criteria are missing, rather than inventing outcomes nobody agreed to.
551
+
552
+ ONE CARD IS ONE QA JUDGEMENT. If your criteria list needs QA to make more than one call ("follows the OS theme" AND "the toggle persists" AND "every surface is restyled"), that is several cards, not one — a bounce from a multi-outcome card names nothing actionable. Implementation steps ("create a React hook", "rename the CSS variables") are never cards; they are lines inside one.
553
+
554
+ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from your authenticated token — there is no parameter for it, and a body naming one is REFUSED rather than ignored, on the same rule as list_my_work_items. Creating is also not claiming: a new card arrives unassigned unless you name an owner, and you pick work up by moving it into an active column with move_work_item(claim:true), which is the act that also starts the stage timer honestly.${GOVERNED_NOTE}`,
555
+ {
556
+ boardId: z.string().describe('Board id from list_work_boards.'),
557
+ title: z.string().describe('What the card is. Keep it a statement of the work, not a label.'),
558
+ body: z
559
+ .string()
560
+ .optional()
561
+ .describe(
562
+ 'The request in full AND its acceptance criteria — what QA will validate this against, one checkable outcome per line under an "Acceptance criteria" heading. Repro steps for a bug go here too. Markdown; an implementation checklist belongs here rather than in sub-cards, because there are none by design.'
563
+ ),
564
+ product: z.string().optional().describe('Product tag — orthogonal to the board.'),
565
+ kind: z
566
+ .enum(['bug', 'story', 'chore'])
567
+ .optional()
568
+ .describe(
569
+ 'What kind of work this is. OMIT IT unless the card plainly says: an absent kind means untyped, which is honest, where a guess is indistinguishable from a person\'s judgement once it is on the record. Change it later with set_work_item_kind.'
570
+ ),
571
+ columnId: z.string().optional().describe('Target column. Defaults to the board\'s first.'),
572
+ assigneeUserId: z
573
+ .string()
574
+ .optional()
575
+ .describe(
576
+ 'Workspace user who will do the work. OMIT IT unless somebody has actually agreed to own this — creating a card is not claiming it, and an unowned card in the backlog is the normal state of a backlog. Do not put yourself here to mark "I filed this": the card already records its creator.'
577
+ ),
578
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to create it.'),
579
+ approved: z.boolean().optional().describe('Required true for a live create.'),
580
+ reason: z.string().optional().describe('Required for a live create.'),
581
+ idempotencyKey: z.string().optional().describe('Required for a live create.'),
582
+ },
583
+ async (args) => {
584
+ const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/items`;
585
+ const preview = validateApiBridgeRequest({
586
+ method: 'POST',
587
+ path,
588
+ dryRun: args.dryRun,
589
+ approved: args.approved,
590
+ reason: args.reason,
591
+ idempotencyKey: args.idempotencyKey,
592
+ });
593
+ if (preview?.dryRun) {
594
+ return ok({
595
+ ...preview,
596
+ wouldCreate: { boardId: args.boardId, title: args.title, kind: args.kind },
597
+ // A statement of FACT about the body, not a guess about its contents.
598
+ // "Does this text contain acceptance criteria" is not something a
599
+ // regex can answer honestly, and a heuristic that half-answered it
600
+ // would either block a legitimate one-line capture or bless a body
601
+ // with a heading and nothing under it. What IS checkable is that
602
+ // there is no body at all — a title-only card, which cannot be QA'd
603
+ // and cannot be built from. Said in the preview, where a human is
604
+ // already deciding whether to approve, and never blocking: a refused
605
+ // capture is how work stops reaching the board at all.
606
+ note: describeMissingBody(args.body),
607
+ });
608
+ }
609
+
610
+ const data = await api('POST', path, {
611
+ body: {
612
+ title: args.title,
613
+ body: args.body,
614
+ product: args.product,
615
+ // Undefined when the caller named no kind, so the key is dropped from
616
+ // the JSON and the card is stored untyped rather than defaulted.
617
+ kind: args.kind,
618
+ columnId: args.columnId,
619
+ assigneeUserId: args.assigneeUserId,
620
+ },
621
+ headers: buildMutationHeaders(args),
622
+ });
623
+ return ok({ created: true, item: data?.data });
624
+ }
625
+ );
626
+ server.tool(
627
+ 'comment_on_work_item',
628
+ `Say something on a card: a question, a finding, or the reason a QA pass sent it back. This is the ONLY place to put a fact that contradicts the card — the ship-the-card skill tells you to say so on the card rather than silently fixing something else, and this is where that goes. Do NOT overwrite the card's body to make the point: the body is the original request, and rewriting it destroys the evidence of what was actually asked for.
629
+
630
+ A comment is deliberately small: no threading, no reactions, no formatting. One author, one paragraph, in order.
631
+
632
+ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id comes from the card's assignee, its history, or list_my_work_items. Anything else you type is literal text: writing "@noah" mentions nobody, which is deliberate. A mention means "see this" and NOTHING more — it does not assign the card, add a watcher, or raise a flag. Only workspace members resolve; a token naming anyone else stays as text and notifies no one.${GOVERNED_NOTE}`,
633
+ {
634
+ itemId: z.string().describe('Card id.'),
635
+ body: z
636
+ .string()
637
+ .describe(
638
+ 'What you want to say. Plain text; newlines are kept. Capped at 2000 characters — longer than that belongs in the card description or a linked document. Use `<@userId>` to @-mention a workspace member; bare "@name" is literal text and mentions nobody.'
639
+ ),
640
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to post it.'),
641
+ approved: z.boolean().optional().describe('Required true for a live post.'),
642
+ reason: z
643
+ .string()
644
+ .optional()
645
+ .describe('Required for a live post: why you are commenting. This is the audit reason, NOT the comment — the comment is `body`.'),
646
+ idempotencyKey: z
647
+ .string()
648
+ .optional()
649
+ .describe(
650
+ 'Required for a live post. Reuse the SAME key on retry: a replayed comment is just the same paragraph twice, which reads as you being emphatic rather than as a retry.'
651
+ ),
652
+ },
653
+ async (args) => {
654
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`;
655
+ const preview = validateApiBridgeRequest({
656
+ method: 'POST',
657
+ path,
658
+ dryRun: args.dryRun,
659
+ approved: args.approved,
660
+ reason: args.reason,
661
+ idempotencyKey: args.idempotencyKey,
662
+ });
663
+ if (preview?.dryRun) {
664
+ return ok({ ...preview, wouldComment: { itemId: args.itemId, body: args.body } });
665
+ }
666
+
667
+ const data = await api('POST', path, {
668
+ body: { body: args.body, idempotencyKey: args.idempotencyKey },
669
+ headers: buildMutationHeaders(args),
670
+ });
671
+ return ok({ commented: true, comment: data?.data });
672
+ }
673
+ );
674
+
675
+ server.tool(
676
+ 'flag_work_item',
677
+ `Flag a card as having a problem WITH THE CARD — unclear scope, no repro, a blocked dependency, a question waiting on an answer — or clear a flag once it is resolved. A flagged card is drawn differently on the board so nobody picks it up by mistake.
678
+
679
+ A FLAG IS NOT A TAG. The tag says how urgent the WORK is; a flag says the CARD is not fit to be worked. If a card is merely important, use set_work_item_tag. If you cannot start because something is missing, flag it.
680
+
681
+ THE REASON IS REQUIRED IN BOTH DIRECTIONS, and it is not the audit reason — it is the text a human reads on the card. Raising says what is wrong; clearing says what resolved it. Anyone may clear a flag, which only works because both directions are recorded as a comment on the card.
682
+
683
+ This is what the ship-the-card skill means by "an unworkable card is a triage problem, not a coding problem": flag it, say the one question that would unblock it, and stop.${GOVERNED_NOTE}`,
684
+ {
685
+ itemId: z.string().describe('Card id.'),
686
+ flagged: z
687
+ .boolean()
688
+ .describe('true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'),
689
+ reason: z
690
+ .string()
691
+ .describe(
692
+ 'REQUIRED. Raising: what is wrong with the card. Clearing: what resolved it. Shown on the card and appended to its comment thread — write it for the person who will read it, not for a log.'
693
+ ),
694
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to change the flag.'),
695
+ approved: z.boolean().optional().describe('Required true for a live change.'),
696
+ idempotencyKey: z
697
+ .string()
698
+ .optional()
699
+ .describe('Required for a live change. Reuse the SAME key on retry — the flag also appends a comment.'),
700
+ },
701
+ async (args) => {
702
+ if (!args.reason || !args.reason.trim()) {
703
+ return ok({
704
+ error: true,
705
+ message:
706
+ 'reason is required to flag or to clear. A flag with nothing written on it stops work without saying how to restart it, and a flag anyone can silently clear is a flag nobody trusts.',
707
+ });
708
+ }
709
+
710
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
711
+ const preview = validateApiBridgeRequest({
712
+ method: 'POST',
713
+ path,
714
+ dryRun: args.dryRun,
715
+ approved: args.approved,
716
+ // The flag's own reason doubles as the governed audit reason. They are
717
+ // genuinely the same fact here — unlike a comment, where the audit
718
+ // reason explains the write and `body` is the content.
719
+ reason: args.reason,
720
+ idempotencyKey: args.idempotencyKey,
721
+ });
722
+ if (preview?.dryRun) {
723
+ return ok({
724
+ ...preview,
725
+ wouldFlag: { itemId: args.itemId, flagged: args.flagged, reason: args.reason },
726
+ });
727
+ }
728
+
729
+ const data = await api('POST', path, {
730
+ body: {
731
+ flagged: args.flagged,
732
+ reason: args.reason,
733
+ idempotencyKey: args.idempotencyKey,
734
+ },
735
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
736
+ });
737
+ return ok({ flagged: args.flagged, item: data?.data });
738
+ }
739
+ );
740
+ }
741
+
742
+ /** Tool names registered here, for the tier map and the toolset manifest. */
743
+ export const WORK_BOARD_TOOL_NAMES = [
744
+ 'list_my_work_items',
745
+ 'list_work_boards',
746
+ 'get_work_board',
747
+ 'get_work_item',
748
+ 'get_work_item_history',
749
+ 'get_work_board_rollup',
750
+ 'list_work_board_rollups',
751
+ 'move_work_item',
752
+ 'set_work_item_tag',
753
+ 'set_work_item_kind',
754
+ 'create_work_item',
755
+ 'get_work_item_comments',
756
+ 'comment_on_work_item',
757
+ 'flag_work_item',
758
+ ];