@adrata/adrata-mcp 1.0.8 → 1.0.30

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,5 @@
1
1
  import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
2
+ import { describeCriteriaQuality, inspectCriteria } from './work-hub/criteria-quality.js';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { readFile, stat } from 'node:fs/promises';
4
5
  import { basename, resolve } from 'node:path';
@@ -273,7 +274,7 @@ export function registerWorkBoardTools(
273
274
  // public id and the handler adds the three private headers at the last hop.
274
275
  const workerLeaseCapabilities = new Map();
275
276
 
276
- function rememberWorkerLease(itemId, grant, claimIdempotencyKey) {
277
+ function rememberWorkerLease(itemId, grant, claimIdempotencyKey, workerLabel) {
277
278
  if (!grant?.leaseId || !grant?.leaseToken || !Number.isInteger(grant?.fencingToken)) {
278
279
  throw new Error('The API returned an incomplete worker-lease grant; no capability was stored.');
279
280
  }
@@ -282,18 +283,32 @@ export function registerWorkBoardTools(
282
283
  leaseToken: grant.leaseToken,
283
284
  fencingToken: grant.fencingToken,
284
285
  claimIdempotencyKey,
286
+ workerLabel,
285
287
  });
286
288
  const { leaseToken: _secret, fencingToken: _fence, qaRequirements, currentCriteria, ...lease } =
287
289
  grant;
288
290
  return { lease, qaRequirements, currentCriteria };
289
291
  }
290
292
 
291
- function conflictingHeldCard(itemId, claimIdempotencyKey) {
293
+ // ONE PASS PER WORKER, NOT PER PROCESS. A fleet runs many QA lanes through a
294
+ // single connector, so scanning every capability in the map made lane A's
295
+ // card refuse lane B — naming a card lane B had never seen and telling it to
296
+ // "transition or release" a peer's pass. That serialises the parallel QA
297
+ // drain the atomic claim exists to make safe, and it does so silently: the
298
+ // refusal reads exactly like ordinary contention for one card.
299
+ //
300
+ // The worker is what the guard was always about, and `workerLabel` is how a
301
+ // worker names itself. A capability stored without one cannot be attributed,
302
+ // so it still conflicts — an unattributable hold fails toward refusing, which
303
+ // costs a retry, rather than toward a double hold, which costs a lost lease.
304
+ function conflictingHeldCard(itemId, claimIdempotencyKey, workerLabel) {
292
305
  for (const [heldItemId, capability] of workerLeaseCapabilities) {
293
306
  const sameCard = itemId != null && heldItemId === itemId;
294
307
  const sameClaim =
295
308
  claimIdempotencyKey != null && capability.claimIdempotencyKey === claimIdempotencyKey;
296
- if (!sameCard && !sameClaim) return heldItemId;
309
+ const sameWorker =
310
+ capability.workerLabel == null || capability.workerLabel === workerLabel;
311
+ if (!sameCard && !sameClaim && sameWorker) return heldItemId;
297
312
  }
298
313
  return null;
299
314
  }
@@ -510,7 +525,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
510
525
  idempotencyKey: z.string().optional(),
511
526
  },
512
527
  async (args) => {
513
- const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey);
528
+ const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey, args.workerLabel);
514
529
  if (heldItemId) {
515
530
  return ok({
516
531
  error: true,
@@ -546,7 +561,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
546
561
  body,
547
562
  headers: buildMutationHeaders(args),
548
563
  });
549
- const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey);
564
+ const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey, args.workerLabel);
550
565
  return ok({ claimed: true, ...visible });
551
566
  }
552
567
  );
@@ -566,7 +581,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
566
581
  idempotencyKey: z.string().optional(),
567
582
  },
