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