@adrata/adrata-mcp 1.0.3 → 1.0.7

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,4 +1,56 @@
1
1
  import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { basename, resolve } from 'node:path';
5
+
6
+ const MAX_QA_IMAGE_BYTES = 10 * 1024 * 1024;
7
+ const MAX_QA_VIDEO_BYTES = 100 * 1024 * 1024;
8
+ const QA_STAGE_NAMES = new Set(['staging qa1', 'staging qa2']);
9
+
10
+ function isQaStageName(name) {
11
+ return QA_STAGE_NAMES.has(String(name ?? '').trim().toLowerCase());
12
+ }
13
+
14
+ /** Read evidence bytes locally without ever putting them in the MCP transcript. */
15
+ export async function loadLocalQaEvidenceFile(filePath) {
16
+ const absolute = resolve(String(filePath || ''));
17
+ const safeName = basename(absolute);
18
+ try {
19
+ const info = await stat(absolute);
20
+ if (!info.isFile()) throw new Error('not_a_regular_file');
21
+ if (info.size <= 0 || info.size > MAX_QA_VIDEO_BYTES) {
22
+ throw new Error('outside_supported_size');
23
+ }
24
+ const bytes = await readFile(absolute);
25
+ return {
26
+ bytes,
27
+ fileName: safeName,
28
+ sizeBytes: bytes.byteLength,
29
+ sha256: createHash('sha256').update(bytes).digest('hex'),
30
+ };
31
+ } catch (error) {
32
+ const code = error?.code || error?.message || 'unreadable';
33
+ throw new Error(`Could not read QA evidence file "${safeName}": ${code}`);
34
+ }
35
+ }
36
+
37
+ /** PUT to the private presigned target while keeping its URL and headers process-private. */
38
+ export async function uploadLocalQaEvidenceFile(ticket, bytes, fetchImpl = globalThis.fetch) {
39
+ if (!ticket?.uploadUrl || !ticket?.headers || typeof fetchImpl !== 'function') {
40
+ throw new Error('The API returned an incomplete private QA evidence upload capability.');
41
+ }
42
+ const response = await fetchImpl(ticket.uploadUrl, {
43
+ method: 'PUT',
44
+ headers: {
45
+ ...ticket.headers,
46
+ 'Content-Length': String(bytes.byteLength),
47
+ },
48
+ body: bytes,
49
+ });
50
+ if (!response.ok) {
51
+ throw new Error(`Private QA evidence upload failed with HTTP ${response.status}.`);
52
+ }
53
+ }
2
54
 
3
55
  /**
4
56
  * Work-board tools for the Adrata MCP Server.
@@ -131,6 +183,45 @@ export function describeMissingAcceptanceCriteria(criteria) {
131
183
  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
184
  }
133
185
 
186
+ /**
187
+ * Make the created card's own criteria counter agree with the criteria returned
188
+ * beside it.
189
+ *
190
+ * Criteria are written by a second call, AFTER `POST /items` has already
191
+ * answered. So the card snapshot in that answer was serialized before the rows
192
+ * existed and its derived `criteria.total` reads 0 — while the array a few
193
+ * lines below it in the same response holds the rows that were just committed.
194
+ * Two fields of one response, disagreeing.
195
+ *
196
+ * That is not cosmetic. An agent reading `item.criteria.total` from a create it
197
+ * just made concludes the card has NO executable definition of done, at exactly
198
+ * the moment the board exists to prevent that, and then either reports the card
199
+ * as not ready for build or re-adds criteria that already exist. The correct
200
+ * data is in the same response a few lines lower, which is what makes it
201
+ * invisible.
202
+ *
203
+ * The repair is a fresh read of the card rather than a locally patched number,
204
+ * because the counter is derived server-side and the server is the only party
205
+ * that can state it. The read is skipped entirely when no criteria were written
206
+ * — there is no stale counter to repair, and the create path should not spend a
207
+ * request proving that.
208
+ *
209
+ * A failed read-back must NOT turn a create that succeeded into an error: the
210
+ * card and its criteria are on the record either way. In that one case the
211
+ * count is reconciled from the rows this function is holding, which is a fact it
212
+ * already has rather than a guess.
213
+ */
214
+ export async function reconcileCriteriaCount(api, item, criteria) {
215
+ if (!item?.id || criteria.length === 0) return item;
216
+ try {
217
+ const fresh = await api('GET', `/api/v1/work-items/${encodeURIComponent(item.id)}`);
218
+ if (fresh?.data?.criteria) return fresh.data;
219
+ } catch {
220
+ // Fall through to the local reconciliation below.
221
+ }
222
+ return { ...item, criteria: { ...(item.criteria ?? {}), total: criteria.length } };
223
+ }
224
+
134
225
  /**
135
226
  * The satisfaction sub-resource of one criterion, POSTed to tick and DELETEd to
136
227
  * un-tick.
@@ -164,10 +255,115 @@ export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = f
164
255
  */
165
256
  export function registerWorkBoardTools(
166
257
  server,
167
- { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
258
+ {
259
+ z,
260
+ api,
261
+ ok,
262
+ validateApiBridgeRequest,
263
+ buildMutationHeaders,
264
+ getGrantedScope = () => undefined,
265
+ loadQaEvidenceFile = loadLocalQaEvidenceFile,
266
+ uploadQaEvidenceFile = uploadLocalQaEvidenceFile,
267
+ }
168
268
  ) {
169
269
  const GOVERNED_NOTE =
170
270
  ' 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).';
271
+ // Capability material belongs to this MCP process, not to the model transcript.
272
+ // A winning claim stores it here; heartbeat/release address the card by its
273
+ // public id and the handler adds the three private headers at the last hop.
274
+ const workerLeaseCapabilities = new Map();
275
+
276
+ function rememberWorkerLease(itemId, grant, claimIdempotencyKey) {
277
+ if (!grant?.leaseId || !grant?.leaseToken || !Number.isInteger(grant?.fencingToken)) {
278
+ throw new Error('The API returned an incomplete worker-lease grant; no capability was stored.');
279
+ }
280
+ workerLeaseCapabilities.set(itemId, {
281
+ leaseId: grant.leaseId,
282
+ leaseToken: grant.leaseToken,
283
+ fencingToken: grant.fencingToken,
284
+ claimIdempotencyKey,
285
+ });
286
+ const { leaseToken: _secret, fencingToken: _fence, qaRequirements, currentCriteria, ...lease } =
287
+ grant;
288
+ return { lease, qaRequirements, currentCriteria };
289
+ }
290
+
291
+ function conflictingHeldCard(itemId, claimIdempotencyKey) {
292
+ for (const [heldItemId, capability] of workerLeaseCapabilities) {
293
+ const sameCard = itemId != null && heldItemId === itemId;
294
+ const sameClaim =
295
+ claimIdempotencyKey != null && capability.claimIdempotencyKey === claimIdempotencyKey;
296
+ if (!sameCard && !sameClaim) return heldItemId;
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function workerLeaseHeaders(itemId) {
302
+ const capability = workerLeaseCapabilities.get(itemId);
303
+ if (!capability) return null;
304
+ return {
305
+ 'x-adrata-worker-lease': capability.leaseId,
306
+ 'x-adrata-worker-token': capability.leaseToken,
307
+ 'x-adrata-worker-fence': String(capability.fencingToken),
308
+ };
309
+ }
310
+
311
+ function mutationHeadersForItem(args, itemId) {
312
+ return {
313
+ ...buildMutationHeaders(args),
314
+ ...(workerLeaseHeaders(itemId) ?? {}),
315
+ };
316
+ }
317
+
318
+ function requiredWorkerLeaseHeaders(itemId) {
319
+ const headers = workerLeaseHeaders(itemId);
320
+ if (!headers) {
321
+ throw new Error(
322
+ `No process-private QA lease capability is held for ${itemId}. Claim this card's current QA pass in this MCP session before recording engineering proof.`
323
+ );
324
+ }
325
+ return headers;
326
+ }
327
+
328
+ // A caller may legitimately use the API maximum (255 characters). Appending
329
+ // an operation suffix would then turn every child write into a 400 and leave
330
+ // a composite half-finished. Hash the caller key into short, deterministic,
331
+ // endpoint-specific keys instead. The original key never leaves this process
332
+ // through a child request.
333
+ function childIdempotencyKey(baseKey, operation) {
334
+ const digest = createHash('sha256').update(String(baseKey)).digest('hex');
335
+ return `qa-${operation}-${digest}`;
336
+ }
337
+
338
+ async function releaseHeldPass(args, releaseKind, leaseHeaders) {
339
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/release`;
340
+ try {
341
+ const data = await api('POST', path, {
342
+ body: { releaseKind },
343
+ headers: { ...buildMutationHeaders(args), ...leaseHeaders },
344
+ });
345
+ workerLeaseCapabilities.delete(args.itemId);
346
+ return { workerLease: data?.data, recoveredAfterResponseLoss: false };
347
+ } catch (releaseError) {
348
+ // A successful release whose HTTP response was lost is already safe on
349
+ // the server. Confirm the public state before retaining a phantom local
350
+ // capability that would prevent this worker from taking its next card.
351
+ try {
352
+ const current = await api(
353
+ 'GET',
354
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease`
355
+ );
356
+ if (!current?.data) {
357
+ workerLeaseCapabilities.delete(args.itemId);
358
+ return { workerLease: null, recoveredAfterResponseLoss: true };
359
+ }
360
+ } catch {
361
+ // Preserve the original release error; an unavailable status read is
362
+ // not evidence that the lease did or did not close.
363
+ }
364
+ throw releaseError;
365
+ }
366
+ }
171
367
 
172
368
  // =========================================================================
173
369
  // READS
@@ -193,7 +389,9 @@ Start here for "what should I work on". With includeUnassigned it also returns t
193
389
  limit: z
194
390
  .number()
195
391
  .optional()
196
- .describe('Cap each list. Defaults to 50; a queue that needs a second page is not a queue.'),
392
+ .describe(
393
+ 'Cap each list. Defaults to 50; a queue that needs a second page is not a queue.'
394
+ ),
197
395
  },