568
583
  async (args) => {
569
- const heldItemId = conflictingHeldCard(null, args.idempotencyKey);
584
+ const heldItemId = conflictingHeldCard(null, args.idempotencyKey, args.workerLabel);
570
585
  if (heldItemId) {
571
586
  return ok({
572
587
  error: true,
@@ -624,7 +639,12 @@ Start here for "what should I work on". With includeUnassigned it also returns t
624
639
  : `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.`,
625
640
  });
626
641
  }
627
- const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
642
+ const visible = rememberWorkerLease(
643
+ selected.workItemId,
644
+ selected.grant,
645
+ args.idempotencyKey,
646
+ args.workerLabel
647
+ );
628
648
  return ok({
629
649
  claimed: true,
630
650
  skippedBlocked,
@@ -881,7 +901,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
881
901
 
882
902
  server.tool(
883
903
  'list_work_item_acceptance_criteria',
884
- 'Read the ordered, executable definition of done for one card. Each criterion says where to check, any starting state, what action to perform, and the observable result. A zero-length list is a finding: nobody has stated how QA can decide the card is done.',
904
+ 'Read the ordered, executable definition of done for one card. Each criterion says where to check, any starting state, what action to perform, and the observable result. A zero-length list is a finding: nobody has stated how QA can decide the card is done. `criteriaQuality` names criteria that cannot fail as written — an unfalsifiable result, a location that is not a place to stand, an unfinished clause. Correct those before executing them; a criterion turned green by reading it generously is worth less than an open one.',
885
905
  { itemId: z.string().describe('Card id.') },
886
906
  async (args) => {
887
907
  const data = await api(
@@ -889,7 +909,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
889
909
  `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`
890
910
  );
891
911
  const criteria = data?.data ?? [];
892
- return ok({ count: criteria.length, criteria });
912
+ return ok({ count: criteria.length, criteria, criteriaQuality: inspectCriteria(criteria) });
893
913
  }
894
914
  );
895
915
 
@@ -909,7 +929,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
909
929
 
910
930
  server.tool(
911
931
  'audit_work_hub',
912
- `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.`,
932
+ `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, truncated reads, and boards that could not be read at all. It does not award a vanity score and it does not mutate anything. A clean result means every board it COULD read has the minimum workflow facts required to operate from it read \`boards\` against \`boardsExpected\` before treating it as coverage. Deployment truth remains a separate exact-SHA fact available through get_work_item_delivery_evidence.`,
913
933
  {},
914
934
  async () => {
915
935
  const listed = await api('GET', '/api/v1/work-boards');
@@ -920,7 +940,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
920
940
  return detail?.data;
921
941
  })
922
942
  );
923
- return ok(auditWorkHubBoards(boards.filter(Boolean)));
943
+ // `refs.length` travels with the payload so a board whose detail read came
944
+ // back empty is REPORTED as unread rather than silently dropped by the
945
+ // filter. Auditing one of four boards and saying "trustworthy" is the
946
+ // single-board coverage claim this repo already warns about.
947
+ return ok(auditWorkHubBoards(boards.filter(Boolean), { expectedBoards: refs.length }));
924
948
  }
925
949
  );
926
950
 
