@adrata/adrata-mcp 1.0.19 → 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,5 +1,8 @@
1
1
  /** Read-only, product-visible health for Starfield's source-control ingestion. */
2
- export function registerSourceControlTools(server, { z, api, ok }) {
2
+ export function registerSourceControlTools(
3
+ server,
4
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
5
+ ) {
3
6
  server.tool(
4
7
  'list_source_control_connections',
5
8
  'List source-control connections with repository binding count, last durably acknowledged delivery, rejection state, actionable health, and the exact webhook events that are safe to enable.',
@@ -23,9 +26,122 @@ export function registerSourceControlTools(server, { z, api, ok }) {
23
26
  return ok({ events: response?.data ?? response ?? [] });
24
27
  }
25
28
  );
29
+
30
+ server.tool(
31
+ 'bind_repository_to_board',
32
+ 'Bind one repository to one board, so a merged pull request carrying a card id moves that card by itself.'
33
+ + ' Until a board is bound, every merge on it advances nothing and the column timestamps are hand-typed,'
34
+ + ' which is the input every cycle-time and control chart is built from.'
35
+ + ' Name BOTH mergeFromColumnId and mergeToColumnId or neither: one end alone is a half-configured rule'
36
+ + ' that silently does nothing, and the database CHECK constraint refuses it.'
37
+ + ' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason,'
38
+ + ' and an idempotencyKey (reuse the SAME key on retry — a duplicate bind is refused as a 409).',
39
+ {
40
+ connectionId: z.string().describe('Source-control connection id, from list_source_control_connections.'),
41
+ boardId: z.string().describe('Board id from list_work_boards. One board binds once per connection.'),
42
+ externalRepoId: z.string().describe('The vendor’s own repository id, not its name.'),
43
+ externalFullName: z.string().describe('owner/name, e.g. adrata/adrata.'),
44
+ mergeFromColumnId: z
45
+ .string()
46
+ .optional()
47
+ .describe('Where a merged pull request’s card must be coming FROM. Both ends or neither.'),
48
+ mergeToColumnId: z
49
+ .string()
50
+ .optional()
51
+ .describe('Where it goes on merge. Both ends or neither.'),
52
+ onMergeAction: z
53
+ .enum(['advance', 'suggest', 'ignore'])
54
+ .optional()
55
+ .describe('Defaults to advance. `suggest` records the proposal without moving the card.'),
56
+ branchSample: z
57
+ .array(z.string())
58
+ .optional()
59
+ .describe(
60
+ 'Recent branch names, newest first. NOT stored: the server measures how many already carry a card'
61
+ + ' reference and seeds the link strategies from what the repository actually does, rather than from'
62
+ + ' a form somebody filled in. Omitting it means the strategies are chosen without that evidence.'
63
+ ),
64
+ advanceOnlyWhenAllMerged: z
65
+ .boolean()
66
+ .optional()
67
+ .describe('Advance only when no linked change request is still open. Defaults true.'),
68
+ openPullsCardIntoProgress: z
69
+ .boolean()
70
+ .optional()
71
+ .describe('Does an OPENED pull request pull its card into In progress? Defaults false.'),
72
+ stagingEnvironmentName: z
73
+ .string()
74
+ .optional()
75
+ .describe('Exact vendor environment name. Absence means that evidence is not configured; never infer a role from a word like "staging".'),
76
+ productionEnvironmentName: z.string().optional().describe('Exact vendor environment name.'),
77
+ dryRun: z.boolean().optional().default(true),
78
+ approved: z.boolean().optional().default(false),
79
+ reason: z.string().optional(),
80
+ idempotencyKey: z.string().optional(),
81
+ },
82
+ async (args) => {
83
+ // Refused HERE rather than at the API, because the 400 this produces reads
84
+ // as a validation error about a column and the actual mistake is that only
85
+ // one end was named. `scm_repositories_move_shape_chk` enforces the same
86
+ // shape in the database; this says why before the round trip.
87
+ const from = args.mergeFromColumnId;
88
+ const to = args.mergeToColumnId;
89
+ if (Boolean(from) !== Boolean(to)) {
90
+ return ok({
91
+ error: true,
92
+ message:
93
+ 'Name both mergeFromColumnId and mergeToColumnId, or neither. One end alone is a half-configured'
94
+ + ' move rule: it is accepted nowhere, and if it were, it would silently never fire — the failure'
95
+ + ' mode that takes longest to notice.',
96
+ });
97
+ }
98
+
99
+ const path = `/api/v1/scm/connections/${encodeURIComponent(args.connectionId)}/repositories`;
100
+ const body = {
101
+ boardId: args.boardId,
102
+ externalRepoId: args.externalRepoId,
103
+ externalFullName: args.externalFullName,
104
+ mergeFromColumnId: from,
105
+ mergeToColumnId: to,
106
+ onMergeAction: args.onMergeAction,
107
+ branchSample: args.branchSample,
108
+ advanceOnlyWhenAllMerged: args.advanceOnlyWhenAllMerged,
109
+ openPullsCardIntoProgress: args.openPullsCardIntoProgress,
110
+ stagingEnvironmentName: args.stagingEnvironmentName,
111
+ productionEnvironmentName: args.productionEnvironmentName,
112
+ };
113
+ const preview = validateApiBridgeRequest({
114
+ method: 'POST',
115
+ path,
116
+ body,
117
+ dryRun: args.dryRun,
118
+ approved: args.approved,
119
+ reason: args.reason,
120
+ idempotencyKey: args.idempotencyKey,
121
+ grantedScope: getGrantedScope(),
122
+ });
123
+ if (preview.dryRun) {
124
+ return ok({
125
+ dryRun: true,
126
+ action: `Bind ${args.externalFullName} to board ${args.boardId}`,
127
+ movesCardsOnMerge:
128
+ to === undefined
129
+ ? 'No. Without both column ends this binding records deliveries but advances nothing.'
130
+ : `Yes: a merged pull request moves its card ${from} -> ${to}.`,
131
+ note: 'Nothing was written. Confirm to create the binding.',
132
+ });
133
+ }
134
+ const data = await api('POST', path, {
135
+ body,
136
+ headers: buildMutationHeaders(args),
137
+ });
138
+ return ok({ bound: true, repository: data?.data ?? data });
139
+ }
140
+ );
26
141
  }
27
142
 
28
143
  export const SOURCE_CONTROL_TOOL_NAMES = [
29
144
  'list_source_control_connections',
30
145
  'get_source_control_connection_events',
146
+ 'bind_repository_to_board',
31
147
  ];
@@ -274,7 +274,7 @@ export function registerWorkBoardTools(
274
274
  // public id and the handler adds the three private headers at the last hop.
275
275
  const workerLeaseCapabilities = new Map();
276
276
 
277
- function rememberWorkerLease(itemId, grant, claimIdempotencyKey) {
277
+ function rememberWorkerLease(itemId, grant, claimIdempotencyKey, workerLabel) {
278
278
  if (!grant?.leaseId || !grant?.leaseToken || !Number.isInteger(grant?.fencingToken)) {
279
279
  throw new Error('The API returned an incomplete worker-lease grant; no capability was stored.');
280
280
  }
@@ -283,18 +283,32 @@ export function registerWorkBoardTools(
283
283
  leaseToken: grant.leaseToken,
284
284
  fencingToken: grant.fencingToken,
285
285
  claimIdempotencyKey,
286
+ workerLabel,
286
287
  });
287
288
  const { leaseToken: _secret, fencingToken: _fence, qaRequirements, currentCriteria, ...lease } =
288
289
  grant;
289
290
  return { lease, qaRequirements, currentCriteria };
290
291
  }
291
292
 
292
- 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) {
293
305
  for (const [heldItemId, capability] of workerLeaseCapabilities) {
294
306
  const sameCard = itemId != null && heldItemId === itemId;
295
307
  const sameClaim =
296
308
  claimIdempotencyKey != null && capability.claimIdempotencyKey === claimIdempotencyKey;
297
- if (!sameCard && !sameClaim) return heldItemId;
309
+ const sameWorker =
310
+ capability.workerLabel == null || capability.workerLabel === workerLabel;
311
+ if (!sameCard && !sameClaim && sameWorker) return heldItemId;
298
312
  }
299
313
  return null;
300
314
  }
@@ -511,7 +525,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
511
525
  idempotencyKey: z.string().optional(),
512
526
  },