198
396
  async (args) => {
199
397
  const params = new URLSearchParams();
@@ -201,10 +399,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
201
399
  if (args.boardId) params.set('boardId', args.boardId);
202
400
  if (args.limit !== undefined) params.set('limit', String(args.limit));
203
401
  const query = params.toString();
204
- const data = await api(
205
- 'GET',
206
- `/api/v1/work-items/assigned-to-me${query ? `?${query}` : ''}`
207
- );
402
+ const data = await api('GET', `/api/v1/work-items/assigned-to-me${query ? `?${query}` : ''}`);
208
403
  const queue = data?.data || {};
209
404
  const mine = queue.assignedToMe || [];
210
405
  return ok({
@@ -256,14 +451,14 @@ Start here for "what should I work on". With includeUnassigned it also returns t
256
451
  // Triage is alarming, three days in In review is a Tuesday. Say so, or
257
452
  // an agent will apply one threshold across the whole board.
258
453
  howToReadStaleness:
259
- 'Each column carries its own agingAfterHours/staleAfterHours. A MISSING bound means the column never ages (a Done column) — it does not mean zero. Compare a card\'s enteredColumnAt against ITS OWN column\'s policy.',
454
+ "Each column carries its own agingAfterHours/staleAfterHours. A MISSING bound means the column never ages (a Done column) — it does not mean zero. Compare a card's enteredColumnAt against ITS OWN column's policy.",
260
455
  });
261
456
  }
262
457
  );
263
458
 
264
459
  server.tool(
265
460
  'get_work_item',
266
- 'Read one card by id: title, body, product, assignee, reporter, creator, its stored tag, and how long it has been in its current column. THREE DIFFERENT PEOPLE can appear on a card and they answer different questions: `assignee` is who is doing it (and changes hands over the card\'s life), `reporterPersonId` is the customer who asked (only on a card ingested from email), and `createdBy` is the teammate who wrote the card — resolved to a name, never changing, and the person to ask what the card meant. An absent `createdBy` means the card predates creator tracking, not that nobody made it.',
461
+ "Read one card by id: title, body, product, assignee, reporter, creator, its stored tag, and how long it has been in its current column. THREE DIFFERENT PEOPLE can appear on a card and they answer different questions: `assignee` is who is doing it (and changes hands over the card's life), `reporterPersonId` is the customer who asked (only on a card ingested from email), and `createdBy` is the teammate who wrote the card — resolved to a name, never changing, and the person to ask what the card meant. An absent `createdBy` means the card predates creator tracking, not that nobody made it.",
267
462
  {
268
463
  itemId: z.string().describe('Card id.'),
269
464
  },
@@ -273,6 +468,367 @@ Start here for "what should I work on". With includeUnassigned it also returns t
273
468
  }
274
469
  );
275
470
 
