@adrata/adrata-mcp 1.0.1 → 1.0.3
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.
- package/README.md +1 -1
- package/access/auth.js +193 -4
- package/access/oauth.js +105 -15
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +35 -0
- package/api-bridge.js +84 -5
- package/governance/governed-args.js +261 -0
- package/package.json +5 -4
- package/product-profile.js +43 -0
- package/resources.js +22 -10
- package/security.js +34 -6
- package/server.js +282 -58
- package/server.json +0 -6
- package/skills/board-review/SKILL.md +10 -2
- package/skills/incident-to-card/SKILL.md +23 -15
- package/skills/ship-the-card/SKILL.md +27 -4
- package/tool-annotations.js +63 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +455 -16
- package/tools/work-hub/audit.js +162 -0
- package/toolsets/crm.js +72 -10
- package/toolsets/prospecting.js +118 -12
- package/toolsets/spaces.js +628 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Work-board tools for the Adrata MCP Server.
|
|
3
5
|
*
|
|
@@ -110,14 +112,9 @@ export function describeUnauditableReason(reason) {
|
|
|
110
112
|
/**
|
|
111
113
|
* Why a card with no body will not be workable, or `null` if it has one.
|
|
112
114
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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.
|
|
115
|
+
* The body is working context: request, evidence, repro, constraints. The
|
|
116
|
+
* executable definition of done lives in first-class acceptance-criteria rows
|
|
117
|
+
* and has its own check below, so prose is never regex-classified as criteria.
|
|
121
118
|
*
|
|
122
119
|
* A MISSING body is a fact, so that is what this reports — as a note on the
|
|
123
120
|
* dry-run preview, where a human is already deciding, and never as a refusal.
|
|
@@ -126,18 +123,48 @@ export function describeUnauditableReason(reason) {
|
|
|
126
123
|
*/
|
|
127
124
|
export function describeMissingBody(body) {
|
|
128
125
|
if (String(body ?? '').trim()) return undefined;
|
|
129
|
-
return 'This card would arrive with a title and
|
|
126
|
+
return 'This card would arrive with a title and no working context. Add the request, evidence, or repro to `body`, or say what is still unknown. Acceptance criteria are separate first-class records in `acceptanceCriteria`.';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function describeMissingAcceptanceCriteria(criteria) {
|
|
130
|
+
if (Array.isArray(criteria) && criteria.length > 0) return undefined;
|
|
131
|
+
return 'This card would have no executable acceptance criteria. It can be captured, but it is not ready for build or QA until at least one where/when/then criterion is added.';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The satisfaction sub-resource of one criterion, POSTed to tick and DELETEd to
|
|
136
|
+
* un-tick.
|
|
137
|
+
*
|
|
138
|
+
* One function for both verbs because they address the SAME resource: a tick
|
|
139
|
+
* and its removal that disagreed about the path would fail asymmetrically —
|
|
140
|
+
* ticks landing and un-ticks 404ing — which is the failure that leaves a board
|
|
141
|
+
* with checkboxes nobody can clear.
|
|
142
|
+
*/
|
|
143
|
+
export function criterionSatisfactionPath(itemId, criterionId) {
|
|
144
|
+
return `/api/v1/work-items/${encodeURIComponent(itemId)}/acceptance-criteria/${encodeURIComponent(criterionId)}/satisfaction`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One target stage's WIP state before and after a proposed move. */
|
|
148
|
+
export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = false }) {
|
|
149
|
+
const projected = count + (cardAlreadyThere ? 0 : 1);
|
|
150
|
+
return {
|
|
151
|
+
limit: limit ?? null,
|
|
152
|
+
current: count,
|
|
153
|
+
projected,
|
|
154
|
+
overLimit: limit == null ? false : projected > limit,
|
|
155
|
+
completeCount: !truncated,
|
|
156
|
+
};
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
/**
|
|
133
160
|
* Register the work-board tools.
|
|
134
161
|
*
|
|
135
162
|
* @param {McpServer} server - the MCP server instance (already tier-gated)
|
|
136
|
-
* @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
|
|
163
|
+
* @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
|
|
137
164
|
*/
|
|
138
165
|
export function registerWorkBoardTools(
|
|
139
166
|
server,
|
|
140
|
-
{ z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
|
|
167
|
+
{ z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
|
|
141
168
|
) {
|
|
142
169
|
const GOVERNED_NOTE =
|
|
143
170
|
' 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).';
|
|
@@ -262,6 +289,54 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
262
289
|
}
|
|
263
290
|
);
|
|
264
291
|
|
|
292
|
+
server.tool(
|
|
293
|
+
'get_work_item_delivery_evidence',
|
|
294
|
+
`Read the four facts that must never be collapsed into one status: the card's workflow column, linked pull-request state, exact-SHA staging presence, and exact-SHA production presence. Use this before calling a card shipped or treating the Production column as proof. Unknown means Adrata has no trustworthy evidence; it never means the work is absent. This tool is read-only and does not move the card.`,
|
|
295
|
+
{
|
|
296
|
+
itemId: z.string().describe('Card id.'),
|
|
297
|
+
},
|
|
298
|
+
async (args) => {
|
|
299
|
+
const data = await api(
|
|
300
|
+
'GET',
|
|
301
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/delivery-evidence`
|
|
302
|
+
);
|
|
303
|
+
const evidence = data?.data;
|
|
304
|
+
const contradictions = deliveryContradictions(evidence);
|
|
305
|
+
return ok({ evidence, contradictions });
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
server.tool(
|
|
310
|
+
'list_work_item_acceptance_criteria',
|
|
311
|
+
'Read the ordered, executable definition of done for one card. Each criterion says where to check, any starting state, what action to perform, and the observable result. A zero-length list is a finding: nobody has stated how QA can decide the card is done.',
|
|
312
|
+
{ itemId: z.string().describe('Card id.') },
|
|
313
|
+
async (args) => {
|
|
314
|
+
const data = await api(
|
|
315
|
+
'GET',
|
|
316
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`
|
|
317
|
+
);
|
|
318
|
+
const criteria = data?.data ?? [];
|
|
319
|
+
return ok({ count: criteria.length, criteria });
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
server.tool(
|
|
324
|
+
'audit_work_hub',
|
|
325
|
+
`Audit every visible Starfield board as an operating ledger. Returns named findings for active cards without owners, active passes without handlers, missing definitions of done, missing work types or context, stale stages, and truncated reads. It does not award a vanity score and it does not mutate anything. A clean result means the board has the minimum workflow facts required to operate from it; deployment truth remains a separate exact-SHA fact available through get_work_item_delivery_evidence.`,
|
|
326
|
+
{},
|
|
327
|
+
async () => {
|
|
328
|
+
const listed = await api('GET', '/api/v1/work-boards');
|
|
329
|
+
const refs = listed?.data ?? [];
|
|
330
|
+
const boards = await Promise.all(
|
|
331
|
+
refs.map(async (board) => {
|
|
332
|
+
const detail = await api('GET', `/api/v1/work-boards/${encodeURIComponent(board.id)}`);
|
|
333
|
+
return detail?.data;
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
return ok(auditWorkHubBoards(boards.filter(Boolean)));
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
265
340
|
server.tool(
|
|
266
341
|
'get_work_board_rollup',
|
|
267
342
|
'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.',
|
|
@@ -317,6 +392,103 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
317
392
|
// WRITES
|
|
318
393
|
// =========================================================================
|
|
319
394
|
|
|
395
|
+
server.tool(
|
|
396
|
+
'set_work_board_archived',
|
|
397
|
+
`Hide or restore a board in the workspace catalogue without deleting anything. Archived boards disappear from normal board lists and roll-ups, but their cards, history, releases, QA flows, memberships, and evidence remain intact. Use this for obsolete, duplicate, or no-longer-operated boards; never manufacture a release or delete cards merely to clean up the board chooser.${GOVERNED_NOTE}`,
|
|
398
|
+
{
|
|
399
|
+
boardId: z.string().describe('Board id from list_work_boards.'),
|
|
400
|
+
archived: z.boolean().describe('true hides the board; false restores it.'),
|
|
401
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
|
|
402
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
403
|
+
reason: z.string().optional().describe('Required for a live change: why this board is being hidden or restored.'),
|
|
404
|
+
idempotencyKey: z.string().optional().describe('Required for a live change.'),
|
|
405
|
+
},
|
|
406
|
+
async (args) => {
|
|
407
|
+
const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/archived`;
|
|
408
|
+
const preview = validateApiBridgeRequest({
|
|
409
|
+
method: 'PATCH',
|
|
410
|
+
path,
|
|
411
|
+
dryRun: args.dryRun,
|
|
412
|
+
approved: args.approved,
|
|
413
|
+
reason: args.reason,
|
|
414
|
+
idempotencyKey: args.idempotencyKey,
|
|
415
|
+
grantedScope: getGrantedScope(),
|
|
416
|
+
});
|
|
417
|
+
if (preview?.dryRun) {
|
|
418
|
+
const data = await api('GET', `/api/v1/work-boards/${encodeURIComponent(args.boardId)}`);
|
|
419
|
+
const board = data?.data;
|
|
420
|
+
const terminalNames = new Set(['production', 'deep backlog']);
|
|
421
|
+
const activeCount = (board?.items ?? []).filter((item) => {
|
|
422
|
+
const column = board?.columns?.find((candidate) => candidate.id === item.columnId);
|
|
423
|
+
return column && !terminalNames.has(String(column.name).trim().toLowerCase());
|
|
424
|
+
}).length;
|
|
425
|
+
return ok({
|
|
426
|
+
...preview,
|
|
427
|
+
wouldSetBoardArchived: {
|
|
428
|
+
boardId: args.boardId,
|
|
429
|
+
boardName: board?.name,
|
|
430
|
+
archived: args.archived,
|
|
431
|
+
visibleItemCount: board?.itemCount ?? (board?.items ?? []).length,
|
|
432
|
+
activeItemCount: activeCount,
|
|
433
|
+
completeCount: board?.truncated !== true,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const data = await api('PATCH', path, {
|
|
439
|
+
body: { archived: args.archived },
|
|
440
|
+
headers: buildMutationHeaders(args),
|
|
441
|
+
});
|
|
442
|
+
return ok({ boardArchiveStateSet: true, ...data?.data });
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
server.tool(
|
|
447
|
+
'set_work_board_column_wip_limit',
|
|
448
|
+
`Set or clear one board column's visible WIP ceiling. Exceeding the ceiling never blocks a move: the board renders the measured count and limit in red so daily Aligning can answer for it. Only a workspace admin may change this operating policy. Production and Deep backlog cannot have limits because they are output and parking columns, not work centres.${GOVERNED_NOTE}`,
|
|
449
|
+
{
|
|
450
|
+
boardId: z.string().describe('Board id from list_work_boards.'),
|
|
451
|
+
columnId: z.string().describe('Column id from get_work_board.'),
|
|
452
|
+
wipLimit: z
|
|
453
|
+
.number()
|
|
454
|
+
.int()
|
|
455
|
+
.positive()
|
|
456
|
+
.nullable()
|
|
457
|
+
.describe('Positive ceiling, or null to clear it (unlimited).'),
|
|
458
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to change it.'),
|
|
459
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
460
|
+
reason: z.string().optional().describe('Required for a live change: why this capacity is right.'),
|
|
461
|
+
idempotencyKey: z.string().optional().describe('Required for a live change. Reuse on retry.'),
|
|
462
|
+
},
|
|
463
|
+
async (args) => {
|
|
464
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
465
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
466
|
+
|
|
467
|
+
const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/columns/${encodeURIComponent(args.columnId)}/wip-limit`;
|
|
468
|
+
const preview = validateApiBridgeRequest({
|
|
469
|
+
method: 'PATCH',
|
|
470
|
+
path,
|
|
471
|
+
dryRun: args.dryRun,
|
|
472
|
+
approved: args.approved,
|
|
473
|
+
reason: args.reason,
|
|
474
|
+
idempotencyKey: args.idempotencyKey,
|
|
475
|
+
grantedScope: getGrantedScope(),
|
|
476
|
+
});
|
|
477
|
+
if (preview?.dryRun) {
|
|
478
|
+
return ok({
|
|
479
|
+
...preview,
|
|
480
|
+
wouldSet: { boardId: args.boardId, columnId: args.columnId, wipLimit: args.wipLimit },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const data = await api('PATCH', path, {
|
|
485
|
+
body: { wipLimit: args.wipLimit },
|
|
486
|
+
headers: buildMutationHeaders(args),
|
|
487
|
+
});
|
|
488
|
+
return ok({ updated: true, column: data?.data });
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
|
|
320
492
|
server.tool(
|
|
321
493
|
'move_work_item',
|
|
322
494
|
`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.
|
|
@@ -343,7 +515,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
343
515
|
.boolean()
|
|
344
516
|
.optional()
|
|
345
517
|
.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
|
|
518
|
+
'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 a deliberate edit to its assignee, never a side effect of a move — and there is no board tool here that does it, so reassignment is a human act on the board (or an explicit PATCH via adrata_api_request). Do not reach for this to work around a refusal; a pass somebody is running is theirs.'
|
|
347
519
|
),
|
|
348
520
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
|
|
349
521
|
approved: z.boolean().optional().describe('Required true for a live move.'),
|
|
@@ -377,8 +549,29 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
377
549
|
approved: args.approved,
|
|
378
550
|
reason: args.reason,
|
|
379
551
|
idempotencyKey: args.idempotencyKey,
|
|
552
|
+
grantedScope: getGrantedScope(),
|
|
380
553
|
});
|
|
381
554
|
if (preview?.dryRun) {
|
|
555
|
+
const itemData = await api(
|
|
556
|
+
'GET',
|
|
557
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}`
|
|
558
|
+
);
|
|
559
|
+
const item = itemData?.data;
|
|
560
|
+
const boardData = await api(
|
|
561
|
+
'GET',
|
|
562
|
+
`/api/v1/work-boards/${encodeURIComponent(item.boardId)}`
|
|
563
|
+
);
|
|
564
|
+
const board = boardData?.data;
|
|
565
|
+
const target = board?.columns?.find((column) => column.id === args.toColumnId);
|
|
566
|
+
if (!target) {
|
|
567
|
+
return ok({
|
|
568
|
+
error: true,
|
|
569
|
+
message: 'toColumnId is not a column on this card\'s board.',
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const count = (board.items ?? []).filter(
|
|
573
|
+
(candidate) => candidate.columnId === args.toColumnId
|
|
574
|
+
).length;
|
|
382
575
|
return ok({
|
|
383
576
|
...preview,
|
|
384
577
|
wouldMove: {
|
|
@@ -388,6 +581,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
388
581
|
claim: args.claim === true,
|
|
389
582
|
force: args.force === true,
|
|
390
583
|
},
|
|
584
|
+
wip: projectColumnWip({
|
|
585
|
+
limit: target.wipLimit,
|
|
586
|
+
count,
|
|
587
|
+
cardAlreadyThere: item.columnId === args.toColumnId,
|
|
588
|
+
truncated: board.truncated === true,
|
|
589
|
+
}),
|
|
391
590
|
});
|
|
392
591
|
}
|
|
393
592
|
|
|
@@ -467,6 +666,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
467
666
|
approved: args.approved,
|
|
468
667
|
reason: args.reason,
|
|
469
668
|
idempotencyKey: args.idempotencyKey,
|
|
669
|
+
grantedScope: getGrantedScope(),
|
|
470
670
|
});
|
|
471
671
|
if (preview?.dryRun) {
|
|
472
672
|
return ok({
|
|
@@ -523,6 +723,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
523
723
|
approved: args.approved,
|
|
524
724
|
reason: args.reason,
|
|
525
725
|
idempotencyKey: args.idempotencyKey,
|
|
726
|
+
grantedScope: getGrantedScope(),
|
|
526
727
|
});
|
|
527
728
|
if (preview?.dryRun) {
|
|
528
729
|
return ok({
|
|
@@ -547,7 +748,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
547
748
|
'create_work_item',
|
|
548
749
|
`Create a card on a board. Lands in the named column, or the board's first column when none is given.
|
|
549
750
|
|
|
550
|
-
|
|
751
|
+
ACCEPTANCE CRITERIA ARE FIRST-CLASS RECORDS, not prose buried in \`body\`. Use \`acceptanceCriteria\` for the executable definition of done: where to check, any starting state, what action to perform, and the observable result. The card and every criterion are replay-safe under one idempotency-key family, so a retry after a partial failure cannot duplicate either. If you cannot write criteria, capture what you know in \`body\`; the preview will mark the card as not ready rather than inventing outcomes nobody agreed to.
|
|
551
752
|
|
|
552
753
|
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
754
|
|
|
@@ -559,7 +760,20 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
559
760
|
.string()
|
|
560
761
|
.optional()
|
|
561
762
|
.describe(
|
|
562
|
-
'The request
|
|
763
|
+
'The request, evidence, and repro/context. Markdown. The executable definition of done belongs in acceptanceCriteria, not in this prose.'
|
|
764
|
+
),
|
|
765
|
+
acceptanceCriteria: z
|
|
766
|
+
.array(
|
|
767
|
+
z.object({
|
|
768
|
+
whereText: z.string().describe('Surface, environment, account, or role to check.'),
|
|
769
|
+
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
770
|
+
whenText: z.string().describe('Action the verifier performs.'),
|
|
771
|
+
thenText: z.string().describe('Observable result that must follow.'),
|
|
772
|
+
})
|
|
773
|
+
)
|
|
774
|
+
.optional()
|
|
775
|
+
.describe(
|
|
776
|
+
'Executable definition of done. One independently checkable outcome per entry. Omit only for a thin capture that is not ready to build.'
|
|
563
777
|
),
|
|
564
778
|
product: z.string().optional().describe('Product tag — orthogonal to the board.'),
|
|
565
779
|
kind: z
|
|
@@ -589,11 +803,17 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
589
803
|
approved: args.approved,
|
|
590
804
|
reason: args.reason,
|
|
591
805
|
idempotencyKey: args.idempotencyKey,
|
|
806
|
+
grantedScope: getGrantedScope(),
|
|
592
807
|
});
|
|
593
808
|
if (preview?.dryRun) {
|
|
594
809
|
return ok({
|
|
595
810
|
...preview,
|
|
596
|
-
wouldCreate: {
|
|
811
|
+
wouldCreate: {
|
|
812
|
+
boardId: args.boardId,
|
|
813
|
+
title: args.title,
|
|
814
|
+
kind: args.kind,
|
|
815
|
+
acceptanceCriteria: args.acceptanceCriteria ?? [],
|
|
816
|
+
},
|
|
597
817
|
// A statement of FACT about the body, not a guess about its contents.
|
|
598
818
|
// "Does this text contain acceptance criteria" is not something a
|
|
599
819
|
// regex can answer honestly, and a heuristic that half-answered it
|
|
@@ -604,6 +824,7 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
604
824
|
// already deciding whether to approve, and never blocking: a refused
|
|
605
825
|
// capture is how work stops reaching the board at all.
|
|
606
826
|
note: describeMissingBody(args.body),
|
|
827
|
+
readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
|
|
607
828
|
});
|
|
608
829
|
}
|
|
609
830
|
|
|
@@ -620,9 +841,217 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
620
841
|
},
|
|
621
842
|
headers: buildMutationHeaders(args),
|
|
622
843
|
});
|
|
623
|
-
|
|
844
|
+
const item = data?.data;
|
|
845
|
+
const criteria = [];
|
|
846
|
+
for (const [index, criterion] of (args.acceptanceCriteria ?? []).entries()) {
|
|
847
|
+
const criterionKey = `${args.idempotencyKey}:criterion:${index + 1}`;
|
|
848
|
+
const result = await api(
|
|
849
|
+
'POST',
|
|
850
|
+
`/api/v1/work-items/${encodeURIComponent(item.id)}/acceptance-criteria`,
|
|
851
|
+
{
|
|
852
|
+
body: criterion,
|
|
853
|
+
headers: buildMutationHeaders({
|
|
854
|
+
...args,
|
|
855
|
+
idempotencyKey: criterionKey,
|
|
856
|
+
}),
|
|
857
|
+
}
|
|
858
|
+
);
|
|
859
|
+
criteria.push(result?.data);
|
|
860
|
+
}
|
|
861
|
+
return ok({ created: true, item, criteria });
|
|
624
862
|
}
|
|
625
863
|
);
|
|
864
|
+
|
|
865
|
+
server.tool(
|
|
866
|
+
'add_work_item_acceptance_criterion',
|
|
867
|
+
`Add one executable acceptance criterion to a card. This is grooming, not a comment: write where the check runs, the action, and the observable result. The server keeps criteria ordered and auditable.${GOVERNED_NOTE}`,
|
|
868
|
+
{
|
|
869
|
+
itemId: z.string().describe('Card id.'),
|
|
870
|
+
whereText: z.string().describe('Surface, environment, account, or role to check.'),
|
|
871
|
+
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
872
|
+
whenText: z.string().describe('Action the verifier performs.'),
|
|
873
|
+
thenText: z.string().describe('Observable result that must follow.'),
|
|
874
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
|
|
875
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
876
|
+
reason: z.string().optional().describe('Required for a live write: why this criterion is being added.'),
|
|
877
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
878
|
+
},
|
|
879
|
+
async (args) => {
|
|
880
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`;
|
|
881
|
+
const preview = validateApiBridgeRequest({
|
|
882
|
+
method: 'POST',
|
|
883
|
+
path,
|
|
884
|
+
...args,
|
|
885
|
+
grantedScope: getGrantedScope(),
|
|
886
|
+
});
|
|
887
|
+
const criterion = {
|
|
888
|
+
whereText: args.whereText,
|
|
889
|
+
givenText: args.givenText,
|
|
890
|
+
whenText: args.whenText,
|
|
891
|
+
thenText: args.thenText,
|
|
892
|
+
};
|
|
893
|
+
if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
|
|
894
|
+
const data = await api('POST', path, {
|
|
895
|
+
body: criterion,
|
|
896
|
+
headers: buildMutationHeaders(args),
|
|
897
|
+
});
|
|
898
|
+
return ok({ added: true, criterion: data?.data });
|
|
899
|
+
}
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
server.tool(
|
|
903
|
+
'satisfy_work_item_acceptance_criterion',
|
|
904
|
+
`Tick one acceptance criterion, with the evidence that made you believe it. This is the act that turns a checklist into proof: a criterion nobody ticks is indistinguishable from one nobody ran, and a board full of untouched checkboxes reports the same thing whether the work was verified or abandoned.
|
|
905
|
+
|
|
906
|
+
WHERE YOU TICK FROM DECIDES WHAT THE TICK IS WORTH, and it is not a permission you can ask for. The grade is derived on every read from the card's CURRENT column and from who you are:
|
|
907
|
+
|
|
908
|
+
• \`verified\` — ticked while the card stands in a QA stage ("Staging QA1" or "Staging QA2"), by somebody who neither wrote the criterion nor owns the card.
|
|
909
|
+
• \`claimed\` — everything else. Ticked in a build column, or ticked at a QA gate by the card's owner or by the person who authored the check. A real and useful state: it says the work is believed done. It is self-assessment, and the board never counts it as verification.
|
|
910
|
+
|
|
911
|
+
So the engineer who built the card CANNOT verify their own work, and neither can whoever wrote the criterion — not because the tick is refused, but because it grades as \`claimed\` however it is worded. Nothing here fails on that account; you get the criterion back carrying a grade, and the grade may not be the one you expected. If you want \`verified\`, the card has to be standing at a QA gate and somebody else has to be doing the ticking.
|
|
912
|
+
|
|
913
|
+
TICK WHAT YOU RAN, not what you believe. The card face prints met over total; the QA exit gate reads the VERIFIED count. So a card whose boxes were all ticked by its own author reads 5/5 on the face and still stops the gate — which is the design working. Ticking without running the check converts an honest "we shipped with two open" into a false "all met", and the false version is the one that gets believed later.
|
|
914
|
+
|
|
915
|
+
FIRST TICK WINS. The server records the ticker, the column and the note only where they were empty, so a second person ticking an already-ticked box cannot overwrite the first person's evidence, and re-ticking is a no-op rather than an upgrade. To re-tick under different circumstances, un-tick it first with unsatisfy_work_item_acceptance_criterion — that clears the ticker, the column and the note together, so the next tick cannot inherit somebody else's evidence.${GOVERNED_NOTE}`,
|
|
916
|
+
{
|
|
917
|
+
itemId: z.string().describe('Card id.'),
|
|
918
|
+
criterionId: z
|
|
919
|
+
.string()
|
|
920
|
+
.describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
|
|
921
|
+
note: z
|
|
922
|
+
.string()
|
|
923
|
+
.optional()
|
|
924
|
+
.describe(
|
|
925
|
+
'OPTIONAL, and the most useful field here: the evidence. A build number, a PR or run link, or the caveat that makes the tick honest ("only checked on Safari"). Stored on the criterion and shown beside it, so it is what a release review reads instead of taking the tick on trust. Omit it when there is genuinely nothing to add — an invented note is worse than none.'
|
|
926
|
+
),
|
|
927
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to record the tick.'),
|
|
928
|
+
approved: z.boolean().optional().describe('Required true for a live tick.'),
|
|
929
|
+
reason: z
|
|
930
|
+
.string()
|
|
931
|
+
.optional()
|
|
932
|
+
.describe(
|
|
933
|
+
'Required for a live tick: the audit reason — how you checked this, not what the criterion already says. The evidence a human reads on the card is `note`.'
|
|
934
|
+
),
|
|
935
|
+
idempotencyKey: z
|
|
936
|
+
.string()
|
|
937
|
+
.optional()
|
|
938
|
+
.describe('Required for a live tick. Reuse the SAME key on retry.'),
|
|
939
|
+
},
|
|
940
|
+
async (args) => {
|
|
941
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
942
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
943
|
+
|
|
944
|
+
const path = criterionSatisfactionPath(args.itemId, args.criterionId);
|
|
945
|
+
const preview = validateApiBridgeRequest({
|
|
946
|
+
method: 'POST',
|
|
947
|
+
path,
|
|
948
|
+
dryRun: args.dryRun,
|
|
949
|
+
approved: args.approved,
|
|
950
|
+
reason: args.reason,
|
|
951
|
+
idempotencyKey: args.idempotencyKey,
|
|
952
|
+
grantedScope: getGrantedScope(),
|
|
953
|
+
});
|
|
954
|
+
if (preview?.dryRun) {
|
|
955
|
+
return ok({
|
|
956
|
+
...preview,
|
|
957
|
+
wouldSatisfy: {
|
|
958
|
+
itemId: args.itemId,
|
|
959
|
+
criterionId: args.criterionId,
|
|
960
|
+
note: args.note ?? null,
|
|
961
|
+
},
|
|
962
|
+
// Said in the preview because the preview is where a caller decides
|
|
963
|
+
// whether to go live, and the one thing it CANNOT tell them is the
|
|
964
|
+
// answer they want. Predicting the grade here would need the card's
|
|
965
|
+
// current column and the criterion's author, and a prediction made
|
|
966
|
+
// from a stale read is worse than an honest refusal to predict.
|
|
967
|
+
gradeIsDerived:
|
|
968
|
+
'The grade is computed at read time from the column this card is standing in and from who you are, so this preview cannot tell you which you will get. Ticking from a QA stage, as somebody who is neither the criterion\'s author nor the card\'s owner, grades `verified`; anything else grades `claimed`.',
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const data = await api('POST', path, {
|
|
973
|
+
// The note is the only field this endpoint takes, and it is optional:
|
|
974
|
+
// `SatisfyCriterionRequest` defaults it, so an omitted note is a valid
|
|
975
|
+
// body rather than a missing one. Sending `{}` is deliberate — a tick
|
|
976
|
+
// with nothing to add must stay recordable, because the alternative is
|
|
977
|
+
// an agent inventing evidence to satisfy a required field.
|
|
978
|
+
body: args.note === undefined ? {} : { note: args.note },
|
|
979
|
+
headers: buildMutationHeaders(args),
|
|
980
|
+
});
|
|
981
|
+
const criterion = data?.data;
|
|
982
|
+
return ok({
|
|
983
|
+
satisfied: true,
|
|
984
|
+
criterion,
|
|
985
|
+
// The grade is the answer, and it is the server's to give. Echoing an
|
|
986
|
+
// optimistic "verified" from the request would be this tool asserting
|
|
987
|
+
// the exact fact the grading rule exists to withhold.
|
|
988
|
+
grade: criterion?.grade,
|
|
989
|
+
...(criterion?.grade === 'claimed'
|
|
990
|
+
? {
|
|
991
|
+
gradeNote:
|
|
992
|
+
'Recorded as `claimed`, not `verified`: either the card was not standing in a QA stage, or you wrote this criterion or own the card. That is the rule working rather than a failure — the tick stands and the card face counts it. Verification needs a QA stage and a different pair of hands.',
|
|
993
|
+
}
|
|
994
|
+
: {}),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
server.tool(
|
|
1000
|
+
'unsatisfy_work_item_acceptance_criterion',
|
|
1001
|
+
`Un-tick one acceptance criterion, back to \`unmet\`. Use it when the check turns out not to hold, when it was ticked against the wrong build, or when it was ticked from a build column and has to be re-run at the gate to count as verified.
|
|
1002
|
+
|
|
1003
|
+
This exists so that ticking is safe to do. A box nobody can clear is a box people hesitate to tick, and that hesitation is exactly how a board ends up with a full checklist that nobody has ever touched — so shipping the tick without its counterpart would have left the same problem in a new shape.
|
|
1004
|
+
|
|
1005
|
+
It clears the WHOLE circumstance together — the ticker, the column, the note and the timestamp — never just the timestamp. Otherwise the next tick would inherit somebody else's evidence: a criterion re-ticked in Aligning would keep reading as verified in QA1, attributed to a person who was not there. So the cost is real, and it is the note: the first ticker's evidence is gone. If it is worth keeping, put it on the card with comment_on_work_item BEFORE you clear it.
|
|
1006
|
+
|
|
1007
|
+
Anyone who can see the card may un-tick, including somebody undoing another person's tick. The audit reason is the only record of why, so write it for them.${GOVERNED_NOTE}`,
|
|
1008
|
+
{
|
|
1009
|
+
itemId: z.string().describe('Card id.'),
|
|
1010
|
+
criterionId: z
|
|
1011
|
+
.string()
|
|
1012
|
+
.describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
|
|
1013
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to clear the tick.'),
|
|
1014
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
1015
|
+
reason: z
|
|
1016
|
+
.string()
|
|
1017
|
+
.optional()
|
|
1018
|
+
.describe(
|
|
1019
|
+
'Required for a live change: why this tick is coming off — the check failed on a later build, it was run against the wrong environment, it needs re-running at the gate. This is the only record the original ticker will have of losing their evidence.'
|
|
1020
|
+
),
|
|
1021
|
+
idempotencyKey: z
|
|
1022
|
+
.string()
|
|
1023
|
+
.optional()
|
|
1024
|
+
.describe('Required for a live change. Reuse the SAME key on retry.'),
|
|
1025
|
+
},
|
|
1026
|
+
async (args) => {
|
|
1027
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
1028
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
1029
|
+
|
|
1030
|
+
const path = criterionSatisfactionPath(args.itemId, args.criterionId);
|
|
1031
|
+
const preview = validateApiBridgeRequest({
|
|
1032
|
+
method: 'DELETE',
|
|
1033
|
+
path,
|
|
1034
|
+
dryRun: args.dryRun,
|
|
1035
|
+
approved: args.approved,
|
|
1036
|
+
reason: args.reason,
|
|
1037
|
+
idempotencyKey: args.idempotencyKey,
|
|
1038
|
+
grantedScope: getGrantedScope(),
|
|
1039
|
+
});
|
|
1040
|
+
if (preview?.dryRun) {
|
|
1041
|
+
return ok({
|
|
1042
|
+
...preview,
|
|
1043
|
+
wouldUnsatisfy: { itemId: args.itemId, criterionId: args.criterionId },
|
|
1044
|
+
clears:
|
|
1045
|
+
'The ticker, the column the tick happened in, the evidence note, and the timestamp — all four together, so a later tick cannot inherit this one. Copy the note onto the card first if it is worth keeping.',
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
|
|
1050
|
+
const criterion = data?.data;
|
|
1051
|
+
return ok({ unsatisfied: true, criterion, grade: criterion?.grade });
|
|
1052
|
+
}
|
|
1053
|
+
);
|
|
1054
|
+
|
|
626
1055
|
server.tool(
|
|
627
1056
|
'comment_on_work_item',
|
|
628
1057
|
`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.
|
|
@@ -659,6 +1088,7 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
|
|
|
659
1088
|
approved: args.approved,
|
|
660
1089
|
reason: args.reason,
|
|
661
1090
|
idempotencyKey: args.idempotencyKey,
|
|
1091
|
+
grantedScope: getGrantedScope(),
|
|
662
1092
|
});
|
|
663
1093
|
if (preview?.dryRun) {
|
|
664
1094
|
return ok({ ...preview, wouldComment: { itemId: args.itemId, body: args.body } });
|
|
@@ -718,6 +1148,7 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
|
|
|
718
1148
|
// reason explains the write and `body` is the content.
|
|
719
1149
|
reason: args.reason,
|
|
720
1150
|
idempotencyKey: args.idempotencyKey,
|
|
1151
|
+
grantedScope: getGrantedScope(),
|
|
721
1152
|
});
|
|
722
1153
|
if (preview?.dryRun) {
|
|
723
1154
|
return ok({
|
|
@@ -746,12 +1177,20 @@ export const WORK_BOARD_TOOL_NAMES = [
|
|
|
746
1177
|
'get_work_board',
|
|
747
1178
|
'get_work_item',
|
|
748
1179
|
'get_work_item_history',
|
|
1180
|
+
'get_work_item_delivery_evidence',
|
|
1181
|
+
'audit_work_hub',
|
|
1182
|
+
'list_work_item_acceptance_criteria',
|
|
749
1183
|
'get_work_board_rollup',
|
|
750
1184
|
'list_work_board_rollups',
|
|
1185
|
+
'set_work_board_archived',
|
|
1186
|
+
'set_work_board_column_wip_limit',
|
|
751
1187
|
'move_work_item',
|
|
752
1188
|
'set_work_item_tag',
|
|
753
1189
|
'set_work_item_kind',
|
|
754
1190
|
'create_work_item',
|
|
1191
|
+
'add_work_item_acceptance_criterion',
|
|
1192
|
+
'satisfy_work_item_acceptance_criterion',
|
|
1193
|
+
'unsatisfy_work_item_acceptance_criterion',
|
|
755
1194
|
'get_work_item_comments',
|
|
756
1195
|
'comment_on_work_item',
|
|
757
1196
|
'flag_work_item',
|