513
527
  async (args) => {
514
- const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey);
528
+ const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey, args.workerLabel);
515
529
  if (heldItemId) {
516
530
  return ok({
517
531
  error: true,
@@ -547,7 +561,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
547
561
  body,
548
562
  headers: buildMutationHeaders(args),
549
563
  });
550
- const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey);
564
+ const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey, args.workerLabel);
551
565
  return ok({ claimed: true, ...visible });
552
566
  }
553
567
  );
@@ -567,7 +581,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
567
581
  idempotencyKey: z.string().optional(),
568
582
  },
569
583
  async (args) => {
570
- const heldItemId = conflictingHeldCard(null, args.idempotencyKey);
584
+ const heldItemId = conflictingHeldCard(null, args.idempotencyKey, args.workerLabel);
571
585
  if (heldItemId) {
572
586
  return ok({
573
587
  error: true,
@@ -625,7 +639,12 @@ Start here for "what should I work on". With includeUnassigned it also returns t
625
639
  : `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.`,
626
640
  });
627
641
  }
628
- 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
+ );
629
648
  return ok({
630
649
  claimed: true,
631
650
  skippedBlocked,
@@ -1484,11 +1503,25 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1484
1503
  return ok({
1485
1504
  moved: true,
1486
1505
  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
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
1489
1516
  // session pointer back into a conversation transcript is the leak this
1490
1517
  // whole field is shaped to avoid.
1491
- 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,
1492
1525
  // Read back from the server rather than echoed from the request, because
1493
1526
  // the two halves of a claim are decided server-side and an agent that
1494
1527
  // assumed "claimed" meant "mine now" would report a QA pick-up as having
@@ -1605,7 +1638,12 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1605
1638
  return ok({
1606
1639
  transferred: true,
1607
1640
  identityPreserved: true,
1608
- 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,
1609
1647
  item: data?.data,
1610
1648
  });
1611
1649
  }
@@ -2570,7 +2608,11 @@ A FLAG IS NOT A TAG. The tag says how urgent the WORK is; a flag says the CARD i
2570
2608
 
2571
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.
2572
2610
 
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}`,
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}`,
2574
2616
  {
2575
2617
  itemId: z.string().describe('Card id.'),
2576
2618
  flagged: z
@@ -2583,6 +2625,12 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2583
2625
  .describe(
2584
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.'
2585
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
+ ),
2586
2634
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to change the flag.'),
2587
2635
  approved: z.boolean().optional().describe('Required true for a live change.'),
2588
2636
  idempotencyKey: z
@@ -2617,7 +2665,15 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2617
2665
  if (preview?.dryRun) {
2618
2666
  return ok({
2619
2667
  ...preview,
2620
- 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
+ },
2621
2677
  });
2622
2678
  }
2623
2679
 
@@ -2626,12 +2682,196 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
2626
2682
  flagged: args.flagged,
2627
2683
  reason: args.reason,
2628
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 }),
2629
2689
  },