471
+ server.tool(
472
+ 'get_work_item_worker_lease',
473
+ 'Read who, if anyone, is actively running this card’s current QA pass. Returns the accountable teammate, declared agent/session labels, and Live or Stalled freshness. It never returns the bearer token, fencing value, or credential identity.',
474
+ { itemId: z.string().describe('Card id.') },
475
+ async (args) => {
476
+ const data = await api(
477
+ 'GET',
478
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease`
479
+ );
480
+ return ok({ workerLease: data?.data ?? null });
481
+ }
482
+ );
483
+
484
+ server.tool(
485
+ 'get_work_item_worker_activity',
486
+ 'Read the human-readable worker lifecycle for one card: claim, conflict, renewal, stall, release, handoff, and audited takeover. Entries carry only public teammate/agent/session labels and timestamps; capability and credential material never appear.',
487
+ { itemId: z.string().describe('Card id.') },
488
+ async (args) => {
489
+ const data = await api(
490
+ 'GET',
491
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease-events`
492
+ );
493
+ const events = data?.data ?? [];
494
+ return ok({ count: events.length, events });
495
+ }
496
+ );
497
+
498
+ server.tool(
499
+ 'claim_work_item_qa_pass',
500
+ `Take one named card’s current Staging QA1 or Staging QA2 pass without changing its durable owner. A winning response includes the current acceptance criteria and the recorded-QA rules you must follow. The bearer and fence stay process-private; later heartbeat and release tools use them without putting them in the conversation.${GOVERNED_NOTE}`,
501
+ {
502
+ itemId: z.string().describe('Card id.'),
503
+ agentName: z.enum(['claude-code', 'codex', 'grok', 'human']),
504
+ runLabel: z.string().describe('Concise public session label, not a transcript or secret.'),
505
+ workerLabel: z.string().describe('Concise public worker label, not a transcript or secret.'),
506
+ takeoverStalled: z.boolean().optional().default(false),
507
+ dryRun: z.boolean().optional().default(true),
508
+ approved: z.boolean().optional().default(false),
509
+ reason: z.string().optional(),
510
+ idempotencyKey: z.string().optional(),
511
+ },
512
+ async (args) => {
513
+ const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey);
514
+ if (heldItemId) {
515
+ return ok({
516
+ error: true,
517
+ message: `This MCP worker already holds card ${heldItemId}. Transition or release it before claiming another pass.`,
518
+ });
519
+ }
520
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/claim`;
521
+ const body = {
522
+ agentName: args.agentName,
523
+ runLabel: args.runLabel,
524
+ workerLabel: args.workerLabel,
525
+ takeoverStalled: args.takeoverStalled === true,
526
+ idempotencyKey: args.idempotencyKey,
527
+ };
528
+ const preview = validateApiBridgeRequest({
529
+ method: 'POST',
530
+ path,
531
+ body,
532
+ dryRun: args.dryRun,
533
+ approved: args.approved,
534
+ reason: args.reason,
535
+ idempotencyKey: args.idempotencyKey,
536
+ grantedScope: getGrantedScope(),
537
+ });
538
+ if (preview.dryRun) {
539
+ return ok({
540
+ dryRun: true,
541
+ action: `Take the current QA pass for card ${args.itemId}`,
542
+ note: 'No lease was created. Confirm to claim this pass atomically.',
543
+ });
544
+ }
545
+ const data = await api('POST', path, {
546
+ body,
547
+ headers: buildMutationHeaders(args),
548
+ });
549
+ const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey);
550
+ return ok({ claimed: true, ...visible });
551
+ }
552
+ );
553
+
554
+ server.tool(
555
+ 'claim_next_work_item_qa_pass',
556
+ `Atomically choose and take the first workable QA pass for ONE exact product in ONE requested gate across every board this worker can see. Product is read from the card, never inferred from the board that happens to hold it. QA1 and QA2 pools must name their gate explicitly; a QA1 drain can never consume QA2 work and vice versa. Concurrent sessions receive distinct cards; one MCP worker holds at most one pass at a time. An empty result means no eligible pass remains in that product/gate. The response returns the selected board and card, explains the selection, and carries the exact acceptance criteria and recorded-QA requirements. Capability material stays inside this MCP process.${GOVERNED_NOTE}`,
557
+ {
558
+ qaGate: z.enum(['Staging QA1', 'Staging QA2']).describe('Exact gate this worker pool is allowed to drain.'),
559
+ product: z.string().min(1).max(120).describe('Exact card product to drain, such as Adrata or Starfield, across all visible boards.'),
560
+ agentName: z.enum(['claude-code', 'codex', 'grok', 'human']),
561
+ runLabel: z.string().describe('Concise public session label, not a transcript or secret.'),
562
+ workerLabel: z.string().describe('Concise public worker label, not a transcript or secret.'),
563
+ dryRun: z.boolean().optional().default(true),
564
+ approved: z.boolean().optional().default(false),
565
+ reason: z.string().optional(),
566
+ idempotencyKey: z.string().optional(),
567
+ },
568
+ async (args) => {
569
+ const heldItemId = conflictingHeldCard(null, args.idempotencyKey);
570
+ if (heldItemId) {
571
+ return ok({
572
+ error: true,
573
+ message: `This MCP worker already holds card ${heldItemId}. Transition or release it before claiming another pass.`,
574
+ });
575
+ }
576
+ const path = '/api/v1/work-boards/worker-leases/claim-next';
577
+ const body = {
578
+ qaGate: args.qaGate,
579
+ product: args.product,
580
+ agentName: args.agentName,
581
+ runLabel: args.runLabel,
582
+ workerLabel: args.workerLabel,
583
+ takeoverStalled: false,
584
+ idempotencyKey: args.idempotencyKey,
585
+ };
586
+ const preview = validateApiBridgeRequest({
587
+ method: 'POST',
588
+ path,
589
+ body,
590
+ dryRun: args.dryRun,
591
+ approved: args.approved,
592
+ reason: args.reason,
593
+ idempotencyKey: args.idempotencyKey,
594
+ grantedScope: getGrantedScope(),
595
+ });
596
+ if (preview.dryRun) {
597
+ return ok({
598
+ dryRun: true,
599
+ action: `Atomically take the first workable ${args.product} ${args.qaGate} pass across all visible boards`,
600
+ note: 'No card was claimed. Confirm to select and claim one pass.',
601
+ });
602
+ }
603
+ const data = await api('POST', path, {
604
+ body,
605
+ headers: buildMutationHeaders(args),
606
+ });
607
+ const selected = data?.data;
608
+ if (!selected) {
609
+ return ok({ claimed: false, qaGate: args.qaGate, product: args.product, note: `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.` });
610
+ }
611
+ const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
612
+ return ok({
613
+ claimed: true,
614
+ workItemId: selected.workItemId,
615
+ boardId: selected.boardId,
616
+ boardName: selected.boardName,
617
+ title: selected.title,
618
+ product: selected.product,
619
+ selectionReason: selected.selectionReason,
620
+ ...visible,
621
+ });
622
+ }
623
+ );
624
+
625
+ server.tool(
626
+ 'heartbeat_work_item_qa_pass',
627
+ `Keep the current MCP session’s claimed QA pass Live. The private bearer and fence are attached inside this process; they are never accepted as tool arguments or returned in the result.${GOVERNED_NOTE}`,
628
+ {
629
+ itemId: z.string().describe('Card id returned by a winning claim in this MCP session.'),
630
+ dryRun: z.boolean().optional().default(true),
631
+ approved: z.boolean().optional().default(false),
632
+ reason: z.string().optional(),
633
+ idempotencyKey: z.string().optional(),
634
+ },
635
+ async (args) => {
636
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/heartbeat`;
637
+ const preview = validateApiBridgeRequest({
638
+ method: 'POST',
639
+ path,
640
+ dryRun: args.dryRun,
641
+ approved: args.approved,
642
+ reason: args.reason,
643
+ idempotencyKey: args.idempotencyKey,
644
+ grantedScope: getGrantedScope(),
645
+ });
646
+ if (preview.dryRun) {
647
+ return ok({
648
+ dryRun: true,
649
+ action: `Keep card ${args.itemId} actively held by this worker`,
650
+ note: 'The lease expiry is unchanged until this write is confirmed.',
651
+ });
652
+ }
653
+ const headers = workerLeaseHeaders(args.itemId);
654
+ if (!headers) {
655
+ return ok({
656
+ error: true,
657
+ message: 'This MCP session has no capability for that card. Claim the QA pass first.',
658
+ });
659
+ }
660
+ const data = await api('POST', path, {
661
+ headers: { ...buildMutationHeaders(args), ...headers },
662
+ });
663
+ return ok({ workerLease: data?.data });
664
+ }
665
+ );
666
+
667
+ server.tool(
668
+ 'release_work_item_qa_pass',
669
+ `Release or hand off the current MCP session’s QA pass. This closes the active lease but does not change the card’s durable owner. The private capability is supplied from process memory and removed after a successful release.${GOVERNED_NOTE}`,
670
+ {
671
+ itemId: z.string().describe('Card id returned by a winning claim in this MCP session.'),
672
+ releaseKind: z.enum(['released', 'handoff']).optional().default('released'),
673
+ dryRun: z.boolean().optional().default(true),
674
+ approved: z.boolean().optional().default(false),
675
+ reason: z.string().optional(),
676
+ idempotencyKey: z.string().optional(),
677
+ },
678
+ async (args) => {
679
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/release`;
680
+ const body = { releaseKind: args.releaseKind ?? 'released' };
681
+ const preview = validateApiBridgeRequest({
682
+ method: 'POST',
683
+ path,
684
+ body,
685
+ dryRun: args.dryRun,
686
+ approved: args.approved,
687
+ reason: args.reason,
688
+ idempotencyKey: args.idempotencyKey,
689
+ grantedScope: getGrantedScope(),
690
+ });
691
+ if (preview.dryRun) {
692
+ return ok({
693
+ dryRun: true,
694
+ action: `${body.releaseKind === 'handoff' ? 'Hand off' : 'Release'} card ${args.itemId}`,
695
+ note: 'The active worker remains unchanged until this write is confirmed.',
696
+ });
697
+ }
698
+ const leaseHeaders = workerLeaseHeaders(args.itemId);
699
+ if (!leaseHeaders) {
700
+ return ok({
701
+ error: true,
702
+ message: 'This MCP session has no capability for that card. Nothing was released.',
703
+ });
704
+ }
705
+ const release = await releaseHeldPass(args, body.releaseKind, leaseHeaders);
706
+ return ok({ released: true, releaseKind: body.releaseKind, ...release });
707
+ }
708
+ );
709
+
710
+ server.tool(
711
+ 'record_work_item_qa_failure_and_release',
712
+ `Durably record why the current QA pass cannot proceed, then safely release the process-private lease without moving the card. A failed criterion is flagged first, un-ticked, and left on the same QA card waiting for a fix and deployed build; it is NOT immediately reclaimable against the unchanged build. A dependency blocker is flagged in place. Both paths release the lease only after the durable safety state exists, so claim-next skips the card until an explicit requeue. This is one governed recovery recipe, not a new bug card and not a QA bounce.${GOVERNED_NOTE}`,
713
+ {
714
+ itemId: z.string().describe('Card currently claimed by this MCP worker.'),
715
+ disposition: z.enum(['failed_criterion', 'blocked_dependency']),
716
+ criterionId: z.string().optional().describe('Required for failed_criterion; the exact acceptance criterion contradicted.'),
717
+ details: z.string().describe('Concrete observed failure or blocker and the recovery needed. Stored on the card.'),
718
+ dryRun: z.boolean().optional().default(true),
719
+ approved: z.boolean().optional().default(false),
720
+ reason: z.string().optional(),
721
+ idempotencyKey: z.string().optional().describe('Required live base key; child writes derive stable endpoint-specific keys.'),
722
+ },
723
+ async (args) => {
724
+ if (!args.details?.trim()) {
725
+ return ok({ error: true, message: 'details must name the observed failure or blocker.' });
726
+ }
727
+ if (args.disposition === 'failed_criterion' && !args.criterionId) {
728
+ return ok({ error: true, message: 'criterionId is required for a failed criterion.' });
729
+ }
730
+ const preview = validateApiBridgeRequest({
731
+ method: 'POST',
732
+ path: `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`,
733
+ body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details },
734
+ dryRun: args.dryRun, approved: args.approved, reason: args.reason,
735
+ idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
736
+ });
737
+ if (preview.dryRun) {
738
+ return ok({
739
+ ...preview,
740
+ wouldRecordQaFailure: {
741
+ itemId: args.itemId,
742
+ disposition: args.disposition,
743
+ criterionId: args.criterionId ?? null,
744
+ details: args.details,
745
+ },
746
+ note: args.disposition === 'failed_criterion'
747
+ ? 'The card will stay in its QA column, be flagged and un-ticked, then release the pass while waiting for a fixed deployed build.'
748
+ : 'The card will be flagged in place and the pass handed off.',
749
+ });
750
+ }
751
+ const leaseHeaders = workerLeaseHeaders(args.itemId);
752
+ if (!leaseHeaders) {
753
+ return ok({ error: true, message: 'This MCP session has no capability for that card. Nothing was recorded or released.' });
754
+ }
755
+ const recordKey = childIdempotencyKey(args.idempotencyKey, 'record');
756
+ const untickKey = childIdempotencyKey(args.idempotencyKey, 'untick');
757
+ const releaseKey = childIdempotencyKey(args.idempotencyKey, 'release');
758
+ const flagReason = args.disposition === 'failed_criterion'
759
+ ? `QA criterion failure (${args.criterionId}): ${args.details.trim()} Waiting for a fixed deployed build before requeue.`
760
+ : args.details.trim();
761
+ // Flag first. If this composite is interrupted after any following step,
762
+ // claim-next still cannot put another worker into a hot loop on the same
763
+ // broken build. Every child is endpoint-idempotent, so a retry continues.
764
+ await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
765
+ body: { flagged: true, reason: flagReason, idempotencyKey: recordKey },
766
+ headers: mutationHeadersForItem({ ...args, idempotencyKey: recordKey }, args.itemId),
767
+ });
768
+ if (args.disposition === 'failed_criterion') {
769
+ await api('DELETE', criterionSatisfactionPath(args.itemId, args.criterionId), {
770
+ headers: mutationHeadersForItem({ ...args, idempotencyKey: untickKey }, args.itemId),
771
+ });
772
+ }
773
+ const releaseKind = args.disposition === 'failed_criterion' ? 'released' : 'handoff';
774
+ const release = await releaseHeldPass(
775
+ { ...args, idempotencyKey: releaseKey },
776
+ releaseKind,
777
+ leaseHeaders
778
+ );
779
+ return ok({
780
+ recorded: true,
781
+ disposition: args.disposition,
782
+ requeued: false,
783
+ blocked: true,
784
+ waitingForFix: args.disposition === 'failed_criterion',
785
+ releaseKind,
786
+ ...release,
787
+ });
788
+ }
789
+ );
790
+
791
+ server.tool(
792
+ 'requeue_work_item_qa_after_fix',
793
+ `Clear a failed QA card’s waiting-for-fix flag only after a different exact build has been deployed and its relationship to the failed build has been checked. This does not claim the card; it makes the same card eligible for a fresh QA pass. The clear reason durably records both SHAs and the fix/deployment reference. The worker must verify ancestry in source control before calling this tool; this endpoint rejects the unchanged SHA but does not pretend to be a Git ancestry oracle.${GOVERNED_NOTE}`,
794
+ {
795
+ itemId: z.string().describe('Flagged QA card left in place by record_work_item_qa_failure_and_release.'),
796
+ failedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the build that failed.'),
797
+ deployedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the newer deployed descendant build.'),
798
+ details: z.string().describe('How ancestry and deployment were verified, including the fix PR or deployment reference.'),
799
+ dryRun: z.boolean().optional().default(true),
800
+ approved: z.boolean().optional().default(false),
801
+ reason: z.string().optional(),
802
+ idempotencyKey: z.string().optional(),
803
+ },
804
+ async (args) => {
805
+ const exactSha = (value) => /^[0-9a-f]{40}$/.test(value ?? '');
806
+ if (!exactSha(args.failedBuildSha) || !exactSha(args.deployedBuildSha)) {
807
+ return ok({ error: true, message: 'Both build SHAs must be exact lowercase 40-character Git SHAs.' });
808
+ }
809
+ if (args.failedBuildSha === args.deployedBuildSha) {
810
+ return ok({ error: true, message: 'The unchanged failed build cannot be requeued.' });
811
+ }
812
+ if (!args.details?.trim()) {
813
+ return ok({ error: true, message: 'details must name the ancestry check and deployment/fix reference.' });
814
+ }
815
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
816
+ const clearReason = `QA requeue: deployed ${args.deployedBuildSha} after failed ${args.failedBuildSha}. ${args.details.trim()}`;
817
+ const preview = validateApiBridgeRequest({
818
+ method: 'POST', path, dryRun: args.dryRun, approved: args.approved,
819
+ reason: args.reason, idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
820
+ });
821
+ if (preview.dryRun) {
822
+ return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha } });
823
+ }
824
+ const data = await api('POST', path, {
825
+ body: { flagged: false, reason: clearReason, idempotencyKey: args.idempotencyKey },
826
+ headers: buildMutationHeaders(args),
827
+ });
828
+ return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha });
829
+ }
830
+ );
831
+
276
832
  server.tool(
277
833
  'get_work_item_history',
278
834
  'Read a card\'s column transitions, newest first: which column it came from, which it went to, when it entered and left, who moved it, and why. This is the audit trail the stage timer is derived from — use it to answer "how long did this actually take" rather than guessing from the current column.',
@@ -320,6 +876,20 @@ Start here for "what should I work on". With includeUnassigned it also returns t
320
876
  }
321
877
  );
322
878
 
879
+ server.tool(
880
+ 'list_work_item_qa_evidence',
881
+ 'Read the durable image/video QA receipts attached to one card. Returns governed metadata and availability, never a presigned content URL or storage capability. Use the card UI to review playback; use attach_work_item_qa_evidence to add a fresh receipt.',
882
+ { itemId: z.string().describe('Card id.') },
883
+ async (args) => {
884
+ const data = await api(
885
+ 'GET',
886
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence`
887
+ );
888
+ const evidence = data?.data ?? [];
889
+ return ok({ count: evidence.length, evidence });
890
+ }
891
+ );
892
+
323
893
  server.tool(
324
894
  'audit_work_hub',
325
895
  `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.`,
@@ -348,10 +918,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
348
918
  },
