@adrata/adrata-mcp 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/access/auth.js +193 -4
- package/access/oauth.js +82 -8
- package/access/tiers.js +33 -0
- package/api-bridge.js +68 -5
- package/governance/governed-args.js +261 -0
- package/package.json +11 -6
- 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 +5 -4
- package/tool-annotations.js +57 -7
- package/tools/billing.js +5 -1
- package/tools/roadmap-tools.js +192 -0
- package/tools/work-board-tools.js +286 -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,35 @@ 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
|
+
/** One target stage's WIP state before and after a proposed move. */
|
|
135
|
+
export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = false }) {
|
|
136
|
+
const projected = count + (cardAlreadyThere ? 0 : 1);
|
|
137
|
+
return {
|
|
138
|
+
limit: limit ?? null,
|
|
139
|
+
current: count,
|
|
140
|
+
projected,
|
|
141
|
+
overLimit: limit == null ? false : projected > limit,
|
|
142
|
+
completeCount: !truncated,
|
|
143
|
+
};
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
/**
|
|
133
147
|
* Register the work-board tools.
|
|
134
148
|
*
|
|
135
149
|
* @param {McpServer} server - the MCP server instance (already tier-gated)
|
|
136
|
-
* @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
|
|
150
|
+
* @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
|
|
137
151
|
*/
|
|
138
152
|
export function registerWorkBoardTools(
|
|
139
153
|
server,
|
|
140
|
-
{ z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
|
|
154
|
+
{ z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
|
|
141
155
|
) {
|
|
142
156
|
const GOVERNED_NOTE =
|
|
143
157
|
' 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 +276,54 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
262
276
|
}
|
|
263
277
|
);
|
|
264
278
|
|
|
279
|
+
server.tool(
|
|
280
|
+
'get_work_item_delivery_evidence',
|
|
281
|
+
`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.`,
|
|
282
|
+
{
|
|
283
|
+
itemId: z.string().describe('Card id.'),
|
|
284
|
+
},
|
|
285
|
+
async (args) => {
|
|
286
|
+
const data = await api(
|
|
287
|
+
'GET',
|
|
288
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/delivery-evidence`
|
|
289
|
+
);
|
|
290
|
+
const evidence = data?.data;
|
|
291
|
+
const contradictions = deliveryContradictions(evidence);
|
|
292
|
+
return ok({ evidence, contradictions });
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
server.tool(
|
|
297
|
+
'list_work_item_acceptance_criteria',
|
|
298
|
+
'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.',
|
|
299
|
+
{ itemId: z.string().describe('Card id.') },
|
|
300
|
+
async (args) => {
|
|
301
|
+
const data = await api(
|
|
302
|
+
'GET',
|
|
303
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`
|
|
304
|
+
);
|
|
305
|
+
const criteria = data?.data ?? [];
|
|
306
|
+
return ok({ count: criteria.length, criteria });
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
server.tool(
|
|
311
|
+
'audit_work_hub',
|
|
312
|
+
`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.`,
|
|
313
|
+
{},
|
|
314
|
+
async () => {
|
|
315
|
+
const listed = await api('GET', '/api/v1/work-boards');
|
|
316
|
+
const refs = listed?.data ?? [];
|
|
317
|
+
const boards = await Promise.all(
|
|
318
|
+
refs.map(async (board) => {
|
|
319
|
+
const detail = await api('GET', `/api/v1/work-boards/${encodeURIComponent(board.id)}`);
|
|
320
|
+
return detail?.data;
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
return ok(auditWorkHubBoards(boards.filter(Boolean)));
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
|
|
265
327
|
server.tool(
|
|
266
328
|
'get_work_board_rollup',
|
|
267
329
|
'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 +379,103 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
317
379
|
// WRITES
|
|
318
380
|
// =========================================================================
|
|
319
381
|
|
|
382
|
+
server.tool(
|
|
383
|
+
'set_work_board_archived',
|
|
384
|
+
`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}`,
|
|
385
|
+
{
|
|
386
|
+
boardId: z.string().describe('Board id from list_work_boards.'),
|
|
387
|
+
archived: z.boolean().describe('true hides the board; false restores it.'),
|
|
388
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
|
|
389
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
390
|
+
reason: z.string().optional().describe('Required for a live change: why this board is being hidden or restored.'),
|
|
391
|
+
idempotencyKey: z.string().optional().describe('Required for a live change.'),
|
|
392
|
+
},
|
|
393
|
+
async (args) => {
|
|
394
|
+
const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/archived`;
|
|
395
|
+
const preview = validateApiBridgeRequest({
|
|
396
|
+
method: 'PATCH',
|
|
397
|
+
path,
|
|
398
|
+
dryRun: args.dryRun,
|
|
399
|
+
approved: args.approved,
|
|
400
|
+
reason: args.reason,
|
|
401
|
+
idempotencyKey: args.idempotencyKey,
|
|
402
|
+
grantedScope: getGrantedScope(),
|
|
403
|
+
});
|
|
404
|
+
if (preview?.dryRun) {
|
|
405
|
+
const data = await api('GET', `/api/v1/work-boards/${encodeURIComponent(args.boardId)}`);
|
|
406
|
+
const board = data?.data;
|
|
407
|
+
const terminalNames = new Set(['production', 'deep backlog']);
|
|
408
|
+
const activeCount = (board?.items ?? []).filter((item) => {
|
|
409
|
+
const column = board?.columns?.find((candidate) => candidate.id === item.columnId);
|
|
410
|
+
return column && !terminalNames.has(String(column.name).trim().toLowerCase());
|
|
411
|
+
}).length;
|
|
412
|
+
return ok({
|
|
413
|
+
...preview,
|
|
414
|
+
wouldSetBoardArchived: {
|
|
415
|
+
boardId: args.boardId,
|
|
416
|
+
boardName: board?.name,
|
|
417
|
+
archived: args.archived,
|
|
418
|
+
visibleItemCount: board?.itemCount ?? (board?.items ?? []).length,
|
|
419
|
+
activeItemCount: activeCount,
|
|
420
|
+
completeCount: board?.truncated !== true,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const data = await api('PATCH', path, {
|
|
426
|
+
body: { archived: args.archived },
|
|
427
|
+
headers: buildMutationHeaders(args),
|
|
428
|
+
});
|
|
429
|
+
return ok({ boardArchiveStateSet: true, ...data?.data });
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
server.tool(
|
|
434
|
+
'set_work_board_column_wip_limit',
|
|
435
|
+
`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}`,
|
|
436
|
+
{
|
|
437
|
+
boardId: z.string().describe('Board id from list_work_boards.'),
|
|
438
|
+
columnId: z.string().describe('Column id from get_work_board.'),
|
|
439
|
+
wipLimit: z
|
|
440
|
+
.number()
|
|
441
|
+
.int()
|
|
442
|
+
.positive()
|
|
443
|
+
.nullable()
|
|
444
|
+
.describe('Positive ceiling, or null to clear it (unlimited).'),
|
|
445
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to change it.'),
|
|
446
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
447
|
+
reason: z.string().optional().describe('Required for a live change: why this capacity is right.'),
|
|
448
|
+
idempotencyKey: z.string().optional().describe('Required for a live change. Reuse on retry.'),
|
|
449
|
+
},
|
|
450
|
+
async (args) => {
|
|
451
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
452
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
453
|
+
|
|
454
|
+
const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/columns/${encodeURIComponent(args.columnId)}/wip-limit`;
|
|
455
|
+
const preview = validateApiBridgeRequest({
|
|
456
|
+
method: 'PATCH',
|
|
457
|
+
path,
|
|
458
|
+
dryRun: args.dryRun,
|
|
459
|
+
approved: args.approved,
|
|
460
|
+
reason: args.reason,
|
|
461
|
+
idempotencyKey: args.idempotencyKey,
|
|
462
|
+
grantedScope: getGrantedScope(),
|
|
463
|
+
});
|
|
464
|
+
if (preview?.dryRun) {
|
|
465
|
+
return ok({
|
|
466
|
+
...preview,
|
|
467
|
+
wouldSet: { boardId: args.boardId, columnId: args.columnId, wipLimit: args.wipLimit },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const data = await api('PATCH', path, {
|
|
472
|
+
body: { wipLimit: args.wipLimit },
|
|
473
|
+
headers: buildMutationHeaders(args),
|
|
474
|
+
});
|
|
475
|
+
return ok({ updated: true, column: data?.data });
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
|
|
320
479
|
server.tool(
|
|
321
480
|
'move_work_item',
|
|
322
481
|
`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 +502,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
343
502
|
.boolean()
|
|
344
503
|
.optional()
|
|
345
504
|
.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
|
|
505
|
+
'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
506
|
),
|
|
348
507
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
|
|
349
508
|
approved: z.boolean().optional().describe('Required true for a live move.'),
|
|
@@ -377,8 +536,29 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
377
536
|
approved: args.approved,
|
|
378
537
|
reason: args.reason,
|
|
379
538
|
idempotencyKey: args.idempotencyKey,
|
|
539
|
+
grantedScope: getGrantedScope(),
|
|
380
540
|
});
|
|
381
541
|
if (preview?.dryRun) {
|
|
542
|
+
const itemData = await api(
|
|
543
|
+
'GET',
|
|
544
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}`
|
|
545
|
+
);
|
|
546
|
+
const item = itemData?.data;
|
|
547
|
+
const boardData = await api(
|
|
548
|
+
'GET',
|
|
549
|
+
`/api/v1/work-boards/${encodeURIComponent(item.boardId)}`
|
|
550
|
+
);
|
|
551
|
+
const board = boardData?.data;
|
|
552
|
+
const target = board?.columns?.find((column) => column.id === args.toColumnId);
|
|
553
|
+
if (!target) {
|
|
554
|
+
return ok({
|
|
555
|
+
error: true,
|
|
556
|
+
message: 'toColumnId is not a column on this card\'s board.',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const count = (board.items ?? []).filter(
|
|
560
|
+
(candidate) => candidate.columnId === args.toColumnId
|
|
561
|
+
).length;
|
|
382
562
|
return ok({
|
|
383
563
|
...preview,
|
|
384
564
|
wouldMove: {
|
|
@@ -388,6 +568,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
388
568
|
claim: args.claim === true,
|
|
389
569
|
force: args.force === true,
|
|
390
570
|
},
|
|
571
|
+
wip: projectColumnWip({
|
|
572
|
+
limit: target.wipLimit,
|
|
573
|
+
count,
|
|
574
|
+
cardAlreadyThere: item.columnId === args.toColumnId,
|
|
575
|
+
truncated: board.truncated === true,
|
|
576
|
+
}),
|
|
391
577
|
});
|
|
392
578
|
}
|
|
393
579
|
|
|
@@ -467,6 +653,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
467
653
|
approved: args.approved,
|
|
468
654
|
reason: args.reason,
|
|
469
655
|
idempotencyKey: args.idempotencyKey,
|
|
656
|
+
grantedScope: getGrantedScope(),
|
|
470
657
|
});
|
|
471
658
|
if (preview?.dryRun) {
|
|
472
659
|
return ok({
|
|
@@ -523,6 +710,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
523
710
|
approved: args.approved,
|
|
524
711
|
reason: args.reason,
|
|
525
712
|
idempotencyKey: args.idempotencyKey,
|
|
713
|
+
grantedScope: getGrantedScope(),
|
|
526
714
|
});
|
|
527
715
|
if (preview?.dryRun) {
|
|
528
716
|
return ok({
|
|
@@ -547,7 +735,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
547
735
|
'create_work_item',
|
|
548
736
|
`Create a card on a board. Lands in the named column, or the board's first column when none is given.
|
|
549
737
|
|
|
550
|
-
|
|
738
|
+
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
739
|
|
|
552
740
|
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
741
|
|
|
@@ -559,7 +747,20 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
559
747
|
.string()
|
|
560
748
|
.optional()
|
|
561
749
|
.describe(
|
|
562
|
-
'The request
|
|
750
|
+
'The request, evidence, and repro/context. Markdown. The executable definition of done belongs in acceptanceCriteria, not in this prose.'
|
|
751
|
+
),
|
|
752
|
+
acceptanceCriteria: z
|
|
753
|
+
.array(
|
|
754
|
+
z.object({
|
|
755
|
+
whereText: z.string().describe('Surface, environment, account, or role to check.'),
|
|
756
|
+
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
757
|
+
whenText: z.string().describe('Action the verifier performs.'),
|
|
758
|
+
thenText: z.string().describe('Observable result that must follow.'),
|
|
759
|
+
})
|
|
760
|
+
)
|
|
761
|
+
.optional()
|
|
762
|
+
.describe(
|
|
763
|
+
'Executable definition of done. One independently checkable outcome per entry. Omit only for a thin capture that is not ready to build.'
|
|
563
764
|
),
|
|
564
765
|
product: z.string().optional().describe('Product tag — orthogonal to the board.'),
|
|
565
766
|
kind: z
|
|
@@ -589,11 +790,17 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
589
790
|
approved: args.approved,
|
|
590
791
|
reason: args.reason,
|
|
591
792
|
idempotencyKey: args.idempotencyKey,
|
|
793
|
+
grantedScope: getGrantedScope(),
|
|
592
794
|
});
|
|
593
795
|
if (preview?.dryRun) {
|
|
594
796
|
return ok({
|
|
595
797
|
...preview,
|
|
596
|
-
wouldCreate: {
|
|
798
|
+
wouldCreate: {
|
|
799
|
+
boardId: args.boardId,
|
|
800
|
+
title: args.title,
|
|
801
|
+
kind: args.kind,
|
|
802
|
+
acceptanceCriteria: args.acceptanceCriteria ?? [],
|
|
803
|
+
},
|
|
597
804
|
// A statement of FACT about the body, not a guess about its contents.
|
|
598
805
|
// "Does this text contain acceptance criteria" is not something a
|
|
599
806
|
// regex can answer honestly, and a heuristic that half-answered it
|
|
@@ -604,6 +811,7 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
604
811
|
// already deciding whether to approve, and never blocking: a refused
|
|
605
812
|
// capture is how work stops reaching the board at all.
|
|
606
813
|
note: describeMissingBody(args.body),
|
|
814
|
+
readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
|
|
607
815
|
});
|
|
608
816
|
}
|
|
609
817
|
|
|
@@ -620,7 +828,61 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
620
828
|
},
|
|
621
829
|
headers: buildMutationHeaders(args),
|
|
622
830
|
});
|
|
623
|
-
|
|
831
|
+
const item = data?.data;
|
|
832
|
+
const criteria = [];
|
|
833
|
+
for (const [index, criterion] of (args.acceptanceCriteria ?? []).entries()) {
|
|
834
|
+
const criterionKey = `${args.idempotencyKey}:criterion:${index + 1}`;
|
|
835
|
+
const result = await api(
|
|
836
|
+
'POST',
|
|
837
|
+
`/api/v1/work-items/${encodeURIComponent(item.id)}/acceptance-criteria`,
|
|
838
|
+
{
|
|
839
|
+
body: criterion,
|
|
840
|
+
headers: buildMutationHeaders({
|
|
841
|
+
...args,
|
|
842
|
+
idempotencyKey: criterionKey,
|
|
843
|
+
}),
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
criteria.push(result?.data);
|
|
847
|
+
}
|
|
848
|
+
return ok({ created: true, item, criteria });
|
|
849
|
+
}
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
server.tool(
|
|
853
|
+
'add_work_item_acceptance_criterion',
|
|
854
|
+
`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}`,
|
|
855
|
+
{
|
|
856
|
+
itemId: z.string().describe('Card id.'),
|
|
857
|
+
whereText: z.string().describe('Surface, environment, account, or role to check.'),
|
|
858
|
+
givenText: z.string().optional().describe('Starting state, when one is required.'),
|
|
859
|
+
whenText: z.string().describe('Action the verifier performs.'),
|
|
860
|
+
thenText: z.string().describe('Observable result that must follow.'),
|
|
861
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
|
|
862
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
863
|
+
reason: z.string().optional().describe('Required for a live write: why this criterion is being added.'),
|
|
864
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
865
|
+
},
|
|
866
|
+
async (args) => {
|
|
867
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`;
|
|
868
|
+
const preview = validateApiBridgeRequest({
|
|
869
|
+
method: 'POST',
|
|
870
|
+
path,
|
|
871
|
+
...args,
|
|
872
|
+
grantedScope: getGrantedScope(),
|
|
873
|
+
});
|
|
874
|
+
const criterion = {
|
|
875
|
+
whereText: args.whereText,
|
|
876
|
+
givenText: args.givenText,
|
|
877
|
+
whenText: args.whenText,
|
|
878
|
+
thenText: args.thenText,
|
|
879
|
+
};
|
|
880
|
+
if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
|
|
881
|
+
const data = await api('POST', path, {
|
|
882
|
+
body: criterion,
|
|
883
|
+
headers: buildMutationHeaders(args),
|
|
884
|
+
});
|
|
885
|
+
return ok({ added: true, criterion: data?.data });
|
|
624
886
|
}
|
|
625
887
|
);
|
|
626
888
|
server.tool(
|
|
@@ -659,6 +921,7 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
|
|
|
659
921
|
approved: args.approved,
|
|
660
922
|
reason: args.reason,
|
|
661
923
|
idempotencyKey: args.idempotencyKey,
|
|
924
|
+
grantedScope: getGrantedScope(),
|
|
662
925
|
});
|
|
663
926
|
if (preview?.dryRun) {
|
|
664
927
|
return ok({ ...preview, wouldComment: { itemId: args.itemId, body: args.body } });
|
|
@@ -718,6 +981,7 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
|
|
|
718
981
|
// reason explains the write and `body` is the content.
|
|
719
982
|
reason: args.reason,
|
|
720
983
|
idempotencyKey: args.idempotencyKey,
|
|
984
|
+
grantedScope: getGrantedScope(),
|
|
721
985
|
});
|
|
722
986
|
if (preview?.dryRun) {
|
|
723
987
|
return ok({
|
|
@@ -746,12 +1010,18 @@ export const WORK_BOARD_TOOL_NAMES = [
|
|
|
746
1010
|
'get_work_board',
|
|
747
1011
|
'get_work_item',
|
|
748
1012
|
'get_work_item_history',
|
|
1013
|
+
'get_work_item_delivery_evidence',
|
|
1014
|
+
'audit_work_hub',
|
|
1015
|
+
'list_work_item_acceptance_criteria',
|
|
749
1016
|
'get_work_board_rollup',
|
|
750
1017
|
'list_work_board_rollups',
|
|
1018
|
+
'set_work_board_archived',
|
|
1019
|
+
'set_work_board_column_wip_limit',
|
|
751
1020
|
'move_work_item',
|
|
752
1021
|
'set_work_item_tag',
|
|
753
1022
|
'set_work_item_kind',
|
|
754
1023
|
'create_work_item',
|
|
1024
|
+
'add_work_item_acceptance_criterion',
|
|
755
1025
|
'get_work_item_comments',
|
|
756
1026
|
'comment_on_work_item',
|
|
757
1027
|
'flag_work_item',
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure operating-health projection for Starfield boards.
|
|
3
|
+
*
|
|
4
|
+
* A board is trustworthy when its workflow claims are backed by the minimum
|
|
5
|
+
* facts needed to act: ownership, a work type, a definition of done, and a
|
|
6
|
+
* current stage. Delivery evidence is deliberately added separately because a
|
|
7
|
+
* column is not a deployment receipt.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const TERMINAL_STAGES = new Set(['production', 'deep backlog']);
|
|
11
|
+
// Up Next is the deliberately unowned pull buffer. Triage/Aligning are capture
|
|
12
|
+
// and grooming, where thin cards are allowed. Only these stages mean somebody
|
|
13
|
+
// has actually taken a pass and therefore require both an end-to-end owner and
|
|
14
|
+
// a current handler.
|
|
15
|
+
const ACTIVE_STAGES = new Set(['in progress', 'staging qa1', 'staging qa2']);
|
|
16
|
+
// A card crossing the cut line into Up Next must be executable. Production is
|
|
17
|
+
// included because historical evidence does not stop mattering after release.
|
|
18
|
+
const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'production']);
|
|
19
|
+
|
|
20
|
+
function normalized(value) {
|
|
21
|
+
return String(value ?? '').trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hoursSince(iso, nowMs) {
|
|
25
|
+
const entered = Date.parse(iso);
|
|
26
|
+
if (!Number.isFinite(entered)) return null;
|
|
27
|
+
return Math.max(0, (nowMs - entered) / 3_600_000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deliveryContradictions(evidence) {
|
|
31
|
+
const contradictions = [];
|
|
32
|
+
const workflowStage = normalized(evidence?.workflow?.columnName);
|
|
33
|
+
if (workflowStage === 'production' && evidence?.production?.state !== 'live') {
|
|
34
|
+
contradictions.push({
|
|
35
|
+
code: 'production_column_without_live_evidence',
|
|
36
|
+
message:
|
|
37
|
+
'The card is in Production, but exact-SHA production evidence is not live. Treat the column as a workflow claim, not a deployment receipt.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (evidence?.production?.state === 'live' && workflowStage !== 'production') {
|
|
41
|
+
contradictions.push({
|
|
42
|
+
code: 'live_evidence_outside_production_column',
|
|
43
|
+
message:
|
|
44
|
+
'Exact-SHA production evidence is live, but the workflow card has not reached Production.',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return contradictions;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Array<object>} boards Full board payloads from GET /work-boards/{id}.
|
|
52
|
+
* @param {{now?: Date|string|number}} options
|
|
53
|
+
*/
|
|
54
|
+
export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
|
|
55
|
+
const nowMs = new Date(now).getTime();
|
|
56
|
+
const findings = [];
|
|
57
|
+
const perBoard = [];
|
|
58
|
+
let totalCards = 0;
|
|
59
|
+
let openCards = 0;
|
|
60
|
+
let activeCards = 0;
|
|
61
|
+
|
|
62
|
+
for (const board of boards) {
|
|
63
|
+
const columns = new Map((board.columns ?? []).map((column) => [column.id, column]));
|
|
64
|
+
const boardFindings = [];
|
|
65
|
+
const items = board.items ?? [];
|
|
66
|
+
totalCards += items.length;
|
|
67
|
+
|
|
68
|
+
if (board.truncated === true) {
|
|
69
|
+
boardFindings.push({
|
|
70
|
+
code: 'board_truncated',
|
|
71
|
+
itemId: null,
|
|
72
|
+
title: null,
|
|
73
|
+
detail: `The board returned ${items.length} of ${board.itemCount ?? 'an unknown number of'} cards.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
const column = columns.get(item.columnId);
|
|
79
|
+
const stage = column?.name ?? 'Unknown';
|
|
80
|
+
const stageKey = normalized(stage);
|
|
81
|
+
const isTerminal = TERMINAL_STAGES.has(stageKey);
|
|
82
|
+
const isActive = ACTIVE_STAGES.has(stageKey);
|
|
83
|
+
const mustBeExecutable = EXECUTABLE_STAGES.has(stageKey);
|
|
84
|
+
if (!isTerminal) openCards += 1;
|
|
85
|
+
if (isActive) activeCards += 1;
|
|
86
|
+
|
|
87
|
+
const add = (code, detail) =>
|
|
88
|
+
boardFindings.push({ code, itemId: item.id, title: item.title, stage, detail });
|
|
89
|
+
|
|
90
|
+
if (mustBeExecutable && !String(item.body ?? '').trim()) {
|
|
91
|
+
add('missing_body', 'The card crossed the cut line without working context.');
|
|
92
|
+
}
|
|
93
|
+
if (mustBeExecutable) {
|
|
94
|
+
if (item.criteria?.total === 0) {
|
|
95
|
+
add('missing_acceptance_criteria', 'Nobody has stated a checkable definition of done.');
|
|
96
|
+
} else if (item.criteria === undefined) {
|
|
97
|
+
add('criteria_not_measured', 'This read did not include acceptance-criteria status.');
|
|
98
|
+
}
|
|
99
|
+
if (!item.kind) add('missing_kind', 'The card is not classified as a story, bug, or chore.');
|
|
100
|
+
}
|
|
101
|
+
if (isActive && !item.assigneeUserId) {
|
|
102
|
+
add('unowned_active_card', 'The card is active but has no end-to-end owner.');
|
|
103
|
+
}
|
|
104
|
+
if (isActive && !item.handler) {
|
|
105
|
+
add('unhandled_active_pass', 'The active build or QA pass has no handler.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const staleAfter = column?.staleness?.staleAfterHours;
|
|
109
|
+
const ageHours = hoursSince(item.enteredColumnAt, nowMs);
|
|
110
|
+
if (!isTerminal && Number.isFinite(staleAfter) && ageHours !== null && ageHours > staleAfter) {
|
|
111
|
+
add(
|
|
112
|
+
'stale_open_card',
|
|
113
|
+
`The card has spent ${Math.floor(ageHours)}h in ${stage}; this column is stale after ${staleAfter}h.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
findings.push(...boardFindings.map((finding) => ({ boardId: board.id, boardName: board.name, ...finding })));
|
|
119
|
+
perBoard.push({
|
|
120
|
+
boardId: board.id,
|
|
121
|
+
boardName: board.name,
|
|
122
|
+
cards: items.length,
|
|
123
|
+
activeCards: items.filter((item) => {
|
|
124
|
+
const stage = columns.get(item.columnId)?.name;
|
|
125
|
+
return ACTIVE_STAGES.has(normalized(stage));
|
|
126
|
+
}).length,
|
|
127
|
+
findings: boardFindings.length,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const counts = {};
|
|
132
|
+
for (const finding of findings) counts[finding.code] = (counts[finding.code] ?? 0) + 1;
|
|
133
|
+
|
|
134
|
+
const priorityOrder = [
|
|
135
|
+
'board_truncated',
|
|
136
|
+
'unowned_active_card',
|
|
137
|
+
'unhandled_active_pass',
|
|
138
|
+
'missing_acceptance_criteria',
|
|
139
|
+
'stale_open_card',
|
|
140
|
+
'missing_body',
|
|
141
|
+
'missing_kind',
|
|
142
|
+
'criteria_not_measured',
|
|
143
|
+
];
|
|
144
|
+
const rank = new Map(priorityOrder.map((code, index) => [code, index]));
|
|
145
|
+
findings.sort((a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99));
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
trustworthy: findings.length === 0,
|
|
149
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
150
|
+
boards: boards.length,
|
|
151
|
+
totalCards,
|
|
152
|
+
openCards,
|
|
153
|
+
activeCards,
|
|
154
|
+
counts,
|
|
155
|
+
perBoard,
|
|
156
|
+
findings,
|
|
157
|
+
interpretation:
|
|
158
|
+
findings.length === 0
|
|
159
|
+
? 'Every returned card has the minimum facts required to operate from this hub. Delivery truth must still be read from exact-SHA evidence.'
|
|
160
|
+
: 'The hub is not yet trustworthy as an operating ledger. Work the findings in order; do not infer completion from column position.',
|
|
161
|
+
};
|
|
162
|
+
}
|