@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.
@@ -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
- * 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.
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 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.';
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 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.'
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
- 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.
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 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.'
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: { boardId: args.boardId, title: args.title, kind: args.kind },
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
- return ok({ created: true, item: data?.data });
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',