349
919
  async (args) => {
350
920
  const rollupId = args.rollupId || 'all';
351
- const data = await api(
352
- 'GET',
353
- `/api/v1/work-board-rollups/${encodeURIComponent(rollupId)}`
354
- );
921
+ const data = await api('GET', `/api/v1/work-board-rollups/${encodeURIComponent(rollupId)}`);
355
922
  return ok({ rollup: data?.data });
356
923
  }
357
924
  );
@@ -369,7 +936,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
369
936
 
370
937
  server.tool(
371
938
  'get_work_item_comments',
372
- 'Read what people have SAID about a card, oldest first: each comment\'s author, body, and timestamp. READ THIS BEFORE STARTING WORK, alongside get_work_item_history. The history says which columns a card passed through; the comments say WHY — a card that came back from QA has the reviewer\'s reason here, and repeating a rejected approach is the most expensive mistake available on this board. A comment marked withdrawn is still returned with its text: its author took the claim back, but somebody may already have acted on it, so it is context and not noise. If the card carries a `flag`, the reason it was raised is in this thread too.',
939
+ "Read what people have SAID about a card, newest first: each comment's author, body, and timestamp. READ THIS BEFORE STARTING WORK, alongside get_work_item_history. The history says which columns a card passed through; the comments say WHY — a card that came back from QA has the reviewer's reason here, and repeating a rejected approach is the most expensive mistake available on this board. A comment marked withdrawn is still returned with its text: its author took the claim back, but somebody may already have acted on it, so it is context and not noise. If the card carries a `flag`, the reason it was raised is in this thread too.",
373
940
  {
374
941
  itemId: z.string().describe('Card id.'),
375
942
  },
@@ -383,7 +950,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
383
950
  count: comments.length,
384
951
  comments,
385
952
  howToRead:
386
- 'Oldest first, so the thread reads forwards. Read `bodyText` — it is `body` with every @-mention resolved to a name. `body` is the STORED form and carries `<@userId>` tokens; keep it only if you intend to edit the comment, since saving `bodyText` back would turn every mention into a literal name. `withdrawnAt` means the author retracted it — the text is kept because somebody may have acted on it. `edited` means the text changed after posting. `mentions` lists the workspace members the comment names.',
953
+ 'Newest first, matching the Activity timeline. Read `bodyText` — it is `body` with every @-mention resolved to a name. `body` is the STORED form and carries `<@userId>` tokens; keep it only if you intend to edit the comment, since saving `bodyText` back would turn every mention into a literal name. `withdrawnAt` means the author retracted it — the text is kept because somebody may have acted on it. `edited` means the text changed after posting. `mentions` lists the workspace members the comment names.',
387
954
  });
388
955
  }
389
956
  );
@@ -392,6 +959,175 @@ Start here for "what should I work on". With includeUnassigned it also returns t
392
959
  // WRITES
393
960
  // =========================================================================
394
961
 