@@ -1479,11 +1503,25 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1479
1503
  return ok({
1480
1504
  moved: true,
1481
1505
  claimed: args.claim === true,
1482
- // Echoed as a BOOLEAN, never as the receipt's contents. What the agent
1483
- // needs to know is whether the evidence was accepted; repeating a
1506
+ // Read back from the SERVER, never `args.receipt !== undefined`. That
1507
+ // echo reported the request rather than the answer, and the two
1508
+ // disagree in all three directions: `receipt: {}` points at nothing and
1509
+ // the server grades it `server_recorded`; offering no receipt at all
1510
+ // still writes a `server_recorded` row, so `false` was wrong; and a
1511
+ // REORDER writes no receipt whatsoever, because the API guards the
1512
+ // write with `if changed_column` — yet a receipt passed alongside one
1513
+ // still reported `true`.
1514
+ //
1515
+ // Still a BOOLEAN and still never the receipt's contents: repeating a
1484
1516
  // session pointer back into a conversation transcript is the leak this
1485
1517
  // whole field is shaped to avoid.
1486
- receiptRecorded: args.receipt !== undefined,
1518
+ receiptRecorded: data?.receiptEvidenceSource != null,
1519
+ // Whether that receipt points at anything, which is a different
1520
+ // question from whether one exists — and it is the one the gaps query
1521
+ // asks. `attested` only when the caller's references actually carried a
1522
+ // value; `server_recorded` when the server wrote the row on its own
1523
+ // observation; null when no receipt was written for this call.
1524
+ receiptEvidenceSource: data?.receiptEvidenceSource ?? null,
1487
1525
  // Read back from the server rather than echoed from the request, because
1488
1526
  // the two halves of a claim are decided server-side and an agent that
1489
1527
  // assumed "claimed" meant "mine now" would report a QA pick-up as having
@@ -1600,7 +1638,12 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1600
1638
  return ok({
1601
1639
  transferred: true,
1602
1640
  identityPreserved: true,
1603
- receiptRecorded: args.receipt !== undefined,
1641
+ // The server's verdict, not an echo of the request. See the move tool
1642
+ // above for why those differ. A transfer always changes board AND
1643
+ // column, so unlike a move it always writes a receipt — but whether
1644
+ // that receipt points at anything is still the server's call.
1645
+ receiptRecorded: data?.receiptEvidenceSource != null,
1646
+ receiptEvidenceSource: data?.receiptEvidenceSource ?? null,
1604
1647
  item: data?.data,
1605
1648
  });
1606
1649
  }
@@ -1853,10 +1896,12 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1853
1896
  acceptanceCriteria: z
1854
1897
  .array(
1855
1898
  z.object({
1856
- whereText: z.string().describe('Surface, environment, account, or role to check.'),
1899
+ whereText: z.string().describe(
1900
+ 'Surface, environment, account, or role to check. A place the next reviewer can stand, not the whole product — "the app" tells them nothing.'
1901
+ ),
1857
1902
  givenText: z.string().optional().describe('Starting state, when one is required.'),
1858
1903
  whenText: z.string().describe('Action the verifier performs.'),
1859
- thenText: z.string().describe('Observable result that must follow.'),
1904
+ thenText: z.string().describe('Observable result that must follow. Name a checkable value, count, state, or exact message. A criterion that cannot fail cannot establish completion.'),
1860
1905
  verificationRoute: z
1861
1906
  .enum(['product', 'engineering', 'both'])
1862
1907
  .describe(
@@ -1934,6 +1979,11 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1934
1979
  // capture is how work stops reaching the board at all.
1935
1980
  note: describeMissingBody(args.body),
1936
1981
  readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
1982
+ // Advisory, and only ever present when it has something to say. The
1983
+ // preview is where an author is already deciding whether to approve,
1984
+ // and a criterion that cannot fail is cheapest to fix before the card
1985
+ // exists rather than after QA has tried to execute it.
1986
+ criteriaQualityNote: describeCriteriaQuality(args.acceptanceCriteria),
1937
1987
  });
1938
1988
  }
1939
1989
 
@@ -1980,10 +2030,12 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1980
2030
  `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}`,
1981
2031
  {
1982
2032
  itemId: z.string().describe('Card id.'),
1983
- whereText: z.string().describe('Surface, environment, account, or role to check.'),
2033
+ whereText: z.string().describe(
2034
+ 'Surface, environment, account, or role to check. A place the next reviewer can stand, not the whole product — "the app" tells them nothing.'
2035
+ ),
1984
2036
  givenText: z.string().optional().describe('Starting state, when one is required.'),
1985
2037
  whenText: z.string().describe('Action the verifier performs.'),
1986
- thenText: z.string().describe('Observable result that must follow.'),
2038
+ thenText: z.string().describe('Observable result that must follow. Name a checkable value, count, state, or exact message. A criterion that cannot fail cannot establish completion.'),
1987
2039
  verificationRoute: z
1988
2040
  .enum(['product', 'engineering', 'both'])
1989
2041
  .describe(
@@ -2025,7 +2077,12 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
2025
2077
  verificationRoute: args.verificationRoute,
2026
2078
  engineeringReason: args.engineeringReason,
2027
2079
  };
2028
- if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
2080
+ if (preview?.dryRun)
2081
+ return ok({
2082
+ ...preview,
2083
+ wouldAdd: { itemId: args.itemId, criterion },
2084
+ criteriaQualityNote: describeCriteriaQuality([criterion]),
2085
+ });
2029
2086
  const data = await api('POST', path, {
2030
2087
  body: criterion,
2031
2088
  headers: buildMutationHeaders(args),
@@ -2112,6 +2169,158 @@ The server refuses the change when the card is standing at a QA gate AND you are
2112
2169
  }
2113
2170
  );
2114
2171
 
2172
+ server.tool(
2173
+ 'edit_work_item_acceptance_criterion',
2174
+ `Rewrite the WORDS of one existing acceptance criterion — its \`whereText\`, \`whenText\`, \`thenText\` or \`givenText\`. Use it when a criterion says the wrong thing: its \`whereText\` names a surface this card does not own, its \`thenText\` asks for something no observation could measure, or it cites a count, fixture or file name that has since changed and would now fail a reviewer who is looking at the right thing.
2175
+
2176
+ REWRITING ANY OF THE FOUR TEXTS CLEARS THE CRITERION'S SATISFACTION. The ticker, the column, the note, the dwell and the evidence link are all set back to NULL, and the card drops below its gate. That is deliberate and it is not a bug to work around: a tick is evidence about a SENTENCE, and the moment the sentence changes the evidence no longer refers to anything. Reordering alone does not clear it — only a change to the words does. So edit at grooming, before a pass has spent anything; editing a verified criterion costs a real recorded journey.
2177
+
2178
+ WHAT THIS TOOL MUST NOT BE USED FOR, and it is the only way it can do damage: narrowing a criterion because the product is failing it. If the product does not do what the criterion says, the fix is the product, or a bounce naming the criterion — never a sentence rewritten until today's behaviour passes. Afterwards a laundered criterion is indistinguishable from one that always said that, except in the receipt, so the receipt is the only thing standing between this tool and a definition of done that quietly tracks whatever was built.
2179
+
2180
+ PREFER THIS TO DELETION WHENEVER THE INTENT IS REAL. Most criteria that look impossible are merely mis-scoped — they ask this card to establish something the whole board owns. Rewriting keeps the intent on the record where a reader can still see what was wanted; deleting throws it away.${GOVERNED_NOTE}`,
2181
+ {
2182
+ itemId: z.string().describe('Card id.'),
2183
+ criterionId: z
2184
+ .string()
2185
+ .describe(
2186
+ 'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
2187
+ ),
2188
+ whereText: z
2189
+ .string()
2190
+ .optional()
2191
+ .describe('New WHERE: the surface or system a reviewer looks at. Omit to leave unchanged.'),
2192
+ whenText: z
2193
+ .string()
2194
+ .optional()
2195
+ .describe('New WHEN: the action the reviewer takes. Omit to leave unchanged.'),
2196
+ thenText: z
2197
+ .string()
2198
+ .optional()
2199
+ .describe(
2200
+ 'New THEN: the observable result. Must be something a reviewer could see and ' +
2201
+ 'disagree about. Omit to leave unchanged.'
2202
+ ),
2203
+ givenText: z
2204
+ .string()
2205
+ .optional()
2206
+ .describe('New GIVEN: the precondition. Omit to leave unchanged.'),
2207
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to apply it.'),
2208
+ approved: z.boolean().optional().describe('Required true for a live write.'),
2209
+ reason: z
2210
+ .string()
2211
+ .optional()
2212
+ .describe(
2213
+ 'Required for a live write: what the criterion said, what it will say, and why ' +
2214
+ 'the old wording could not be reviewed as written.'
2215
+ ),
2216
+ idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
2217
+ },
2218
+ async (args) => {
2219
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria/${encodeURIComponent(args.criterionId)}`;
2220
+ const preview = validateApiBridgeRequest({
2221
+ method: 'PATCH',
2222
+ path,
2223
+ dryRun: args.dryRun,
2224
+ approved: args.approved,
2225
+ reason: args.reason,
2226
+ idempotencyKey: args.idempotencyKey,
2227
+ grantedScope: getGrantedScope(),
2228
+ });
2229
+ // Only the parts the caller actually named. An absent key means "leave this
2230
+ // column exactly as it is" on the server; sending all four would clear the
2231
+ // criterion's satisfaction even for a caller who changed one word.
2232
+ const edit = {};
2233
+ if (args.whereText !== undefined) edit.whereText = args.whereText;
2234
+ if (args.whenText !== undefined) edit.whenText = args.whenText;
2235
+ if (args.thenText !== undefined) edit.thenText = args.thenText;
2236
+ if (args.givenText !== undefined) edit.givenText = args.givenText;
2237
+ if (Object.keys(edit).length === 0) {
2238
+ return ok({
2239
+ edited: false,
2240
+ refused:
2241
+ 'No new text was given, so there is nothing to change. Name at least one of ' +
2242
+ 'whereText, whenText, thenText or givenText.',
2243
+ });
2244
+ }
2245
+ if (preview?.dryRun) {
2246
+ return ok({
2247
+ ...preview,
2248
+ wouldEdit: { itemId: args.itemId, criterionId: args.criterionId, edit },
2249
+ warning:
2250
+ 'Applying this CLEARS any existing satisfaction on this criterion — ticker, ' +
2251
+ 'column, note and evidence link — because the sentence the evidence referred ' +
2252
+ 'to will no longer exist.',
2253
+ });
2254
+ }
2255
+ const data = await api('PATCH', path, {
2256
+ body: edit,
2257
+ headers: buildMutationHeaders(args),
2258
+ });
2259
+ return ok({ edited: true, criterion: data?.data });
2260
+ }
2261
+ );
2262
+
2263
+ server.tool(
2264
+ 'delete_work_item_acceptance_criterion',
2265
+ `PERMANENTLY REMOVE one acceptance criterion from a card, taking its history with it. Reach for this ONLY when a criterion is unsatisfiable BY CONSTRUCTION — when no evidence any reviewer could gather would ever discharge it. That is not a high bar being enforced; it is a card that can never leave its gate, and it costs a full pass every time a lane draws it and discovers why.
2266
+
2267
+ TWO SHAPES QUALIFY, and both were measured on this board:
2268
+
2269
+ GATE-AS-CRITERION. Its subject is the exit condition of the whole gate or the whole board rather than this card's own deliverable — "every criterion on every QA card carries a fresh receipt", "every card in QA2 is verified". A card cannot verify the queue it is standing in, and a criterion requiring a later gate to have run cannot be discharged from the earlier one.
2270
+
2271
+ PRODUCTION-CERTIFICATION. Its \`whereText\` is the production build after protected merge and deploy. Agents never move a card to Production and never certify production — that is the human owner's act by design — so no QA pass can discharge it at any level of effort. The observation is real and belongs in an acceptance note for the owner, not in a row that gates a QA lane.
2272
+
2273
+ WHAT DOES NOT QUALIFY, which is most of what will tempt you: a criterion that is merely hard, currently failing, blocked on a fixture you do not have, or blocked on an environment that is down. Every one of those is a BOUNCE or a BLOCKER — cheap, reversible, and visible to whoever can clear it. Deletion is none of those things. "I could not satisfy it" and "it cannot be satisfied" are different claims, and only the second one is this tool.
2274
+
2275
+ TRY \`edit_work_item_acceptance_criterion\` FIRST. In practice most impossible-looking criteria have real intent and an impossible SCOPE: rewriting one to the part this card actually owns keeps the intent legible to the next reader, where deleting erases the fact that the definition of done was ever larger. Delete when the intent itself does not belong on this card — not when its wording is wrong.
2276
+
2277
+ Say in \`reason\` which of the two shapes it is and what a reviewer would have had to produce, because after this call nothing downstream can reconstruct what was removed.${GOVERNED_NOTE}`,
2278
+ {
2279
+ itemId: z.string().describe('Card id.'),
2280
+ criterionId: z
2281
+ .string()
2282
+ .describe(
2283
+ 'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`. ' +
2284
+ 'Read the card first: ordinals shift as rows are removed, ids do not.'
2285
+ ),
2286
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to delete it.'),
2287
+ approved: z.boolean().optional().describe('Required true for a live write.'),
2288
+ reason: z
2289
+ .string()
2290
+ .optional()
2291
+ .describe(
2292
+ 'Required for a live write: which shape of unsatisfiable-by-construction this is ' +
2293
+ '(gate-as-criterion or production-certification) and what a reviewer would have ' +
2294
+ 'had to produce. This is the only surviving record of what was removed.'
2295
+ ),
2296
+ idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
2297
+ },
2298
+ async (args) => {
2299
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria/${encodeURIComponent(args.criterionId)}`;
2300
+ const preview = validateApiBridgeRequest({
2301
+ method: 'DELETE',
2302
+ path,
2303
+ dryRun: args.dryRun,
2304
+ approved: args.approved,
2305
+ reason: args.reason,
2306
+ idempotencyKey: args.idempotencyKey,
2307
+ grantedScope: getGrantedScope(),
2308
+ });
2309
+ if (preview?.dryRun) {
2310
+ return ok({
2311
+ ...preview,
2312
+ wouldDelete: { itemId: args.itemId, criterionId: args.criterionId },
2313
+ warning:
2314
+ "This is irreversible and takes the criterion's history with it. Confirm from " +
2315
+ 'list_work_item_acceptance_criteria that this id is the row you mean — ordinals ' +
2316
+ 'shift when a row is removed, ids do not.',
2317
+ });
2318
+ }
2319
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
2320
+ return ok({ deleted: true, remaining: data?.data });
2321
+ }
2322
+ );
2323
+
2115
2324
  server.tool(
2116
2325
  'satisfy_work_item_acceptance_criterion',
2117
2326
  `Tick one acceptance criterion with the evidence you actually observed.
@@ -2277,14 +2486,14 @@ Anyone who can see the card may un-tick, including somebody undoing another pers
2277
2486
 
2278
2487
  server.tool(
2279
2488
  'record_work_item_criterion_engineering_proof',
2280
- `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}`,
2489
+ `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 runtime smoke. Local CLI or isolated runtime checks must declare local; they do not establish a staging deployment or a product user journey. 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}`,
2281
2490
  {
2282
2491
  itemId: z.string().describe('Card id.'),
2283
2492
  criterionId: z.string().describe('Criterion id, not its display ordinal.'),
2284
- environment: z.enum(['staging', 'production']),
2493
+ environment: z.enum(['local', 'staging', 'production']).describe('Where the evidence actually ran; local evidence must never be relabelled staging or production.'),
2285
2494
  outcome: z.enum(['pass', 'fail']),
2286
2495
  summary: z.string().describe('Short result summary; no raw logs or transcript content.'),
2287
- buildSha: z.string().regex(/^[0-9a-f]{40}$/).describe('Exact deployed 40-character commit SHA.'),
2496
+ buildSha: z.string().regex(/^[0-9a-f]{40}$/).describe('Exact tested 40-character commit SHA; declare deployment only when actually tested there.'),
2288
2497
  stagingSmoke: z.string().describe('The narrowest honest staging/runtime smoke performed.'),
2289
2498
  proofs: z
2290
2499
  .array(
@@ -2399,7 +2608,11 @@ A FLAG IS NOT A TAG. The tag says how urgent the WORK is; a flag says the CARD i
2399
2608
 
2400
2609
  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.
2401
2610
 
2402
- 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}`,
2611
+ 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.
2612
+
2613
+ A FLAG HANDS THE CARD ON; IT DOES NOT HIDE IT. Raising one leaves the card claimable, so another agent picks it up, reads your reason, and tries — and reflags with what it learned if it fails too. That loop is the point: write the reason for the next agent, not for a log. Say what you tried and what you ruled out, because they will otherwise re-derive it.
2614
+
2615
+ PASS blocksClaim:true ONLY FOR A BLOCKER NO AGENT CAN SATISFY — an owner credential, a vendor consent, a product decision. That takes the card out of every reviewer's queue until somebody clears it by hand, so it is the wrong answer for "the test failed" or "I could not work out why". If another agent could plausibly get further, leave it claimable.${GOVERNED_NOTE}`,
2403
2616
  {
2404
2617
  itemId: z.string().describe('Card id.'),
2405
2618
  flagged: z
@@ -2412,6 +2625,12 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2412
2625
  .describe(
2413
2626
  '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.'
2414
2627
  ),
2628
+ blocksClaim: z
2629
+ .boolean()
2630
+ .optional()
2631
+ .describe(
2632
+ 'Whether this flag stops anyone picking the card up. OMITTED MEANS FALSE — the card stays claimable and another agent can try, which is what a flag is for. Pass true only for a blocker no agent can satisfy (an owner credential, a vendor consent, a decision); that hides the card until somebody clears the flag by hand. Ignored when clearing — clearing always restores the blocking default.'
2633
+ ),
2415
2634
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to change the flag.'),
2416
2635
  approved: z.boolean().optional().describe('Required true for a live change.'),
2417
2636
  idempotencyKey: z
@@ -2446,7 +2665,15 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2446
2665
  if (preview?.dryRun) {
2447
2666
  return ok({
2448
2667
  ...preview,
2449
- wouldFlag: { itemId: args.itemId, flagged: args.flagged, reason: args.reason },
2668
+ wouldFlag: {
2669
+ itemId: args.itemId,
2670
+ flagged: args.flagged,
2671
+ reason: args.reason,
2672
+ // Shown in the rehearsal because it decides whether the card
2673
+ // disappears from every queue, which is the consequence a reader
2674
+ // most needs to see before approving the write.
2675
+ blocksClaim: args.flagged ? (args.blocksClaim ?? false) : true,
2676
+ },
2450
2677
  });
2451
2678
  }
2452
2679
 
@@ -2455,12 +2682,196 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2455
2682
  flagged: args.flagged,
2456
2683
  reason: args.reason,
2457
2684
  idempotencyKey: args.idempotencyKey,
2685
+ // Sent only when the caller named it, so an older API that does not
2686
+ // know the field is not handed one, and the server's own default
2687
+ // (blocking) remains the single source of that decision.
2688
+ ...(args.blocksClaim === undefined ? {} : { blocksClaim: args.blocksClaim }),
2458
2689
  },
2459
2690
  headers: buildMutationHeaders({ ...args, reason: args.reason }),
2460
2691
  });
2461
2692
  return ok({ flagged: args.flagged, item: data?.data });
2462
2693
  }
2463
2694
  );
2695
+
2696
+ // ── Retiring a card ──────────────────────────────────────────────────────
2697
+ //
2698
+ // Three tools rather than one with a `kind` switch, and the split is
2699
+ // deliberate. `delete_` is matched by tool-annotations' DESTRUCTIVE_PATTERN,
2700
+ // so naming the destructive branch as its own tool is what gets a host to
2701
+ // confirmation-gate it. A single `archive_work_item({ kind: 'deleted' })`
2702
+ // would hide the destructive path behind a name classified as safe.
2703
+
2704
+ 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.
2705
+
2706
+ 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}`;
2707
+
2708
+ server.tool(
2709
+ 'archive_work_item',
2710
+ `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.
2711
+
2712
+ 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.
2713
+
2714
+ ${RETIRE_NOTE}`,
2715
+ {
2716
+ itemId: z.string().describe('Card id.'),
2717
+ reason: z
2718
+ .string()
2719
+ .describe(
2720
+ '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.'
2721
+ ),
2722
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to retire the card.'),
2723
+ approved: z.boolean().optional().describe('Required true for a live change.'),
2724
+ idempotencyKey: z
2725
+ .string()
2726
+ .optional()
2727
+ .describe(
2728
+ 'Required for a live change. Reuse the SAME key on retry — retiring also appends a comment.'
2729
+ ),
2730
+ },
2731
+ async (args) => {
2732
+ if (!args.reason || !args.reason.trim()) {
2733
+ return ok({
2734
+ error: true,
2735
+ message:
2736
+ 'reason is required. A card that disappears from the board with nothing written on it is exactly what archiving exists to replace.',
2737
+ });
2738
+ }
2739
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/archive`;
2740
+ const preview = validateApiBridgeRequest({
2741
+ method: 'POST',
2742
+ path,
2743
+ dryRun: args.dryRun,
2744
+ approved: args.approved,
2745
+ reason: args.reason,
2746
+ idempotencyKey: args.idempotencyKey,
2747
+ grantedScope: getGrantedScope(),
2748
+ });
2749
+ if (preview?.dryRun) {
2750
+ return ok({
2751
+ ...preview,
2752
+ wouldArchive: { itemId: args.itemId, kind: 'archived', reason: args.reason },
2753
+ });
2754
+ }
2755
+ const data = await api('POST', path, {
2756
+ body: { reason: args.reason, kind: 'archived', idempotencyKey: args.idempotencyKey },
2757
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
2758
+ });
2759
+ return ok({ archived: true, kind: 'archived', item: data?.data });
2760
+ }
2761
+ );
2762
+
2763
+ server.tool(
2764
+ 'delete_work_item',
2765
+ `Retire a card as DELETED rather than archived. Rare, and deliberate.
2766
+
2767
+ 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.
2768
+
2769
+ 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.
2770
+
2771
+ 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.
2772
+
2773
+ ${RETIRE_NOTE}`,
2774
+ {
2775
+ itemId: z.string().describe('Card id.'),
2776
+ confirmTitle: z
2777
+ .string()
2778
+ .describe(
2779
+ "REQUIRED. The card's title, exactly as get_work_item returns it. A mismatch is refused and nothing is written."
2780
+ ),
2781
+ reason: z
2782
+ .string()
2783
+ .describe('REQUIRED. Why this card should never have existed.'),
2784
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to delete the card.'),
2785
+ approved: z.boolean().optional().describe('Required true for a live change.'),
2786
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
2787
+ },
2788
+ async (args) => {
2789
+ if (!args.reason || !args.reason.trim()) {
2790
+ return ok({ error: true, message: 'reason is required to delete a card.' });
2791
+ }
2792
+ if (!args.confirmTitle || !args.confirmTitle.trim()) {
2793
+ return ok({
2794
+ error: true,
2795
+ message:
2796
+ "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.",
2797
+ });
2798
+ }
2799
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/archive`;
2800
+ const preview = validateApiBridgeRequest({
2801
+ method: 'POST',
2802
+ path,
2803
+ dryRun: args.dryRun,
2804
+ approved: args.approved,
2805
+ reason: args.reason,
2806
+ idempotencyKey: args.idempotencyKey,
2807
+ grantedScope: getGrantedScope(),
2808
+ });
2809
+ if (preview?.dryRun) {
2810
+ return ok({
2811
+ ...preview,
2812
+ wouldDelete: {
2813
+ itemId: args.itemId,
2814
+ kind: 'deleted',
2815
+ reason: args.reason,
2816
+ confirmTitle: args.confirmTitle,
2817
+ },
2818
+ });
2819
+ }
2820
+ const data = await api('POST', path, {
2821
+ body: {
2822
+ reason: args.reason,
2823
+ kind: 'deleted',
2824
+ confirmTitle: args.confirmTitle,
2825
+ idempotencyKey: args.idempotencyKey,
2826
+ },
2827
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
2828
+ });
2829
+ return ok({ archived: true, kind: 'deleted', item: data?.data });
2830
+ }
2831
+ );
2832
+
2833
+ server.tool(
2834
+ 'unarchive_work_item',
2835
+ `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.
2836
+
2837
+ 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.
2838
+
2839
+ 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}`,
2840
+ {
2841
+ itemId: z.string().describe('Card id. Find retired cards with the board archived-items read.'),
2842
+ reason: z.string().describe('REQUIRED. What changed — why this work is wanted again.'),
2843
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to restore the card.'),
2844
+ approved: z.boolean().optional().describe('Required true for a live change.'),
2845
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
2846
+ },
2847
+ async (args) => {
2848
+ if (!args.reason || !args.reason.trim()) {
2849
+ return ok({
2850
+ error: true,
2851
+ message:
2852
+ 'reason is required to restore a card. Anyone can bring a card back, which only works if bringing it back leaves a record.',
2853
+ });
2854
+ }
2855
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/unarchive`;
2856
+ const preview = validateApiBridgeRequest({
2857
+ method: 'POST',
2858
+ path,
2859
+ dryRun: args.dryRun,
2860
+ approved: args.approved,
2861
+ reason: args.reason,
2862
+ idempotencyKey: args.idempotencyKey,
2863
+ grantedScope: getGrantedScope(),
2864
+ });
2865
+ if (preview?.dryRun) {
2866
+ return ok({ ...preview, wouldUnarchive: { itemId: args.itemId, reason: args.reason } });
2867
+ }
2868
+ const data = await api('POST', path, {
2869
+ body: { reason: args.reason, idempotencyKey: args.idempotencyKey },
2870
+ headers: buildMutationHeaders({ ...args, reason: args.reason }),
2871
+ });
2872
+ return ok({ archived: false, item: data?.data });
2873
+ }
2874
+ );
2464
2875
  }
2465
2876
 
2466
2877
  /** Tool names registered here, for the tier map and the toolset manifest. */
@@ -2495,12 +2906,17 @@ export const WORK_BOARD_TOOL_NAMES = [
2495
2906
  'create_work_item',
2496
2907
  'add_work_item_acceptance_criterion',
2497
2908
  'classify_work_item_acceptance_criterion',
2909
+ 'edit_work_item_acceptance_criterion',
2910
+ 'delete_work_item_acceptance_criterion',
2498
2911
  'satisfy_work_item_acceptance_criterion',
2499
2912
  'record_work_item_criterion_engineering_proof',
2500
2913
  'unsatisfy_work_item_acceptance_criterion',
2501
2914
  'get_work_item_comments',
2502
2915
  'comment_on_work_item',
2503
2916
  'flag_work_item',
2917
+ 'archive_work_item',
2918
+ 'delete_work_item',
2919
+ 'unarchive_work_item',
2504
2920
  'block_work_item',
2505
2921
  'unblock_work_item',
2506
2922
  ];