2630
2690
  headers: buildMutationHeaders({ ...args, reason: args.reason }),
2631
2691
  });
2632
2692
  return ok({ flagged: args.flagged, item: data?.data });
2633
2693
  }
2634
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
+ );
2635
2875
  }
2636
2876
 
2637
2877
  /** Tool names registered here, for the tier map and the toolset manifest. */
@@ -2674,6 +2914,9 @@ export const WORK_BOARD_TOOL_NAMES = [
2674
2914
  'get_work_item_comments',
2675
2915
  'comment_on_work_item',
2676
2916
  'flag_work_item',
2917
+ 'archive_work_item',
2918
+ 'delete_work_item',
2919
+ 'unarchive_work_item',
2677
2920
  'block_work_item',
2678
2921
  'unblock_work_item',
2679
2922
  ];
@@ -110,8 +110,10 @@ const UNLOCATED_WHERE = [
110
110
  ];
111
111
 
112
112
  /** Text nobody has written yet, left in a field that is required to be written. */
113
+ // WIP limit(s) names the board feature, not unfinished prose. Exempt only that
114
+ // noun phrase; bare WIP and separate placeholders in the same field still fire.
113
115
  const PLACEHOLDER =
114
- /(?:^|[^a-z0-9])(?:tbd|tba|todo|fixme|wip|xxx|placeholder|n\/a)(?:[^a-z0-9]|$)|\?{3,}|<[^>]{0,40}>/i;
116
+ /(?:^|[^a-z0-9])(?:tbd|tba|todo|fixme|wip(?![\s-]+limits?\b)|xxx|placeholder|n\/a)(?:[^a-z0-9]|$)|\?{3,}|<[^>]{0,40}>/i;
115
117
 
116
118
  const PART_LABELS = {
117
119
  whereText: 'where',