962
+ server.tool(
963
+ 'attach_work_item_qa_evidence',
964
+ `Attach one finalized local image or video to the card's CURRENT claimed QA dwell. This is the whole governed flow: the MCP process reads and hashes the local file, asks Adrata for a private upload capability, uploads the bytes directly, renews the worker lease, finalizes server-side verification, and returns only the durable receipt. File bytes, the local path, presigned URLs, upload headers, lease bearer, and fence never enter the conversation. Claim the pass first and keep recordings synthetic/redacted.${GOVERNED_NOTE}`,
965
+ {
966
+ itemId: z.string().describe('Card id returned by this MCP session’s winning QA claim.'),
967
+ criterionId: z.string().optional().describe('Acceptance criterion this recording proves. Required by the API when the card has criteria.'),
968
+ filePath: z.string().describe('Local path to the finalized PNG, JPEG, WebP, GIF, MP4, or WebM. The path and bytes stay process-private.'),
969
+ mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'video/mp4', 'video/webm']),
970
+ environment: z.enum(['local', 'staging', 'production']),
971
+ declaredBuildSha: z.string().describe('Exact lowercase 40-character deployed Git SHA tested by this recording.'),
972
+ declaredReviewerAgent: z.string().describe('Agent/runtime declaration shown on the receipt; never invent an unknown value.'),
973
+ declaredReviewerModel: z.string().describe('Model declaration shown on the receipt; use an honest unrecorded label rather than guessing.'),
974
+ outcome: z.enum(['pass', 'fail']),
975
+ summary: z.string().describe('Short synthetic-safe result summary. Never paste a transcript, token, customer data, or sensitive URL.'),
976
+ durationMs: z.number().int().positive().optional().describe('Final media duration for video, measured from the finalized file; omit for images.'),
977
+ checkpoints: z.array(z.object({
978
+ offsetMs: z.number().int().nonnegative(),
979
+ label: z.string(),
980
+ })).optional().describe('Ordered video checkpoints beginning at 0; omit for images.'),
981
+ consoleErrorCount: z.number().int().nonnegative(),
982
+ pageErrorCount: z.number().int().nonnegative(),
983
+ networkErrorCount: z.number().int().nonnegative(),
984
+ redactionConfirmed: z.literal(true).describe('Confirms synthetic/approved data and no visible credentials, tokens, customer data, or sensitive query values.'),
985
+ dryRun: z.boolean().optional().default(true),
986
+ approved: z.boolean().optional().default(false),
987
+ reason: z.string().optional().describe('Required for a live attachment: what journey was recorded and why it belongs on this criterion.'),
988
+ idempotencyKey: z.string().optional().describe('Required for a live attachment. Reuse the SAME key on retry.'),
989
+ },
990
+ async (args) => {
991
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/uploads`;
992
+ const preview = validateApiBridgeRequest({
993
+ method: 'POST',
994
+ path,
995
+ dryRun: args.dryRun,
996
+ approved: args.approved,
997
+ reason: args.reason,
998
+ idempotencyKey: args.idempotencyKey,
999
+ grantedScope: getGrantedScope(),
1000
+ });
1001
+ if (preview.dryRun) {
1002
+ return ok({
1003
+ ...preview,
1004
+ wouldAttachQaEvidence: {
1005
+ itemId: args.itemId,
1006
+ criterionId: args.criterionId ?? null,
1007
+ fileName: basename(String(args.filePath || '')),
1008
+ mimeType: args.mimeType,
1009
+ environment: args.environment,
1010
+ declaredBuildSha: args.declaredBuildSha,
1011
+ outcome: args.outcome,
1012
+ },
1013
+ note: 'The local file has not been read or uploaded. Its path and bytes will remain process-private after approval.',
1014
+ });
1015
+ }
1016
+
1017
+ const leaseHeaders = workerLeaseHeaders(args.itemId);
1018
+ if (!leaseHeaders) {
1019
+ return ok({
1020
+ error: true,
1021
+ message: 'This MCP session has no capability for that card. Claim the QA pass before attaching evidence.',
1022
+ });
1023
+ }
1024
+ const local = await loadQaEvidenceFile(args.filePath);
1025
+ const maxBytes = args.mimeType.startsWith('image/')
1026
+ ? MAX_QA_IMAGE_BYTES
1027
+ : MAX_QA_VIDEO_BYTES;
1028
+ if (local.sizeBytes <= 0 || local.sizeBytes > maxBytes) {
1029
+ throw new Error(`QA ${args.mimeType.startsWith('image/') ? 'image' : 'video'} exceeds its governed size limit.`);
1030
+ }
1031
+ const request = {
1032
+ acceptanceCriterionId: args.criterionId ?? null,
1033
+ environment: args.environment,
1034
+ declaredBuildSha: args.declaredBuildSha,
1035
+ fileName: local.fileName,
1036
+ mimeType: args.mimeType,
1037
+ sizeBytes: local.sizeBytes,
1038
+ sha256: local.sha256,
1039
+ declaredReviewerAgent: args.declaredReviewerAgent,
1040
+ declaredReviewerModel: args.declaredReviewerModel,
1041
+ outcome: args.outcome,
1042
+ summary: args.summary,
1043
+ declaredDurationMs: args.durationMs ?? null,
1044
+ checkpoints: args.checkpoints ?? [],
1045
+ declaredConsoleErrorCount: args.consoleErrorCount,
1046
+ declaredPageErrorCount: args.pageErrorCount,
1047
+ declaredNetworkErrorCount: args.networkErrorCount,
1048
+ redactionConfirmed: args.redactionConfirmed === true,
1049
+ };
1050
+ const initiateHeaders = { ...buildMutationHeaders(args), ...leaseHeaders };
1051
+ const initiated = await api('POST', path, { body: request, headers: initiateHeaders });
1052
+ const ticket = initiated?.data;
1053
+ if (!ticket?.id || !ticket?.uploadUrl || !ticket?.headers) {
1054
+ throw new Error('The API returned an incomplete private QA evidence upload capability.');
1055
+ }
1056
+
1057
+ const heartbeatPath = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/heartbeat`;
1058
+ // Initiation owns the caller's replay key. Heartbeat and completion are
1059
+ // separate resources with their own server-side retry semantics, so they
1060
+ // receive only the private lease capability rather than reusing one
1061
+ // Idempotency-Key across three endpoints.
1062
+ await api('POST', heartbeatPath, { headers: leaseHeaders });
1063
+ try {
1064
+ await uploadQaEvidenceFile(ticket, local.bytes);
1065
+ } catch {
1066
+ throw new Error(`QA evidence ${ticket.id} could not be uploaded to private storage.`);
1067
+ }
1068
+ await api('POST', heartbeatPath, { headers: leaseHeaders });
1069
+
1070
+ let completed;
1071
+ try {
1072
+ completed = await api(
1073
+ 'POST',
1074
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/${encodeURIComponent(ticket.id)}/complete`,
1075
+ { headers: leaseHeaders }
1076
+ );
1077
+ } catch {
1078
+ throw new Error(`QA evidence ${ticket.id} could not be finalized by Adrata.`);
1079
+ }
1080
+ return ok({ attached: true, evidence: completed?.data });
1081
+ }
1082
+ );
1083
+
1084
+ server.tool(
1085
+ 'verify_work_item_qa_evidence_playback',
1086
+ `Record the separate fact that you REOPENED an already-uploaded QA video in the card UI and personally observed it load, play, seek, and enter fullscreen. Upload completion proves stored bytes only; never call this tool from an upload response or without exercising all four controls. The receipt is bound to the current claimed QA dwell and server-authenticated credential. In QA2 you may verify the current QA2 recording or the recording from the immediately preceding QA1 dwell.${GOVERNED_NOTE}`,
1087
+ {
1088
+ itemId: z.string().describe('Card id currently claimed by this MCP worker.'),
1089
+ evidenceId: z.string().describe('Video evidence receipt id actually reopened in the signed-in card UI.'),
1090
+ contentLoaded: z.literal(true).describe('True only after the video rendered usable media.'),
1091
+ playStarted: z.literal(true).describe('True only after playback visibly started.'),
1092
+ seekCompleted: z.literal(true).describe('True only after seeking to another point completed.'),
1093
+ fullscreenEntered: z.literal(true).describe('True only after the video entered native or in-page fullscreen.'),
1094
+ dryRun: z.boolean().optional().default(true),
1095
+ approved: z.boolean().optional().default(false),
1096
+ reason: z.string().optional().describe('Required live audit reason naming the playback review.'),
1097
+ idempotencyKey: z.string().optional().describe('Required live key. Reuse the SAME key only for the same playback review.'),
1098
+ },
1099
+ async (args) => {
1100
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/${encodeURIComponent(args.evidenceId)}/playback-verifications`;
1101
+ const body = {
1102
+ contentLoaded: args.contentLoaded,
1103
+ playStarted: args.playStarted,
1104
+ seekCompleted: args.seekCompleted,
1105
+ fullscreenEntered: args.fullscreenEntered,
1106
+ };
1107
+ const preview = validateApiBridgeRequest({
1108
+ method: 'POST', path, body, dryRun: args.dryRun, approved: args.approved,
1109
+ reason: args.reason, idempotencyKey: args.idempotencyKey,
1110
+ grantedScope: getGrantedScope(),
1111
+ });
1112
+ if (preview.dryRun) {
1113
+ return ok({
1114
+ ...preview,
1115
+ wouldVerifyPlayback: { itemId: args.itemId, evidenceId: args.evidenceId },
1116
+ note: 'No receipt was written. Confirm only after the signed-in video loaded, played, sought, and entered fullscreen.',
1117
+ });
1118
+ }
1119
+ const leaseHeaders = workerLeaseHeaders(args.itemId);
1120
+ if (!leaseHeaders) {
1121
+ return ok({ error: true, message: 'This MCP session has no capability for that card. Claim the QA pass before recording playback.' });
1122
+ }
1123
+ const data = await api('POST', path, {
1124
+ body,
1125
+ headers: { ...buildMutationHeaders(args), ...leaseHeaders },
1126
+ });
1127
+ return ok({ playbackVerification: data?.data });
1128
+ }
1129
+ );
1130
+
395
1131
  server.tool(
396
1132
  'set_work_board_archived',
397
1133
  `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}`,
@@ -400,7 +1136,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
400
1136
  archived: z.boolean().describe('true hides the board; false restores it.'),
401
1137
  dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
402
1138
  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.'),
1139
+ reason: z
1140
+ .string()
1141
+ .optional()
1142
+ .describe('Required for a live change: why this board is being hidden or restored.'),
404
1143
  idempotencyKey: z.string().optional().describe('Required for a live change.'),
405
1144
  },
406
1145
  async (args) => {
@@ -457,7 +1196,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
457
1196
  .describe('Positive ceiling, or null to clear it (unlimited).'),
458
1197
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to change it.'),
459
1198
  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.'),
1199
+ reason: z
1200
+ .string()
1201
+ .optional()
1202
+ .describe('Required for a live change: why this capacity is right.'),
461
1203
  idempotencyKey: z.string().optional().describe('Required for a live change. Reuse on retry.'),
462
1204
  },
463
1205
  async (args) => {
@@ -491,16 +1233,18 @@ Start here for "what should I work on". With includeUnassigned it also returns t
491
1233
 
492
1234
  server.tool(
493
1235
  'move_work_item',
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.
1236
+ `Move a card to another column on the same board — and, outside QA, use claim:true to 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.
495
1237
 
496
1238
  A card carries TWO people and they are not interchangeable. The OWNER (assignee) is whoever carries the card end to end — the engineer who builds it, and the person a QA bounce sends it back to. The HANDLER is whoever took the pass the card is on right now, which at a QA gate is the tester and nowhere else is usually the owner. claim:true always takes the pass; it takes ownership ONLY of a card nobody owns. So a QA pick-up on an engineer's card leaves the engineer owning it, which is what makes the two-gate flow work at all.
497
1239
 
498
- This is both halves of the developer loop. Claiming is a parameter and not a second tool on purpose: picking a card up is one act, and a separate "assign" call is the one that gets skipped leaving a card in an active column with no owner, which is the exact finding the board's unassigned glyph exists to shout about.${GOVERNED_NOTE}`,
1240
+ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-private lease and fencing capability before it can write in Staging QA1 or Staging QA2. Use claim_work_item_qa_pass (or claim_next_work_item_qa_pass) after the card is in QA; never use move_work_item(claim:true) to take a QA pass. This connector refuses that legacy shape locally instead of sending an unfenced write the API must reject.${GOVERNED_NOTE}`,
499
1241
  {
500
1242
  itemId: z.string().describe('Card id to move.'),
501
1243
  toColumnId: z
502
1244
  .string()
503
- .describe('Target column id. Must be a column on the SAME board — get it from get_work_board.'),
1245
+ .describe(
1246
+ 'Target column id. Must be a column on the SAME board — get it from get_work_board.'
1247
+ ),
504
1248
  position: z
505
1249
  .number()
506
1250
  .optional()
@@ -509,7 +1253,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
509
1253
  .boolean()
510
1254
  .optional()
511
1255
  .describe(
512
- 'Pick this card up as part of this move: it records YOU as the handler of the pass the card lands on, and makes you the owner only if the card has no owner. "You" is resolved from the authenticated token — there is no way to claim on somebody else\'s behalf. Use it whenever you are picking work up. Taking a pass on a card SOMEBODY ELSE OWNS is allowed and normal (that is a QA pick-up) and leaves their ownership alone; taking a pass somebody else is already HOLDING is refused (see force). Re-claiming a pass you already hold is a no-op, not an error. The handler is written to the card\'s open dwell, so a same-column claim works too — that is how you take a pass on a card already sitting in your stage.'
1256
+ 'Pick this card up as part of a NON-QA move: it records YOU as the handler of the pass the card lands on, and makes you the owner only if the card has no owner. "You" is resolved from the authenticated token — there is no way to claim on somebody else\'s behalf. Staging QA1 and Staging QA2 use claim_work_item_qa_pass instead because every executable QA write must carry a process-private lease and fence. Taking a non-QA pass somebody else is already HOLDING is refused (see force). Re-claiming a non-QA pass you already hold is a no-op, not an error.'
513
1257
  ),
514
1258
  force: z
515
1259
  .boolean()
@@ -529,6 +1273,57 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
529
1273
  .string()
530
1274
  .optional()
531
1275
  .describe('Required for a live move. Reuse the SAME key on retry; the server replays.'),
1276
+ acknowledgeUnmetCriteria: z
1277
+ .boolean()
1278
+ .optional()
1279
+ .describe(
1280
+ 'Record an explicit override for moving a card FORWARD out of Staging QA1 or Staging QA2 with acceptance criteria still unverified. The first such move is always refused (409) naming what is open; sending this is the acknowledgement itself, and the server stores the count on the transition row where a release review reads it later. IT IS A HUMAN ACT: the API refuses it to a program (403) before it reads the flag, so an agent setting it gets a refusal rather than a waiver. It is also NOT how a card gets parked — moving a card to Backlog or Deep backlog is a different kind of move and needs no override at all.'
1281
+ ),
1282
+ receipt: z
1283
+ .object({
1284
+ commitSha: z
1285
+ .string()
1286
+ .optional()
1287
+ .describe('The commit or merge SHA the claim rests on — 7 to 40 hex characters.'),
1288
+ pullRequestNumber: z.number().int().optional().describe('The PR number.'),
1289
+ pullRequestUrl: z.string().optional().describe('The PR URL (https).'),
1290
+ repositoryFullName: z.string().optional().describe('owner/name.'),
1291
+ ciProvider: z
1292
+ .enum(['github_actions', 'gitlab_ci', 'local'])
1293
+ .optional()
1294
+ .describe(
1295
+ 'Which system the run id belongs to. Required alongside ciRunId: an id with no provider names no API to re-fetch it from. `local` is first-class — evidence from a local verification run belongs here rather than in a second mechanism.'
1296
+ ),
1297
+ ciRunId: z
1298
+ .string()
1299
+ .optional()
1300
+ .describe(
1301
+ 'The CI or test run BY ID, so a reader can re-fetch it instead of believing you.'
1302
+ ),
1303
+ ciRunUrl: z
1304
+ .string()
1305
+ .optional()
1306
+ .describe('Where to fetch it, when the URL is not derivable.'),
1307
+ environment: z
1308
+ .enum(['local', 'staging', 'production'])
1309
+ .optional()
1310
+ .describe('Where the check ran, if it ran against a running system.'),
1311
+ verifiedUrl: z.string().optional().describe('The exact URL that was checked.'),
1312
+ sessionRef: z
1313
+ .string()
1314
+ .optional()
1315
+ .describe(
1316
+ 'A POINTER to your session — an id or a hash. NEVER its contents. Letters, digits, dot, underscore, colon and hyphen only, 8 to 128 characters; anything carrying a space or a newline is refused by the server AND by the column. Do not put transcript text, message bodies, tool output, or anything you were told in confidence anywhere in this receipt.'
1317
+ ),
1318
+ sessionRefKind: z
1319
+ .enum(['claude_session_id', 'codex_session_id', 'sha256'])
1320
+ .optional()
1321
+ .describe('What kind of pointer sessionRef is. Required alongside it.'),
1322
+ })
1323
+ .optional()
1324
+ .describe(
1325
+ 'The STRUCTURED evidence this move rests on, stored beside the reason rather than instead of it. Every field is a REFERENCE somebody can re-check without trusting you: a SHA verified against main, a run id re-fetched from the vendor. There is deliberately no field for a sentence — that is what `reason` is for. Send it whenever the move rests on something that landed; a move with no receipt is counted as a move that named no merge commit, which is a real and useful answer.'
1326
+ ),
532
1327
  },
533
1328
  async (args) => {
534
1329
  if (args.force === true && args.claim !== true) {
@@ -552,10 +1347,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
552
1347
  grantedScope: getGrantedScope(),
553
1348
  });
554
1349
  if (preview?.dryRun) {
555
- const itemData = await api(
556
- 'GET',
557
- `/api/v1/work-items/${encodeURIComponent(args.itemId)}`
558
- );
1350
+ const itemData = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
559
1351
  const item = itemData?.data;
560
1352
  const boardData = await api(
561
1353
  'GET',
@@ -566,12 +1358,24 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
566
1358
  if (!target) {
567
1359
  return ok({
568
1360
  error: true,
569
- message: 'toColumnId is not a column on this card\'s board.',
1361
+ message: "toColumnId is not a column on this card's board.",
570
1362
  });
571
1363
  }
572
1364
  const count = (board.items ?? []).filter(
573
1365
  (candidate) => candidate.columnId === args.toColumnId
574
1366
  ).length;
1367
+ if (args.claim === true && isQaStageName(target.name)) {
1368
+ return ok({
1369
+ ...preview,
1370
+ blocked: true,
1371
+ code: 'qa_pass_requires_worker_lease_claim',
1372
+ message:
1373
+ item.columnId === args.toColumnId
1374
+ ? `Card ${args.itemId} is already in ${target.name}. Use claim_work_item_qa_pass to take this exact QA dwell atomically; move_work_item cannot create or retain the required private lease fence.`
1375
+ : `Move card ${args.itemId} into ${target.name} without claim:true, then use claim_work_item_qa_pass to take the new QA dwell atomically. move_work_item cannot create or retain the required private lease fence.`,
1376
+ wouldSend: false,
1377
+ });
1378
+ }
575
1379
  return ok({
576
1380
  ...preview,
577
1381
  wouldMove: {
@@ -580,6 +1384,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
580
1384
  position: args.position,
581
1385
  claim: args.claim === true,
582
1386
  force: args.force === true,
1387
+ receipt: args.receipt !== undefined,
583
1388
  },
584
1389
  wip: projectColumnWip({
585
1390
  limit: target.wipLimit,
@@ -590,6 +1395,46 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
590
1395
  });
591
1396
  }
592
1397
 
1398
+ // The generic move claim predates executable QA worker leases. It can
1399
+ // name a handler, but it cannot return and retain the bearer/fence that
1400
+ // every later agent write in a QA dwell must prove. Sending that legacy
1401
+ // shape now produces an opaque 409 and leaves no claimed pass. Resolve
1402
+ // the target before the mutation and direct the worker to the dedicated
1403
+ // atomic lease endpoint instead. The API fence remains mandatory.
1404
+ if (args.claim === true) {
1405
+ const itemData = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
1406
+ const item = itemData?.data;
1407
+ const boardData = item?.boardId
1408
+ ? await api('GET', `/api/v1/work-boards/${encodeURIComponent(item.boardId)}`)
1409
+ : null;
1410
+ const target = boardData?.data?.columns?.find(
1411
+ (column) => column.id === args.toColumnId
1412
+ );
1413
+ if (isQaStageName(target?.name)) {
1414
+ return ok({
1415
+ error: true,
1416
+ code: 'qa_pass_requires_worker_lease_claim',
1417
+ message:
1418
+ item?.columnId === args.toColumnId
1419
+ ? `Card ${args.itemId} is already in ${target.name}. Use claim_work_item_qa_pass to take this exact QA dwell atomically; move_work_item cannot create or retain the required private lease fence.`
1420
+ : `Move card ${args.itemId} into ${target.name} without claim:true, then use claim_work_item_qa_pass to take the new QA dwell atomically. move_work_item cannot create or retain the required private lease fence.`,
1421
+ });
1422
+ }
1423
+ }
1424
+
1425
+ // A same-column move is only a reorder: the QA dwell and server lease
1426
+ // remain open. Read the source immediately before the governed write so
1427
+ // this process drops its capability only when the move truly transitions
1428
+ // columns. A stale capability is still rejected by the server fence, but
1429
+ // prematurely forgetting a live one would strand the claimed pass.
1430
+ let sourceColumnId = null;
1431
+ if (workerLeaseCapabilities.has(args.itemId)) {
1432
+ const beforeMove = await api(
1433
+ 'GET',
1434
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}`
1435
+ );
1436
+ sourceColumnId = beforeMove?.data?.columnId ?? null;
1437
+ }
593
1438
  const data = await api('POST', path, {
594
1439
  body: {
595
1440
  toColumnId: args.toColumnId,
@@ -600,13 +1445,28 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
600
1445
  // about the assignee at all.
601
1446
  claim: args.claim === true ? true : undefined,
602
1447
  force: args.force === true ? true : undefined,
1448
+ // The API's documented escape hatch, which this connector used to
1449
+ // drop on the floor: the field was never in the body, so a person
1450
+ // driving the board through MCP could be refused a QA exit and had
1451
+ // no way to record the override the refusal told them to send.
1452
+ acknowledgeUnmetCriteria:
1453
+ args.acknowledgeUnmetCriteria === true ? true : undefined,
1454
+ receipt: args.receipt,
603
1455
  },
604
- headers: buildMutationHeaders(args),
1456
+ headers: mutationHeadersForItem(args, args.itemId),
605
1457
  });
606
1458
  const item = data?.data;
1459
+ if (sourceColumnId !== args.toColumnId) {
1460
+ workerLeaseCapabilities.delete(args.itemId);
1461
+ }
607
1462
  return ok({
608
1463
  moved: true,
609
1464
  claimed: args.claim === true,
1465
+ // Echoed as a BOOLEAN, never as the receipt's contents. What the agent
1466
+ // needs to know is whether the evidence was accepted; repeating a
1467
+ // session pointer back into a conversation transcript is the leak this
1468
+ // whole field is shaped to avoid.
1469
+ receiptRecorded: args.receipt !== undefined,
610
1470
  // Read back from the server rather than echoed from the request, because
611
1471
  // the two halves of a claim are decided server-side and an agent that
612
1472
  // assumed "claimed" meant "mine now" would report a QA pick-up as having
@@ -618,6 +1478,117 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
618
1478
  }
619
1479
  );
620
1480
 
1481
+ server.tool(
1482
+ 'transfer_work_item_between_boards',
1483
+ `Transfer one existing card identity to a column on another operating board. This is NOT a copy and NOT a workflow promotion: the server keeps the work-item id and every item-linked comment, criterion, task, QA-evidence row, release link and SCM link; closes the old board dwell; and opens the destination dwell in one transaction. Use move_work_item for a column on the same board. A transfer needs BOTH destination ids so a column from a different board cannot be attached accidentally.${GOVERNED_NOTE}`,
1484
+ {
1485
+ itemId: z.string().describe('Existing card id. It is preserved; no new card is created.'),
1486
+ toBoardId: z.string().describe('Destination operating-board id from list_work_boards.'),
1487
+ toColumnId: z
1488
+ .string()
1489
+ .describe('Destination column id from get_work_board(toBoardId). Must belong to toBoardId.'),
1490
+ position: z
1491
+ .number()
1492
+ .optional()
1493
+ .describe('Sort position within the target column. Omit to append.'),
1494
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live transfer.'),
1495
+ approved: z.boolean().optional().describe('Required true for a live transfer.'),
1496
+ reason: z
1497
+ .string()
1498
+ .optional()
1499
+ .describe('Required for a live transfer: why this operating board is canonical.'),
1500
+ idempotencyKey: z
1501
+ .string()
1502
+ .optional()
1503
+ .describe('Required for a live transfer. Reuse the SAME key on retry.'),
1504
+ receipt: z
1505
+ .object({
1506
+ commitSha: z.string().optional(),
1507
+ pullRequestNumber: z.number().int().optional(),
1508
+ pullRequestUrl: z.string().optional(),
1509
+ repositoryFullName: z.string().optional(),
1510
+ ciProvider: z.enum(['github_actions', 'gitlab_ci', 'local']).optional(),
1511
+ ciRunId: z.string().optional(),
1512
+ ciRunUrl: z.string().optional(),
1513
+ environment: z.enum(['local', 'staging', 'production']).optional(),
1514
+ verifiedUrl: z.string().optional(),
1515
+ sessionRef: z.string().optional(),
1516
+ sessionRefKind: z
1517
+ .enum(['claude_session_id', 'codex_session_id', 'sha256'])
1518
+ .optional(),
1519
+ })
1520
+ .optional()
1521
+ .describe('Optional structured references supporting the reconciliation decision.'),
1522
+ },
1523
+ async (args) => {
1524
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
1525
+ if (unauditable) return ok({ error: true, message: unauditable });
1526
+
1527
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/transfer`;
1528
+ const preview = validateApiBridgeRequest({
1529
+ method: 'POST',
1530
+ path,
1531
+ dryRun: args.dryRun,
1532
+ approved: args.approved,
1533
+ reason: args.reason,
1534
+ idempotencyKey: args.idempotencyKey,
1535
+ grantedScope: getGrantedScope(),
1536
+ });
1537
+ if (preview?.dryRun) {
1538
+ const [itemData, boardData] = await Promise.all([
1539
+ api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`),
1540
+ api('GET', `/api/v1/work-boards/${encodeURIComponent(args.toBoardId)}`),
1541
+ ]);
1542
+ const item = itemData?.data;
1543
+ const board = boardData?.data;
1544
+ if (item?.boardId === args.toBoardId) {
1545
+ return ok({
1546
+ error: true,
1547
+ message: 'The card already belongs to toBoardId; use move_work_item for same-board movement.',
1548
+ });
1549
+ }
1550
+ const target = board?.columns?.find((column) => column.id === args.toColumnId);
1551
+ if (!target) {
1552
+ return ok({
1553
+ error: true,
1554
+ message: 'toColumnId is not a visible column on toBoardId.',
1555
+ });
1556
+ }
1557
+ return ok({
1558
+ ...preview,
1559
+ wouldTransfer: {
1560
+ itemId: args.itemId,
1561
+ fromBoardId: item?.boardId,
1562
+ toBoardId: args.toBoardId,
1563
+ toColumnId: args.toColumnId,
1564
+ position: args.position,
1565
+ receipt: args.receipt !== undefined,
1566
+ },
1567
+ identityPreserved: true,
1568
+ freshDestinationDwellRequired: true,
1569
+ });
1570
+ }
1571
+
1572
+ const data = await api('POST', path, {
1573
+ body: {
1574
+ toBoardId: args.toBoardId,
1575
+ toColumnId: args.toColumnId,
1576
+ position: args.position,
1577
+ reason: args.reason,
1578
+ idempotencyKey: args.idempotencyKey,
1579
+ receipt: args.receipt,
1580
+ },
1581
+ headers: buildMutationHeaders(args),
1582
+ });
1583
+ return ok({
1584
+ transferred: true,
1585
+ identityPreserved: true,
1586
+ receiptRecorded: args.receipt !== undefined,
1587
+ item: data?.data,
1588
+ });
1589
+ }
1590
+ );
1591
+
621
1592
  server.tool(
622
1593
  'set_work_item_tag',
623
1594
  `Set a card's URGENCY tag. The scheme must be one the board reads (severity | priority | impact) and the source must be human, model, or rules. A "model" tag REQUIRES a confidence: the board drops a low-confidence model tag, and a model tag with no confidence cannot be held to that floor, so it would be trusted by default — backwards. This does NOT say what kind of work the card is — that is a separate field; use set_work_item_kind, and note that setting one never disturbs the other.${GOVERNED_NOTE}`,
@@ -746,9 +1717,9 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
746
1717
 
747
1718
  server.tool(
748
1719
  'create_work_item',
749
- `Create a card on a board. Lands in the named column, or the board's first column when none is given.
1720
+ `Create a card on a board. Lands in the named column, or in Backlog on a standard board when none is given.
750
1721
 
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.
1722
+ 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. Author the proof route at the same time: \`verificationRoute\` is REQUIRED on every criterion, because an omitted route silently became \`product\` and that is what a browser recording is then demanded for. Use \`engineering\`/\`both\` with a plain-language \`engineeringReason\` when no meaningful product-person retest exists. 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.
752
1723
 
753
1724
  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.
754
1725
 
@@ -769,6 +1740,24 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
769
1740
  givenText: z.string().optional().describe('Starting state, when one is required.'),
770
1741
  whenText: z.string().describe('Action the verifier performs.'),
771
1742
  thenText: z.string().describe('Observable result that must follow.'),
1743
+ verificationRoute: z
1744
+ .enum(['product', 'engineering', 'both'])
1745
+ .describe(
1746
+ 'REQUIRED. How this criterion gets proved. `product` needs a ' +
1747
+ 'signed-in browser recording; `engineering` needs executed code/' +
1748
+ 'test/runtime proof; `both` needs BOTH and is the STRICTEST of ' +
1749
+ 'the three, not a compromise. Choose from what the thenText ' +
1750
+ 'says: can a browser DISPLAY the result? Then product. Is the ' +
1751
+ 'result an ECS task definition, a migration receipt, a CI leg, ' +
1752
+ 'an HTTP status contract, a log record, a vendor invoice or a ' +
1753
+ 'test suite? Then engineering.'
1754
+ ),
1755
+ engineeringReason: z
1756
+ .string()
1757
+ .optional()
1758
+ .describe(
1759
+ 'Required for engineering or both: why no meaningful product-person retest exists.'
1760
+ ),
772
1761
  })
773
1762
  )
774
1763
  .optional()
@@ -780,9 +1769,12 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
780
1769
  .enum(['bug', 'story', 'chore'])
781
1770
  .optional()
782
1771
  .describe(
783
- 'What kind of work this is. OMIT IT unless the card plainly says: an absent kind means untyped, which is honest, where a guess is indistinguishable from a person\'s judgement once it is on the record. Change it later with set_work_item_kind.'
1772
+ "What kind of work this is. OMIT IT unless the card plainly says: an absent kind means untyped, which is honest, where a guess is indistinguishable from a person's judgement once it is on the record. Change it later with set_work_item_kind."
784
1773
  ),
785
- columnId: z.string().optional().describe('Target column. Defaults to the board\'s first.'),
1774
+ columnId: z
1775
+ .string()
1776
+ .optional()
1777
+ .describe('Target column. Defaults to Backlog on a standard board.'),
786
1778
  assigneeUserId: z
787
1779
  .string()
788
1780
  .optional()
@@ -858,22 +1850,46 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
858
1850
  );
859
1851
  criteria.push(result?.data);
860
1852
  }
861
- return ok({ created: true, item, criteria });
1853
+ return ok({
1854
+ created: true,
1855
+ item: await reconcileCriteriaCount(api, item, criteria),
1856
+ criteria,
1857
+ });
862
1858
  }
863
1859
  );
864
1860
 
865
1861
  server.tool(
866
1862
  '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}`,
1863
+ `Add one executable acceptance criterion to a card. This is grooming, not a comment: write where the check runs, the action, the observable result, and its proof route. verificationRoute is REQUIRED -- an omitted route silently became product, which then demands a browser recording for a criterion about a CI job. engineering and both require a plain-language engineeringReason. The server stores the criterion and its initial route audit receipt atomically.${GOVERNED_NOTE}`,
868
1864
  {
869
1865
  itemId: z.string().describe('Card id.'),
870
1866
  whereText: z.string().describe('Surface, environment, account, or role to check.'),
871
1867
  givenText: z.string().optional().describe('Starting state, when one is required.'),
872
1868
  whenText: z.string().describe('Action the verifier performs.'),
873
1869
  thenText: z.string().describe('Observable result that must follow.'),
1870
+ verificationRoute: z
1871
+ .enum(['product', 'engineering', 'both'])
1872
+ .describe(
1873
+ 'REQUIRED. How this criterion gets proved. `product` needs a signed-in ' +
1874
+ 'browser recording; `engineering` needs executed code/test/runtime ' +
1875
+ 'proof; `both` needs BOTH and is the STRICTEST of the three, not a ' +
1876
+ 'compromise. Choose from what the thenText says: can a browser ' +
1877
+ 'DISPLAY the result? Then product. Is the result an ECS task ' +
1878
+ 'definition, a migration receipt, a CI leg, an HTTP status contract, ' +
1879
+ 'a log record, a vendor invoice or a test suite? Then engineering.'
1880
+ ),
1881
+ engineeringReason: z
1882
+ .string()
1883
+ .optional()
1884
+ .describe(
1885
+ 'Required for engineering or both: why no meaningful product-person retest exists.'
1886
+ ),
874
1887
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
875
1888
  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.'),
1889
+ reason: z
1890
+ .string()
1891
+ .optional()
1892
+ .describe('Required for a live write: why this criterion is being added.'),
877
1893
  idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
878
1894
  },
879
1895
  async (args) => {
@@ -889,6 +1905,8 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
889
1905
  givenText: args.givenText,
890
1906
  whenText: args.whenText,
891
1907
  thenText: args.thenText,
1908
+ verificationRoute: args.verificationRoute,
1909
+ engineeringReason: args.engineeringReason,
892
1910
  };
893
1911
  if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
894
1912
  const data = await api('POST', path, {
@@ -901,23 +1919,29 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
901
1919
 
902
1920
  server.tool(
903
1921
  '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.
1922
+ `Tick one acceptance criterion with the evidence you actually observed.
1923
+
1924
+ WHERE AND WHEN 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 QA PASS and your server-derived credential:
905
1925
 
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:
1926
+ \`verified\` ticked during the card's CURRENT visit to Staging QA1 or Staging QA2 by an authenticated human, or by an agent credential distinct from the criterion author. In Staging QA2, that agent must also differ from the agent whose clean receipt opened Staging QA1.
1927
+ • \`claimed\` — everything else: a build-column tick, an earlier-pass tick, an agent checking its own criterion, the same AI trying to certify both gates, or legacy evidence with no provable credential identity.
907
1928
 
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.
1929
+ EACH QA GATE IS ITS OWN GATE. A receipt belongs to the exact visit it was made in, not to the column's name. So a criterion verified in Staging QA1 reads \`claimed\` once the card reaches Staging QA2, and a card bounced out of QA1 and sent back needs checking again. This is not the board losing your evidence — the earlier pass stays in the card's activity and transition history. It is the board refusing to let one test present itself as two.
910
1930
 
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.
1931
+ The human path is deliberately accountable rather than artificially independent: a sole human reviewer may verify a card they own or a criterion they wrote. The two-AI path remains independent by credential, and one AI cannot impersonate two passes. Nothing fails when a tick grades as \`claimed\`; inspect the returned grade and leave the card where the actual evidence supports it.
912
1932
 
913
1933
  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
1934
 
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}`,
1935
+ THE LATEST TICK IS THE ONE THAT COUNTS, and it REPLACES the one before it. The server writes the ticker, the credential, the column, the pass and the note in one statement, so a second tick overwrites the first including its note. That is what makes two gates two gates: the QA2 reviewer re-ticks the same box to record their own pass, and a tick that could not be replaced would leave QA2 permanently reading QA1's receipt.
1936
+
1937
+ The cost is real and it is the note: re-ticking discards the previous ticker's evidence, and the criterion row only ever shows its current pass. If that evidence is worth keeping, put it on the card with comment_on_work_item BEFORE you re-tick. Do not re-tick a box merely to attach your name to somebody else's finished check.${GOVERNED_NOTE}`,
916
1938
  {
917
1939
  itemId: z.string().describe('Card id.'),
918
1940
  criterionId: z
919
1941
  .string()
920
- .describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
1942
+ .describe(
1943
+ 'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
1944
+ ),
921
1945
  note: z
922
1946
  .string()
923
1947
  .optional()
@@ -965,7 +1989,7 @@ FIRST TICK WINS. The server records the ticker, the column and the note only whe
965
1989
  // current column and the criterion's author, and a prediction made
966
1990
  // from a stale read is worse than an honest refusal to predict.
967
1991
  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`.',
1992
+ 'The grade is computed at read time from the exact QA dwell and authenticated credential, so this preview cannot tell you which you will get. A human may verify an authored or owned card. An agent must differ from the criterion author; in Staging QA2 it must also differ from the agent whose clean Staging QA1 receipt opened the pass.',
969
1993
  });
970
1994
  }
971
1995
 
@@ -976,7 +2000,7 @@ FIRST TICK WINS. The server records the ticker, the column and the note only whe
976
2000
  // with nothing to add must stay recordable, because the alternative is
977
2001
  // an agent inventing evidence to satisfy a required field.
978
2002
  body: args.note === undefined ? {} : { note: args.note },
979
- headers: buildMutationHeaders(args),
2003
+ headers: mutationHeadersForItem(args, args.itemId),
980
2004
  });
