@adrata/adrata-mcp 1.0.19 → 1.0.40

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,4 @@
1
+ import { registerWorkItemFieldChanges } from './work-hub/field-changes.js';
1
2
  import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
2
3
  import { describeCriteriaQuality, inspectCriteria } from './work-hub/criteria-quality.js';
3
4
  import { createHash } from 'node:crypto';
@@ -184,6 +185,88 @@ export function describeMissingAcceptanceCriteria(criteria) {
184
185
  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.';
185
186
  }
186
187
 
188
+ /**
189
+ * Why a card with no product will not reach a QA lane, or `undefined` if it has
190
+ * one.
191
+ *
192
+ * `product` reads like an optional label and is not one. Every QA drain claims
193
+ * for ONE exact product, and the dispatcher selects on `i.product = $4` — SQL
194
+ * equality against NULL is not true, so an unclassified card is not ranked last
195
+ * or shown late, it is absent from every product queue at once while each of
196
+ * them reports itself empty. Measured 2026-09-08 on the Adrata board: 3 of 7
197
+ * cards in Staging QA2 carried no product, a product-scoped census returned 0
198
+ * workable, and those three were the only workable cards there.
199
+ *
200
+ * Advisory rather than a refusal, on the same argument `describeMissingBody`
201
+ * makes: a refused capture is how work stops reaching the board at all. The
202
+ * preview is where the author is already deciding, and this is the cheapest
203
+ * moment the omission is ever visible.
204
+ */
205
+ /**
206
+ * The population, the exclusions, and which exclusions are themselves suspect.
207
+ *
208
+ * Returned whether or not a card was claimed. Shaped as counts plus the cards
209
+ * behind them, because a total is the last thing to trust and a reader who
210
+ * doubts the summary needs the rows.
211
+ */
212
+ export function describeEmptyQueue(census) {
213
+ const finished = census?.skippedFinished ?? [];
214
+ const unrouted = census?.unroutedProduct ?? [];
215
+ return {
216
+ considered: census?.considered ?? 0,
217
+ truncated: census?.truncated === true,
218
+ skippedFinishedCount: finished.length,
219
+ skippedFinished: finished,
220
+ unroutedProductCount: unrouted.length,
221
+ unroutedProduct: unrouted,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * What a lane should DO about an empty answer.
227
+ *
228
+ * Ordered by what changes the lane's next action, most actionable first. The
229
+ * unrouted clause comes before the flat "nothing remains" sentence on purpose:
230
+ * a gate holding unclassified cards is NOT empty, and saying it is would be the
231
+ * same false all-clear in new words.
232
+ */
233
+ export function emptyQueueNote(product, qaGate, skippedBlocked, census) {
234
+ const parts = [];
235
+ const finished = census?.skippedFinished?.length ?? 0;
236
+ const unrouted = census?.unroutedProduct?.length ?? 0;
237
+ const considered = census?.considered ?? 0;
238
+
239
+ if (skippedBlocked?.length) {
240
+ parts.push(
241
+ `${skippedBlocked.length} card(s) in this gate are blocked by another card. Do not re-claim — read the blockers in skippedBlocked.`
242
+ );
243
+ }
244
+ if (finished) {
245
+ parts.push(
246
+ `${finished} card(s) were skipped because every acceptance criterion is already verified in this dwell. They are DONE, not pending — do not re-run a pass on them; they are listed in census.skippedFinished for whoever advances them.`
247
+ );
248
+ }
249
+ if (unrouted) {
250
+ parts.push(
251
+ `${unrouted} card(s) stand in ${qaGate} with NO product set. They are invisible to every product-scoped lane at once, including this one — this gate is unrouted, not empty. Set a product on them (they are listed in census.unroutedProduct) rather than concluding there is no work here.`
252
+ );
253
+ }
254
+ if (census?.truncated) {
255
+ parts.push(
256
+ 'The candidate window was full, so the ranking saw only its first page; this census describes that page, not the whole gate.'
257
+ );
258
+ }
259
+ if (!parts.length) {
260
+ return `No eligible unclaimed ${product} ${qaGate} pass remains on any visible board: ${considered} candidate(s) were considered and none was excluded. Pick another lane.`;
261
+ }
262
+ return `No workable ${product} ${qaGate} pass was handed out. ${considered} candidate(s) considered. ${parts.join(' ')}`;
263
+ }
264
+
265
+ export function describeMissingProduct(product) {
266
+ if (String(product ?? '').trim()) return undefined;
267
+ return 'This card would arrive with no `product`. QA lanes drain ONE exact product at a time and select on an exact match, so a card without one is invisible to every lane at once rather than merely deprioritised — and each of those queues reports itself empty. Set `product` unless you intend this card to be worked by hand.';
268
+ }
269
+
187
270
  /**
188
271
  * Make the created card's own criteria counter agree with the criteria returned
189
272
  * beside it.
@@ -267,6 +350,7 @@ export function registerWorkBoardTools(
267
350
  uploadQaEvidenceFile = uploadLocalQaEvidenceFile,
268
351
  }
269
352
  ) {
353
+ registerWorkItemFieldChanges(server, { z, api, ok });
270
354
  const GOVERNED_NOTE =
271
355
  ' 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).';
272
356
  // Capability material belongs to this MCP process, not to the model transcript.
@@ -274,7 +358,7 @@ export function registerWorkBoardTools(
274
358
  // public id and the handler adds the three private headers at the last hop.
275
359
  const workerLeaseCapabilities = new Map();
276
360
 
277
- function rememberWorkerLease(itemId, grant, claimIdempotencyKey) {
361
+ function rememberWorkerLease(itemId, grant, claimIdempotencyKey, workerLabel) {
278
362
  if (!grant?.leaseId || !grant?.leaseToken || !Number.isInteger(grant?.fencingToken)) {
279
363
  throw new Error('The API returned an incomplete worker-lease grant; no capability was stored.');
280
364
  }
@@ -283,18 +367,32 @@ export function registerWorkBoardTools(
283
367
  leaseToken: grant.leaseToken,
284
368
  fencingToken: grant.fencingToken,
285
369
  claimIdempotencyKey,
370
+ workerLabel,
286
371
  });
287
372
  const { leaseToken: _secret, fencingToken: _fence, qaRequirements, currentCriteria, ...lease } =
288
373
  grant;
289
374
  return { lease, qaRequirements, currentCriteria };
290
375
  }
291
376
 
292
- function conflictingHeldCard(itemId, claimIdempotencyKey) {
377
+ // ONE PASS PER WORKER, NOT PER PROCESS. A fleet runs many QA lanes through a
378
+ // single connector, so scanning every capability in the map made lane A's
379
+ // card refuse lane B — naming a card lane B had never seen and telling it to
380
+ // "transition or release" a peer's pass. That serialises the parallel QA
381
+ // drain the atomic claim exists to make safe, and it does so silently: the
382
+ // refusal reads exactly like ordinary contention for one card.
383
+ //
384
+ // The worker is what the guard was always about, and `workerLabel` is how a
385
+ // worker names itself. A capability stored without one cannot be attributed,
386
+ // so it still conflicts — an unattributable hold fails toward refusing, which
387
+ // costs a retry, rather than toward a double hold, which costs a lost lease.
388
+ function conflictingHeldCard(itemId, claimIdempotencyKey, workerLabel) {
293
389
  for (const [heldItemId, capability] of workerLeaseCapabilities) {
294
390
  const sameCard = itemId != null && heldItemId === itemId;
295
391
  const sameClaim =
296
392
  claimIdempotencyKey != null && capability.claimIdempotencyKey === claimIdempotencyKey;
297
- if (!sameCard && !sameClaim) return heldItemId;
393
+ const sameWorker =
394
+ capability.workerLabel == null || capability.workerLabel === workerLabel;
395
+ if (!sameCard && !sameClaim && sameWorker) return heldItemId;
298
396
  }
299
397
  return null;
300
398
  }
@@ -511,7 +609,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
511
609
  idempotencyKey: z.string().optional(),
512
610
  },
513
611
  async (args) => {
514
- const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey);
612
+ const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey, args.workerLabel);
515
613
  if (heldItemId) {
516
614
  return ok({
517
615
  error: true,
@@ -547,7 +645,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
547
645
  body,
548
646
  headers: buildMutationHeaders(args),
549
647
  });
550
- const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey);
648
+ const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey, args.workerLabel);
551
649
  return ok({ claimed: true, ...visible });
552
650
  }
553
651
  );
@@ -567,7 +665,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
567
665
  idempotencyKey: z.string().optional(),
568
666
  },
569
667
  async (args) => {
570
- const heldItemId = conflictingHeldCard(null, args.idempotencyKey);
668
+ const heldItemId = conflictingHeldCard(null, args.idempotencyKey, args.workerLabel);
571
669
  if (heldItemId) {
572
670
  return ok({
573
671
  error: true,
@@ -611,24 +709,42 @@ Start here for "what should I work on". With includeUnassigned it also returns t
611
709
  const outcome = data?.data ?? {};
612
710
  const selected = outcome.claimed;
613
711
  const skippedBlocked = outcome.skippedBlocked ?? [];
712
+ const census = outcome.census ?? {};
614
713
  if (!selected) {
615
- // The empty-handed case is two different situations and a lane acts on
616
- // them differently: an empty gate means pick another lane, while a
617
- // fully-blocked one means go and look at the blockers.
714
+ // The empty-handed case is FOUR different situations and a lane acts on
715
+ // them differently: an empty gate means pick another lane; a
716
+ // fully-blocked one means go and look at the blockers; a gate whose
717
+ // work is all verified means the drain is finished; and cards with no
718
+ // product mean the queue is not empty at all, it is unrouted.
719
+ //
720
+ // So this answer shows its working rather than asserting a conclusion.
721
+ // A signal that can tell a worker to STOP has to be contradictable —
722
+ // this factory has already believed "there is genuinely nothing to pick
723
+ // up" once, from a count that could not be wrong out loud, while 211 of
724
+ // 212 claims sat on cards that had left the column.
618
725
  return ok({
619
726
  claimed: false,
620
727
  qaGate: args.qaGate,
621
728
  product: args.product,
622
729
  skippedBlocked,
623
- note: skippedBlocked.length
624
- ? `No workable ${args.product} ${args.qaGate} pass remains: ${skippedBlocked.length} card(s) in this gate are blocked by another card. Do not re-claim — look at the blockers listed in skippedBlocked.`
625
- : `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.`,
730
+ census: describeEmptyQueue(census),
731
+ note: emptyQueueNote(args.product, args.qaGate, skippedBlocked, census),
626
732
  });
627
733
  }
628
- const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
734
+ const visible = rememberWorkerLease(
735
+ selected.workItemId,
736
+ selected.grant,
737
+ args.idempotencyKey,
738
+ args.workerLabel
739
+ );
629
740
  return ok({
630
741
  claimed: true,
631
742
  skippedBlocked,
743
+ // Reported on the winning path as well. Three finished cards and two
744
+ // unrouted ones in this gate are a board finding whoever is reading,
745
+ // and a lane that only sees them on the empty pass learns about them
746
+ // last -- by which point it has no reason to look.
747
+ census: describeEmptyQueue(census),
632
748
  workItemId: selected.workItemId,
633
749
  boardId: selected.boardId,
634
750
  boardName: selected.boardName,
@@ -727,10 +843,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
727
843
 
728
844
  server.tool(
729
845
  'record_work_item_qa_failure_and_release',
730
- `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}`,
846
+ `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 for the next worker to diagnose, repair, deploy and independently retest. Ordinary failure and dependency handoffs remain claimable. Set blocksClaim:true only for an explicit prerequisite no agent can satisfy, such as owner consent or a vendor credential. Both paths release the lease only after the failure is durably recorded; a claimable handoff is not a QA pass. This is one governed recovery recipe, not a new bug card and not a QA bounce.${GOVERNED_NOTE}`,
731
847
  {
732
848
  itemId: z.string().describe('Card currently claimed by this MCP worker.'),
733
849
  disposition: z.enum(['failed_criterion', 'blocked_dependency']),
850
+ blocksClaim: z.boolean().optional().default(false).describe('Stop pickup only for an explicit prerequisite no agent can satisfy. Ordinary failed work remains claimable for repair and retest.'),
734
851
  criterionId: z.string().optional().describe('Required for failed_criterion; the exact acceptance criterion contradicted.'),
735
852
  details: z.string().describe('Concrete observed failure or blocker and the recovery needed. Stored on the card.'),
736
853
  dryRun: z.boolean().optional().default(true),
@@ -748,7 +865,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
748
865
  const preview = validateApiBridgeRequest({
749
866
  method: 'POST',
750
867
  path: `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`,
751
- body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details },
868
+ body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details, blocksClaim: args.blocksClaim === true },
752
869
  dryRun: args.dryRun, approved: args.approved, reason: args.reason,
753
870
  idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
754
871
  });
@@ -760,10 +877,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
760
877
  disposition: args.disposition,
761
878
  criterionId: args.criterionId ?? null,
762
879
  details: args.details,
880
+ blocksClaim: args.blocksClaim === true,
763
881
  },
764
- note: args.disposition === 'failed_criterion'
765
- ? 'The card will stay in its QA column, be flagged and un-ticked, then release the pass while waiting for a fixed deployed build.'
766
- : 'The card will be flagged in place and the pass handed off.',
882
+ note: args.blocksClaim === true
883
+ ? 'The card will be flagged in place and withheld from pickup until the explicit prerequisite is resolved.'
884
+ : 'The card will stay in place, retain its failure reason, and remain claimable for repair and retest after this pass is released.',
767
885
  });
768
886
  }
769
887
  const leaseHeaders = workerLeaseHeaders(args.itemId);
@@ -776,11 +894,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
776
894
  const flagReason = args.disposition === 'failed_criterion'
777
895
  ? `QA criterion failure (${args.criterionId}): ${args.details.trim()} Waiting for a fixed deployed build before requeue.`
778
896
  : args.details.trim();
779
- // Flag first. If this composite is interrupted after any following step,
780
- // claim-next still cannot put another worker into a hot loop on the same
781
- // broken build. Every child is endpoint-idempotent, so a retry continues.
782
- await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
783
- body: { flagged: true, reason: flagReason, idempotencyKey: recordKey },
897
+ // Preserve the contradiction before un-ticking or releasing. Ordinary
898
+ // failures remain claimable so another worker can repair them; only an
899
+ // explicitly requested hold blocks pickup. Child writes are idempotent.
900
+ const recordedFlag = await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
901
+ body: { flagged: true, reason: flagReason, blocksClaim: args.blocksClaim === true, idempotencyKey: recordKey },
784
902
  headers: mutationHeadersForItem({ ...args, idempotencyKey: recordKey }, args.itemId),
785
903
  });
786
904
  if (args.disposition === 'failed_criterion') {
@@ -798,8 +916,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
798
916
  recorded: true,
799
917
  disposition: args.disposition,
800
918
  requeued: false,
801
- blocked: true,
919
+ blocked: args.blocksClaim === true,
920
+ claimable: args.blocksClaim !== true,
802
921
  waitingForFix: args.disposition === 'failed_criterion',
922
+ flagGeneration: recordedFlag?.data?.recordedFlagGeneration ?? null,
803
923
  releaseKind,
804
924
  ...release,
805
925
  });
@@ -808,9 +928,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
808
928
 
809
929
  server.tool(
810
930
  'requeue_work_item_qa_after_fix',
811
- `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}`,
931
+ `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 or certify the card; it clears the resolved hold or failure handoff before a fresh independent 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}`,
812
932
  {
813
933
  itemId: z.string().describe('Flagged QA card left in place by record_work_item_qa_failure_and_release.'),
934
+ repairedFlagGeneration: z.string().min(1).describe('The flag.generation captured for the failure actually repaired. Never substitute a newer flag generation without resolving that failure.'),
814
935
  failedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the build that failed.'),
815
936
  deployedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the newer deployed descendant build.'),
816
937
  details: z.string().describe('How ancestry and deployment were verified, including the fix PR or deployment reference.'),
@@ -830,6 +951,9 @@ Start here for "what should I work on". With includeUnassigned it also returns t
830
951
  if (!args.details?.trim()) {
831
952
  return ok({ error: true, message: 'details must name the ancestry check and deployment/fix reference.' });
832
953
  }
954
+ if (!args.repairedFlagGeneration?.trim()) {
955
+ return ok({ error: true, message: 'repairedFlagGeneration is required. Read and resolve the current failure before retrying; never substitute a newer generation for an older repair.' });
956
+ }
833
957
  const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
834
958
  const clearReason = `QA requeue: deployed ${args.deployedBuildSha} after failed ${args.failedBuildSha}. ${args.details.trim()}`;
835
959
  const preview = validateApiBridgeRequest({
@@ -837,13 +961,13 @@ Start here for "what should I work on". With includeUnassigned it also returns t
837
961
  reason: args.reason, idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
838
962
  });
839
963
  if (preview.dryRun) {
840
- return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha } });
964
+ return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha, repairedFlagGeneration: args.repairedFlagGeneration } });
841
965
  }
842
966
  const data = await api('POST', path, {
843
- body: { flagged: false, reason: clearReason, idempotencyKey: args.idempotencyKey },
967
+ body: { flagged: false, reason: clearReason, expectedFlagGeneration: args.repairedFlagGeneration, idempotencyKey: args.idempotencyKey },
844
968
  headers: buildMutationHeaders(args),
845
969
  });
846
- return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha });
970
+ return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha, repairedFlagGeneration: args.repairedFlagGeneration });
847
971
  }
848
972
  );
849
973
 
@@ -1253,6 +1377,41 @@ Start here for "what should I work on". With includeUnassigned it also returns t
1253
1377
  }
1254
1378
  );
1255
1379
 
1380
+ server.tool(
1381
+ 'preview_work_item_acceptance_reaffirmation',
1382
+ 'Read the exact current criterion definitions and preserved independent QA evidence for a legacy human-accepted Ready to Ship card. Returns a content digest for a separate explicit owner decision. Original acceptance scope remains unknown; this never repairs missing QA.',
1383
+ { itemId: z.string() },
1384
+ async (args) => {
1385
+ const data = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-reaffirmation`);
1386
+ return ok(data?.data);
1387
+ }
1388
+ );
1389
+
1390
+ server.tool(
1391
+ 'reaffirm_work_item_current_acceptance',
1392
+ `Record a separate owner approval of the exact CURRENT scope and existing QA evidence from preview_work_item_acceptance_reaffirmation. Only the authenticated delegated OAuth owner can do this. The original legacy acceptance receipt stays unchanged and its original scope stays unknown. This neither verifies criteria nor fills missing QA receipts. Use only on the owner's explicit instruction after reviewing the preview and limitation.${GOVERNED_NOTE}`,
1393
+ {
1394
+ itemId: z.string(), historyId: z.string(), originalReceiptId: z.string(),
1395
+ contentSha256: z.string().regex(/^[0-9a-f]{64}$/),
1396
+ acknowledgeOriginalScopeUnknown: z.literal(true),
1397
+ receivingBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1398
+ expiresAt: z.number().int(),
1399
+ dryRun: z.boolean().optional(), approved: z.boolean().optional(),
1400
+ reason: z.string().optional(), idempotencyKey: z.string().optional(),
1401
+ },
1402
+ async (args) => {
1403
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-reaffirmation`;
1404
+ const preview = validateApiBridgeRequest({ method: 'POST', path, dryRun: args.dryRun,
1405
+ approved: args.approved, reason: args.reason, idempotencyKey: args.idempotencyKey,
1406
+ grantedScope: getGrantedScope() });
1407
+ const { dryRun: _dryRun, ...body } = args;
1408
+ if (preview?.dryRun) return ok({ ...preview, wouldReaffirm: body,
1409
+ limitation: 'Original legacy acceptance scope remains unknown. This is a separate current acceptance.' });
1410
+ const data = await api('POST', path, { body, headers: buildMutationHeaders(args) });
1411
+ return ok({ reaffirmed: true, receipt: data?.data });
1412
+ }
1413
+ );
1414
+
1256
1415
  server.tool(
1257
1416
  'move_work_item',
1258
1417
  `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.
@@ -1285,6 +1444,20 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1285
1444
  ),
1286
1445
  dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
1287
1446
  approved: z.boolean().optional().describe('Required true for a live move.'),
1447
+ presenceAttestation: z.string().optional().describe('Single-use human-minted presence token. This remains separate from owner delegation.'),
1448
+ ownerApproval: z.object({
1449
+ approved: z.literal(true),
1450
+ itemId: z.string(),
1451
+ toColumnId: z.string(),
1452
+ idempotencyKey: z.string(),
1453
+ acceptanceReceiptId: z.string(),
1454
+ reaffirmationReceiptId: z.string().optional().describe('Separate current acceptance receipt for legacy original scope; never a backfilled original receipt.'),
1455
+ reviewedBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1456
+ receivingBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1457
+ expiresAt: z.number().int(),
1458
+ }).strict().optional().describe(
1459
+ 'Explicit owner-delegated Production request. Use only after the owner approves this exact card, destination, accepted QA2 closing receipt and reviewed delivery build. Expires within five minutes. Server requires the authenticated owner’s delegated OAuth credential and preserves species agent. This is delegated authority, not cryptographic proof of human presence. receivingBuildSha is the live API build; reviewedBuildSha is the separately reviewed component build and must match receipt.commitSha.'
1460
+ ),
1288
1461
  reason: z
1289
1462
  .string()
1290
1463
  .optional()
@@ -1407,6 +1580,8 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1407
1580
  claim: args.claim === true,
1408
1581
  force: args.force === true,
1409
1582
  receipt: args.receipt !== undefined,
1583
+ ownerApproval: args.ownerApproval,
1584
+ presenceAttestation: args.presenceAttestation !== undefined,
1410
1585
  },
1411
1586
  wip: projectColumnWip({
1412
1587
  limit: target.wipLimit,
@@ -1474,6 +1649,8 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1474
1649
  acknowledgeUnmetCriteria:
1475
1650
  args.acknowledgeUnmetCriteria === true ? true : undefined,
1476
1651
  receipt: args.receipt,
1652
+ ownerApproval: args.ownerApproval,
1653
+ presenceAttestation: args.presenceAttestation,
1477
1654
  },
1478
1655
  headers: mutationHeadersForItem(args, args.itemId),
1479
1656
  });
@@ -1484,11 +1661,25 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1484
1661
  return ok({
1485
1662
  moved: true,
1486
1663
  claimed: args.claim === true,
1487
- // Echoed as a BOOLEAN, never as the receipt's contents. What the agent
1488
- // needs to know is whether the evidence was accepted; repeating a
1664
+ // Read back from the SERVER, never `args.receipt !== undefined`. That
1665
+ // echo reported the request rather than the answer, and the two
1666
+ // disagree in all three directions: `receipt: {}` points at nothing and
1667
+ // the server grades it `server_recorded`; offering no receipt at all
1668
+ // still writes a `server_recorded` row, so `false` was wrong; and a
1669
+ // REORDER writes no receipt whatsoever, because the API guards the
1670
+ // write with `if changed_column` — yet a receipt passed alongside one
1671
+ // still reported `true`.
1672
+ //
1673
+ // Still a BOOLEAN and still never the receipt's contents: repeating a
1489
1674
  // session pointer back into a conversation transcript is the leak this
1490
1675
  // whole field is shaped to avoid.
1491
- receiptRecorded: args.receipt !== undefined,
1676
+ receiptRecorded: data?.receiptEvidenceSource != null,
1677
+ // Whether that receipt points at anything, which is a different
1678
+ // question from whether one exists — and it is the one the gaps query
1679
+ // asks. `attested` only when the caller's references actually carried a
1680
+ // value; `server_recorded` when the server wrote the row on its own
1681
+ // observation; null when no receipt was written for this call.
1682
+ receiptEvidenceSource: data?.receiptEvidenceSource ?? null,
1492
1683
  // Read back from the server rather than echoed from the request, because
1493
1684
  // the two halves of a claim are decided server-side and an agent that
1494
1685
  // assumed "claimed" meant "mine now" would report a QA pick-up as having
@@ -1605,7 +1796,12 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1605
1796
  return ok({
1606
1797
  transferred: true,
1607
1798
  identityPreserved: true,
1608
- receiptRecorded: args.receipt !== undefined,
1799
+ // The server's verdict, not an echo of the request. See the move tool
1800
+ // above for why those differ. A transfer always changes board AND
1801
+ // column, so unlike a move it always writes a receipt — but whether
1802
+ // that receipt points at anything is still the server's call.
1803
+ receiptRecorded: data?.receiptEvidenceSource != null,
1804
+ receiptEvidenceSource: data?.receiptEvidenceSource ?? null,
1609
1805
  item: data?.data,
1610
1806
  });
1611
1807
  }
@@ -1941,6 +2137,9 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1941
2137
  // capture is how work stops reaching the board at all.
1942
2138
  note: describeMissingBody(args.body),
1943
2139
  readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
2140
+ // A missing product is not a missing label: it is the difference
2141
+ // between a card a QA lane can be handed and one no lane can see.
2142
+ routingNote: describeMissingProduct(args.product),
1944
2143
  // Advisory, and only ever present when it has something to say. The
1945
2144
  // preview is where an author is already deciding whether to approve,
1946
2145
  // and a criterion that cannot fail is cheapest to fix before the card
@@ -2564,25 +2763,34 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
2564
2763
 
2565
2764
  server.tool(
2566
2765
  'flag_work_item',
2567
- `Flag a card as having a problem WITH THE CARD — unclear scope, no repro, a blocked dependency, a question waiting on an answer — or clear a flag once it is resolved. A flagged card is drawn differently on the board so nobody picks it up by mistake.
2766
+ `Flag unresolved work for the next repair and retest, or clear a flag once its reason is resolved. Ordinary failures remain visible and claimable.
2767
+
2768
+ A FLAG IS NOT A TAG. The tag says how urgent the work is; a flag records an unresolved problem. If a card is merely important, use set_work_item_tag.
2568
2769
 
2569
- A FLAG IS NOT A TAG. The tag says how urgent the WORK is; a flag says the CARD is not fit to be worked. If a card is merely important, use set_work_item_tag. If you cannot start because something is missing, flag it.
2770
+ THE REASON IS REQUIRED IN BOTH DIRECTIONS, and it is not the audit reason — it is the text read on the card. Raising says what failed, what you tried, and what remains to check; clearing says what resolved it. Agents may clear a resolved flag without a human handoff. Both directions append a comment, preserving the original failure and its resolution.
2570
2771
 
2571
- THE REASON IS REQUIRED IN BOTH DIRECTIONS, and it is not the audit reason it is the text a human reads on the card. Raising says what is wrong; clearing says what resolved it. Anyone may clear a flag, which only works because both directions are recorded as a comment on the card.
2772
+ A FLAG HANDS THE CARD ON; IT DOES NOT HIDE IT. A failed test or an unsuccessful first repair is not an external hold. Leave it claimable so the next agent can investigate, repair, and independently retest. Clearing a flag does not certify acceptance criteria or replace either QA gate.
2572
2773
 
2573
- This is what the ship-the-card skill means by "an unworkable card is a triage problem, not a coding problem": flag it, say the one question that would unblock it, and stop.${GOVERNED_NOTE}`,
2774
+ PASS blocksClaim:true ONLY FOR AN EXPLICIT PREREQUISITE AGENTS CANNOT SATISFY owner authorization, vendor consent, or a product decision. The hold remains until that prerequisite is satisfied and the flag is cleared with a resolution reason. Do not turn an ordinary test failure into an owner decision.${GOVERNED_NOTE}`,
2574
2775
  {
2575
2776
  itemId: z.string().describe('Card id.'),
2576
2777
  flagged: z
2577
2778
  .boolean()
2578
2779
  .describe(
2579
- 'true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'
2780
+ 'true raises the flag; false clears it. Raising an already-flagged card replaces the reason, and that is an EDIT rather than a second flag: the card carries a lifetime count of how many times it has been flagged (drawn on the board as a stack of one, two, three, or three-plus pennants), and only an unflagged card becoming flagged increments it. So narrowing a live flag costs nothing, and a card showing four pennants really has been through the loop four times.'
2580
2781
  ),
2581
2782
  reason: z
2582
2783
  .string()
2583
2784
  .describe(
2584
2785
  'REQUIRED. Raising: what is wrong with the card. Clearing: what resolved it. Shown on the card and appended to its comment thread — write it for the person who will read it, not for a log.'
2585
2786
  ),
2787
+ expectedFlagGeneration: z.string().optional().describe('Required when clearing: flag.generation captured for the failure actually resolved. A stale generation is refused; inspect and resolve the newer flag before another clear.'),
2788
+ blocksClaim: z
2789
+ .boolean()
2790
+ .optional()
2791
+ .describe(
2792
+ 'Whether this flag prevents claims. OMITTED MEANS FALSE: ordinary failed work remains claimable for repair and independent retest. Pass true only for an explicit prerequisite agents cannot satisfy, such as owner authorization or vendor consent. Clear after that prerequisite is satisfied, recording the resolution. Ignored when clearing.'
2793
+ ),
2586
2794
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to change the flag.'),
2587
2795
  approved: z.boolean().optional().describe('Required true for a live change.'),
2588
2796
  idempotencyKey: z
@@ -2597,10 +2805,13 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2597
2805
  return ok({
2598
2806
  error: true,
2599
2807
  message:
2600
- 'reason is required to flag or to clear. A flag with nothing written on it stops work without saying how to restart it, and a flag anyone can silently clear is a flag nobody trusts.',
2808
+ 'reason is required to flag or to clear. Record the unresolved problem when raising and what resolved it when clearing; both become card history.',
2601
2809
  });
2602
2810
  }
2603
2811
 
2812
+ if (!args.flagged && !args.expectedFlagGeneration?.trim()) {
2813
+ return ok({ error: true, message: 'Clearing requires expectedFlagGeneration from the failure actually resolved. Read and resolve the current flag before retrying.' });
2814
+ }
2604
2815
  const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
2605
2816
  const preview = validateApiBridgeRequest({
2606
2817
  method: 'POST',
@@ -2617,25 +2828,220 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2617
2828
  if (preview?.dryRun) {
2618
2829
  return ok({
2619
2830
  ...preview,
2620
- wouldFlag: { itemId: args.itemId, flagged: args.flagged, reason: args.reason },
2831
+ wouldFlag: {
2832
+ itemId: args.itemId,
2833
+ flagged: args.flagged,
2834
+ expectedFlagGeneration: args.expectedFlagGeneration,
2835
+ reason: args.reason,
2836
+ // Shown in the rehearsal because it decides whether the card
2837
+ // disappears from every queue, which is the consequence a reader
2838
+ // most needs to see before approving the write.
2839
+ blocksClaim: args.flagged ? (args.blocksClaim ?? false) : true,
2840
+ },
2621
2841
  });
2622
2842
  }
2623
2843
 
2624
2844
  const data = await api('POST', path, {
2625
2845
  body: {
2626
2846
  flagged: args.flagged,
2847
+ expectedFlagGeneration: args.expectedFlagGeneration,
2627
2848
  reason: args.reason,
2628
2849
  idempotencyKey: args.idempotencyKey,
2850
+ // Match the preview explicitly: ordinary failures remain claimable.
2851
+ // Clearing ignores this field on the server.
2852
+ blocksClaim: args.flagged ? (args.blocksClaim ?? false) : true,
2629
2853
  },
2630
2854
  headers: buildMutationHeaders({ ...args, reason: args.reason }),
2631
2855
  });
2632
2856
  return ok({ flagged: args.flagged, item: data?.data });
2633
2857
  }
2634
2858
  );
2859
+
2860
+ // ── Retiring a card ──────────────────────────────────────────────────────
2861
+ //
2862
+ // Three tools rather than one with a `kind` switch, and the split is
2863
+ // deliberate. `delete_` is matched by tool-annotations' DESTRUCTIVE_PATTERN,
2864
+ // so naming the destructive branch as its own tool is what gets a host to
2865
+ // confirmation-gate it. A single `archive_work_item({ kind: 'deleted' })`
2866
+ // would hide the destructive path behind a name classified as safe.
2867
+
2868
+ const RETIRE_NOTE = `WHAT THIS IS NOT. Do not use move_work_item to Backlog or Deep backlog to retire a card. Those are PARKING: work still intended, later — it stays in every count, which is correct for parked work and wrong for work nobody will ever do. Retiring is the other thing, and it is the only one that takes the card out of the numbers.
2869
+
2870
+ NOTHING IS DESTROYED. The card keeps its board, its column, its column history, its acceptance criteria and its QA evidence. It stays readable by get_work_item and it comes back with unarchive_work_item. The exclusion is a read filter, never a row removal — QA receipts bind to those column dwells and deleting them would leave every receipt on the card unexplainable.${GOVERNED_NOTE}`;
2871
+
2872
+ server.tool(
2873
+ 'archive_work_item',
2874
+ `Retire a card: done with, wrong, obsolete, or superseded. It leaves every active count — the board's own size, the roll-up, the personal queue, velocity, capacity, the roadmap's card counts, the criterion standings — and stops being offered to a QA lane.
2875
+
2876
+ This is the COMMON case and it is recoverable. Reach for it whenever a card should stop being counted; if you find yourself wanting to hide a card by moving it somewhere nobody looks, this is the tool you actually wanted.
2877
+
2878
+ ${RETIRE_NOTE}`,
2879
+ {
2880
+ itemId: z.string().describe('Card id.'),
2881
+ reason: z
2882
+ .string()
2883
+ .describe(
2884
+ 'REQUIRED. Why this work will not be done. Shown on the card and appended to its comment thread — write it for the person who finds the card in six months and wonders what happened to it. "obsolete" is not an answer; "superseded by <card>, the API changed under it" is.'
2885
+ ),
2886
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to retire the card.'),
2887
+ approved: z.boolean().optional().describe('Required true for a live change.'),
2888
+ idempotencyKey: z
2889
+ .string()
2890
+ .optional()
2891
+ .describe(
2892
+ 'Required for a live change. Reuse the SAME key on retry — retiring also appends a comment.'
2893
+ ),
2894
+ },
2895
+ async (args) => {
2896
+ if (!args.reason || !args.reason.trim()) {
2897
+ return ok({
2898
+ error: true,
2899
+ message:
2900
+ 'reason is required. A card that disappears from the board with nothing written on it is exactly what archiving exists to replace.',
2901
+ });
2902
+ }
2903
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/archive`;
2904
+ const preview = validateApiBridgeRequest({
2905
+ method: 'POST',
2906
+ path,
2907
+ dryRun: args.dryRun,
2908
+ approved: args.approved,
2909
+ reason: args.reason,
2910
+ idempotencyKey: args.idempotencyKey,
2911
+ grantedScope: getGrantedScope(),
2912
+ });
2913
+ if (preview?.dryRun) {
2914
+ return ok({
2915
+ ...preview,
2916
+ wouldArchive: { itemId: args.itemId, kind: 'archived', reason: args.reason },
2917
+ });
2918
+ }
2919
+ const data = await api('POST', path, {
2920
+ body: { reason: args.reason, kind: 'archived', idempotencyKey: args.idempotencyKey },
2921
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
2922
+ });
2923
+ return ok({ archived: true, kind: 'archived', item: data?.data });
2924
+ }
2925
+ );
2926
+
2927
+ server.tool(
2928
+ 'delete_work_item',
2929
+ `Retire a card as DELETED rather than archived. Rare, and deliberate.
2930
+
2931
+ PREFER archive_work_item. The two do the same thing to the numbers; the difference is what the record says about the decision, and "archived" is the honest word for almost every card. Use this only when the card should never have existed — a duplicate, a mis-filed capture, something created by an integration by mistake.
2932
+
2933
+ IT IS STILL A SOFT DELETE. The row survives and unarchive_work_item brings it back. That is not a loophole: this board is the audit record behind a two-gate QA process, and a hard delete would cascade away the column history that every QA receipt binds to.
2934
+
2935
+ YOU MUST ECHO THE CARD'S EXACT TITLE in confirmTitle. Read the card first with get_work_item; do not guess it from the id or from a list you are half sure of. The echo is the whole safeguard — it is what makes this something you cannot do by accident inside a loop.
2936
+
2937
+ ${RETIRE_NOTE}`,
2938
+ {
2939
+ itemId: z.string().describe('Card id.'),
2940
+ confirmTitle: z
2941
+ .string()
2942
+ .describe(
2943
+ "REQUIRED. The card's title, exactly as get_work_item returns it. A mismatch is refused and nothing is written."
2944
+ ),
2945
+ reason: z
2946
+ .string()
2947
+ .describe('REQUIRED. Why this card should never have existed.'),
2948
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to delete the card.'),
2949
+ approved: z.boolean().optional().describe('Required true for a live change.'),
2950
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
2951
+ },
2952
+ async (args) => {
2953
+ if (!args.reason || !args.reason.trim()) {
2954
+ return ok({ error: true, message: 'reason is required to delete a card.' });
2955
+ }
2956
+ if (!args.confirmTitle || !args.confirmTitle.trim()) {
2957
+ return ok({
2958
+ error: true,
2959
+ message:
2960
+ "confirmTitle is required and must match the card's exact title. Read the card with get_work_item first — the echo is the only thing standing between a deliberate delete and an accidental one.",
2961
+ });
2962
+ }
2963
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/archive`;
2964
+ const preview = validateApiBridgeRequest({
2965
+ method: 'POST',
2966
+ path,
2967
+ dryRun: args.dryRun,
2968
+ approved: args.approved,
2969
+ reason: args.reason,
2970
+ idempotencyKey: args.idempotencyKey,
2971
+ grantedScope: getGrantedScope(),
2972
+ });
2973
+ if (preview?.dryRun) {
2974
+ return ok({
2975
+ ...preview,
2976
+ wouldDelete: {
2977
+ itemId: args.itemId,
2978
+ kind: 'deleted',
2979
+ reason: args.reason,
2980
+ confirmTitle: args.confirmTitle,
2981
+ },
2982
+ });
2983
+ }
2984
+ const data = await api('POST', path, {
2985
+ body: {
2986
+ reason: args.reason,
2987
+ kind: 'deleted',
2988
+ confirmTitle: args.confirmTitle,
2989
+ idempotencyKey: args.idempotencyKey,
2990
+ },
2991
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
2992
+ });
2993
+ return ok({ archived: true, kind: 'deleted', item: data?.data });
2994
+ }
2995
+ );
2996
+
2997
+ server.tool(
2998
+ 'unarchive_work_item',
2999
+ `Bring a retired card back into circulation, whichever verb retired it — archived or deleted. It rejoins every count exactly where it left, in the same column, with its whole history intact.
3000
+
3001
+ As easy as retiring it was, on purpose: a retirement nobody could undo would make a mistaken one permanent, and that is the failure the confirmation on delete_work_item exists to prevent in the first place.
3002
+
3003
+ THE REASON IS REQUIRED HERE TOO — what changed. Anyone can bring a card back, which only works if bringing it back leaves a record.${GOVERNED_NOTE}`,
3004
+ {
3005
+ itemId: z.string().describe('Card id. Find retired cards with the board archived-items read.'),
3006
+ reason: z.string().describe('REQUIRED. What changed — why this work is wanted again.'),
3007
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to restore the card.'),
3008
+ approved: z.boolean().optional().describe('Required true for a live change.'),
3009
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
3010
+ },
3011
+ async (args) => {
3012
+ if (!args.reason || !args.reason.trim()) {
3013
+ return ok({
3014
+ error: true,
3015
+ message:
3016
+ 'reason is required to restore a card. Anyone can bring a card back, which only works if bringing it back leaves a record.',
3017
+ });
3018
+ }
3019
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/unarchive`;
3020
+ const preview = validateApiBridgeRequest({
3021
+ method: 'POST',
3022
+ path,
3023
+ dryRun: args.dryRun,
3024
+ approved: args.approved,
3025
+ reason: args.reason,
3026
+ idempotencyKey: args.idempotencyKey,
3027
+ grantedScope: getGrantedScope(),
3028
+ });
3029
+ if (preview?.dryRun) {
3030
+ return ok({ ...preview, wouldUnarchive: { itemId: args.itemId, reason: args.reason } });
3031
+ }
3032
+ const data = await api('POST', path, {
3033
+ body: { reason: args.reason, idempotencyKey: args.idempotencyKey },
3034
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
3035
+ });
3036
+ return ok({ archived: false, item: data?.data });
3037
+ }
3038
+ );
2635
3039
  }
2636
3040
 
2637
3041
  /** Tool names registered here, for the tier map and the toolset manifest. */
2638
3042
  export const WORK_BOARD_TOOL_NAMES = [
3043
+ 'preview_work_item_acceptance_reaffirmation',
3044
+ 'reaffirm_work_item_current_acceptance',
2639
3045
  'list_my_work_items',
2640
3046
  'list_work_boards',
2641
3047
  'get_work_board',
@@ -2649,6 +3055,7 @@ export const WORK_BOARD_TOOL_NAMES = [
2649
3055
  'record_work_item_qa_failure_and_release',
2650
3056
  'requeue_work_item_qa_after_fix',
2651
3057
  'get_work_item_history',
3058
+ 'get_work_item_field_changes',
2652
3059
  'get_work_item_delivery_evidence',
2653
3060
  'audit_work_hub',
2654
3061
  'list_work_item_acceptance_criteria',
@@ -2674,6 +3081,9 @@ export const WORK_BOARD_TOOL_NAMES = [
2674
3081
  'get_work_item_comments',
2675
3082
  'comment_on_work_item',
2676
3083
  'flag_work_item',
3084
+ 'archive_work_item',
3085
+ 'delete_work_item',
3086
+ 'unarchive_work_item',
2677
3087
  'block_work_item',
2678
3088
  'unblock_work_item',
2679
3089
  ];