981
2005
  const criterion = data?.data;
982
2006
  return ok({
@@ -989,7 +2013,7 @@ FIRST TICK WINS. The server records the ticker, the column and the note only whe
989
2013
  ...(criterion?.grade === 'claimed'
990
2014
  ? {
991
2015
  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.',
2016
+ 'Recorded as `claimed`, not `verified`: this is the independence rule working rather than a failure. The tick was outside this exact QA dwell, its authenticated agent credential authored the criterion, or its Staging QA2 credential did not differ from the agent that passed Staging QA1. The evidence remains recorded, but it does not open this gate.',
993
2017
  }
994
2018
  : {}),
995
2019
  });
@@ -1009,7 +2033,9 @@ Anyone who can see the card may un-tick, including somebody undoing another pers
1009
2033
  itemId: z.string().describe('Card id.'),
1010
2034
  criterionId: z
1011
2035
  .string()
1012
- .describe('Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'),
2036
+ .describe(
2037
+ 'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
2038
+ ),
1013
2039
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to clear the tick.'),
1014
2040
  approved: z.boolean().optional().describe('Required true for a live change.'),
1015
2041
  reason: z
@@ -1046,12 +2072,78 @@ Anyone who can see the card may un-tick, including somebody undoing another pers
1046
2072
  });
1047
2073
  }
1048
2074
 
1049
- const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
2075
+ const data = await api('DELETE', path, {
2076
+ headers: mutationHeadersForItem(args, args.itemId),
2077
+ });
1050
2078
  const criterion = data?.data;
1051
2079
  return ok({ unsatisfied: true, criterion, grade: criterion?.grade });
1052
2080
  }
1053
2081
  );
1054
2082
 
2083
+ server.tool(
2084
+ 'record_work_item_criterion_engineering_proof',
2085
+ `Record a structured, non-media Engineering verified receipt for one criterion in its current QA dwell. Use this only when the criterion is explicitly routed engineering or both. It records the exact build, environment, outcome, named code/test/configuration/data/runtime references, and the narrowest honest staging smoke. It does not upload an image or video and must never be described as a visual or user-journey pass. QA2 requires a different authenticated credential from the passing QA1 engineering receipt.${GOVERNED_NOTE}`,
2086
+ {
2087
+ itemId: z.string().describe('Card id.'),
2088
+ criterionId: z.string().describe('Criterion id, not its display ordinal.'),
2089
+ environment: z.enum(['staging', 'production']),
2090
+ outcome: z.enum(['pass', 'fail']),
2091
+ summary: z.string().describe('Short result summary; no raw logs or transcript content.'),
2092
+ buildSha: z.string().regex(/^[0-9a-f]{40}$/).describe('Exact deployed 40-character commit SHA.'),
2093
+ stagingSmoke: z.string().describe('The narrowest honest staging/runtime smoke performed.'),
2094
+ proofs: z
2095
+ .array(
2096
+ z.object({
2097
+ proofType: z.enum(['code', 'test', 'configuration', 'data', 'runtime']),
2098
+ name: z.string(),
2099
+ resultReference: z.string(),
2100
+ })
2101
+ )
2102
+ .min(1),
2103
+ declaredReviewerModel: z
2104
+ .string()
2105
+ .max(120)
2106
+ .describe('Exact model used for this review, explicitly declared; never guessed.'),
2107
+ dryRun: z.boolean().optional().describe('Defaults to true.'),
2108
+ approved: z.boolean().optional().describe('Required true for a live receipt.'),
2109
+ reason: z.string().optional().describe('Required audit reason for a live receipt.'),
2110
+ idempotencyKey: z.string().optional().describe('Required for live writes; reuse on retry.'),
2111
+ },
2112
+ async (args) => {
2113
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
2114
+ if (unauditable) return ok({ error: true, message: unauditable });
2115
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria/${encodeURIComponent(args.criterionId)}/engineering-proof`;
2116
+ const preview = validateApiBridgeRequest({
2117
+ method: 'POST',
2118
+ path,
2119
+ dryRun: args.dryRun,
2120
+ approved: args.approved,
2121
+ reason: args.reason,
2122
+ idempotencyKey: args.idempotencyKey,
2123
+ grantedScope: getGrantedScope(),
2124
+ });
2125
+ const receipt = {
2126
+ environment: args.environment,
2127
+ outcome: args.outcome,
2128
+ summary: args.summary,
2129
+ buildSha: args.buildSha,
2130
+ stagingSmoke: args.stagingSmoke,
2131
+ proofs: args.proofs,
2132
+ declaredReviewerModel: args.declaredReviewerModel,
2133
+ idempotencyKey: args.idempotencyKey,
2134
+ };
2135
+ if (preview?.dryRun) return ok({ ...preview, wouldRecord: receipt });
2136
+ const data = await api('POST', path, {
2137
+ body: receipt,
2138
+ headers: {
2139
+ ...buildMutationHeaders(args),
2140
+ ...requiredWorkerLeaseHeaders(args.itemId),
2141
+ },
2142
+ });
2143
+ return ok({ recorded: true, engineeringProof: data?.data });
2144
+ }
2145
+ );
2146
+
1055
2147
  server.tool(
1056
2148
  'comment_on_work_item',
1057
2149
  `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.
@@ -1071,7 +2163,9 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
1071
2163
  reason: z
1072
2164
  .string()
1073
2165
  .optional()
1074
- .describe('Required for a live post: why you are commenting. This is the audit reason, NOT the comment — the comment is `body`.'),
2166
+ .describe(
2167
+ 'Required for a live post: why you are commenting. This is the audit reason, NOT the comment — the comment is `body`.'
2168
+ ),
1075
2169
  idempotencyKey: z
1076
2170
  .string()
1077
2171
  .optional()
@@ -1115,7 +2209,9 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
1115
2209
  itemId: z.string().describe('Card id.'),
1116
2210
  flagged: z
1117
2211
  .boolean()
1118
- .describe('true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'),
2212
+ .describe(
2213
+ 'true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'
2214
+ ),
1119
2215
  reason: z
1120
2216
  .string()
1121
2217
  .describe(
@@ -1126,7 +2222,9 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
1126
2222
  idempotencyKey: z
1127
2223
  .string()
1128
2224
  .optional()
1129
- .describe('Required for a live change. Reuse the SAME key on retry — the flag also appends a comment.'),
2225
+ .describe(
2226
+ 'Required for a live change. Reuse the SAME key on retry — the flag also appends a comment.'
2227
+ ),
1130
2228
  },
1131
2229
  async (args) => {
1132
2230
  if (!args.reason || !args.reason.trim()) {
@@ -1176,20 +2274,33 @@ export const WORK_BOARD_TOOL_NAMES = [
1176
2274
  'list_work_boards',
1177
2275
  'get_work_board',
1178
2276
  'get_work_item',
2277
+ 'get_work_item_worker_lease',
2278
+ 'get_work_item_worker_activity',
2279
+ 'claim_work_item_qa_pass',
2280
+ 'claim_next_work_item_qa_pass',
2281
+ 'heartbeat_work_item_qa_pass',
2282
+ 'release_work_item_qa_pass',
2283
+ 'record_work_item_qa_failure_and_release',
2284
+ 'requeue_work_item_qa_after_fix',
1179
2285
  'get_work_item_history',
1180
2286
  'get_work_item_delivery_evidence',
1181
2287
  'audit_work_hub',
1182
2288
  'list_work_item_acceptance_criteria',
2289
+ 'list_work_item_qa_evidence',
1183
2290
  'get_work_board_rollup',
1184
2291
  'list_work_board_rollups',
1185
2292
  'set_work_board_archived',
1186
2293
  'set_work_board_column_wip_limit',
2294
+ 'attach_work_item_qa_evidence',
2295
+ 'verify_work_item_qa_evidence_playback',
1187
2296
  'move_work_item',
2297
+ 'transfer_work_item_between_boards',
1188
2298
  'set_work_item_tag',
1189
2299
  'set_work_item_kind',
1190
2300
  'create_work_item',
1191
2301
  'add_work_item_acceptance_criterion',
1192
2302
  'satisfy_work_item_acceptance_criterion',
2303
+ 'record_work_item_criterion_engineering_proof',
1193
2304
  'unsatisfy_work_item_acceptance_criterion',
1194
2305
  'get_work_item_comments',
1195
2306
  'comment_on_work_item',