@adrata/adrata-mcp 1.0.2 → 1.0.6
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 +355 -26
- package/access/resource-metadata.js +9 -1
- package/access/tiers.js +17 -0
- package/analytics.js +141 -20
- package/api-bridge.js +16 -0
- package/package.json +2 -2
- package/server.js +109 -16
- package/server.json +14 -2
- package/skills/qa-the-card/SKILL.md +179 -0
- package/skills/ship-the-card/SKILL.md +31 -5
- package/tool-annotations.js +15 -1
- package/tools/source-control/connection-tools.js +31 -0
- package/tools/work-board-tools.js +1174 -30
- 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,51 @@
|
|
|
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
|
+
|
|
9
|
+
/** Read evidence bytes locally without ever putting them in the MCP transcript. */
|
|
10
|
+
export async function loadLocalQaEvidenceFile(filePath) {
|
|
11
|
+
const absolute = resolve(String(filePath || ''));
|
|
12
|
+
const safeName = basename(absolute);
|
|
13
|
+
try {
|
|
14
|
+
const info = await stat(absolute);
|
|
15
|
+
if (!info.isFile()) throw new Error('not_a_regular_file');
|
|
16
|
+
if (info.size <= 0 || info.size > MAX_QA_VIDEO_BYTES) {
|
|
17
|
+
throw new Error('outside_supported_size');
|
|
18
|
+
}
|
|
19
|
+
const bytes = await readFile(absolute);
|
|
20
|
+
return {
|
|
21
|
+
bytes,
|
|
22
|
+
fileName: safeName,
|
|
23
|
+
sizeBytes: bytes.byteLength,
|
|
24
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
25
|
+
};
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const code = error?.code || error?.message || 'unreadable';
|
|
28
|
+
throw new Error(`Could not read QA evidence file "${safeName}": ${code}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** PUT to the private presigned target while keeping its URL and headers process-private. */
|
|
33
|
+
export async function uploadLocalQaEvidenceFile(ticket, bytes, fetchImpl = globalThis.fetch) {
|
|
34
|
+
if (!ticket?.uploadUrl || !ticket?.headers || typeof fetchImpl !== 'function') {
|
|
35
|
+
throw new Error('The API returned an incomplete private QA evidence upload capability.');
|
|
36
|
+
}
|
|
37
|
+
const response = await fetchImpl(ticket.uploadUrl, {
|
|
38
|
+
method: 'PUT',
|
|
39
|
+
headers: {
|
|
40
|
+
...ticket.headers,
|
|
41
|
+
'Content-Length': String(bytes.byteLength),
|
|
42
|
+
},
|
|
43
|
+
body: bytes,
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Private QA evidence upload failed with HTTP ${response.status}.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
2
49
|
|
|
3
50
|
/**
|
|
4
51
|
* Work-board tools for the Adrata MCP Server.
|
|
@@ -131,6 +178,19 @@ export function describeMissingAcceptanceCriteria(criteria) {
|
|
|
131
178
|
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
179
|
}
|
|
133
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The satisfaction sub-resource of one criterion, POSTed to tick and DELETEd to
|
|
183
|
+
* un-tick.
|
|
184
|
+
*
|
|
185
|
+
* One function for both verbs because they address the SAME resource: a tick
|
|
186
|
+
* and its removal that disagreed about the path would fail asymmetrically —
|
|
187
|
+
* ticks landing and un-ticks 404ing — which is the failure that leaves a board
|
|
188
|
+
* with checkboxes nobody can clear.
|
|
189
|
+
*/
|
|
190
|
+
export function criterionSatisfactionPath(itemId, criterionId) {
|
|
191
|
+
return `/api/v1/work-items/${encodeURIComponent(itemId)}/acceptance-criteria/${encodeURIComponent(criterionId)}/satisfaction`;
|
|
192
|
+
}
|
|
193
|
+
|
|
134
194
|
/** One target stage's WIP state before and after a proposed move. */
|
|
135
195
|
export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = false }) {
|
|
136
196
|
const projected = count + (cardAlreadyThere ? 0 : 1);
|
|
@@ -151,10 +211,115 @@ export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = f
|
|
|
151
211
|
*/
|
|
152
212
|
export function registerWorkBoardTools(
|
|
153
213
|
server,
|
|
154
|
-
{
|
|
214
|
+
{
|
|
215
|
+
z,
|
|
216
|
+
api,
|
|
217
|
+
ok,
|
|
218
|
+
validateApiBridgeRequest,
|
|
219
|
+
buildMutationHeaders,
|
|
220
|
+
getGrantedScope = () => undefined,
|
|
221
|
+
loadQaEvidenceFile = loadLocalQaEvidenceFile,
|
|
222
|
+
uploadQaEvidenceFile = uploadLocalQaEvidenceFile,
|
|
223
|
+
}
|
|
155
224
|
) {
|
|
156
225
|
const GOVERNED_NOTE =
|
|
157
226
|
' 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).';
|
|
227
|
+
// Capability material belongs to this MCP process, not to the model transcript.
|
|
228
|
+
// A winning claim stores it here; heartbeat/release address the card by its
|
|
229
|
+
// public id and the handler adds the three private headers at the last hop.
|
|
230
|
+
const workerLeaseCapabilities = new Map();
|
|
231
|
+
|
|
232
|
+
function rememberWorkerLease(itemId, grant, claimIdempotencyKey) {
|
|
233
|
+
if (!grant?.leaseId || !grant?.leaseToken || !Number.isInteger(grant?.fencingToken)) {
|
|
234
|
+
throw new Error('The API returned an incomplete worker-lease grant; no capability was stored.');
|
|
235
|
+
}
|
|
236
|
+
workerLeaseCapabilities.set(itemId, {
|
|
237
|
+
leaseId: grant.leaseId,
|
|
238
|
+
leaseToken: grant.leaseToken,
|
|
239
|
+
fencingToken: grant.fencingToken,
|
|
240
|
+
claimIdempotencyKey,
|
|
241
|
+
});
|
|
242
|
+
const { leaseToken: _secret, fencingToken: _fence, qaRequirements, currentCriteria, ...lease } =
|
|
243
|
+
grant;
|
|
244
|
+
return { lease, qaRequirements, currentCriteria };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function conflictingHeldCard(itemId, claimIdempotencyKey) {
|
|
248
|
+
for (const [heldItemId, capability] of workerLeaseCapabilities) {
|
|
249
|
+
const sameCard = itemId != null && heldItemId === itemId;
|
|
250
|
+
const sameClaim =
|
|
251
|
+
claimIdempotencyKey != null && capability.claimIdempotencyKey === claimIdempotencyKey;
|
|
252
|
+
if (!sameCard && !sameClaim) return heldItemId;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function workerLeaseHeaders(itemId) {
|
|
258
|
+
const capability = workerLeaseCapabilities.get(itemId);
|
|
259
|
+
if (!capability) return null;
|
|
260
|
+
return {
|
|
261
|
+
'x-adrata-worker-lease': capability.leaseId,
|
|
262
|
+
'x-adrata-worker-token': capability.leaseToken,
|
|
263
|
+
'x-adrata-worker-fence': String(capability.fencingToken),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mutationHeadersForItem(args, itemId) {
|
|
268
|
+
return {
|
|
269
|
+
...buildMutationHeaders(args),
|
|
270
|
+
...(workerLeaseHeaders(itemId) ?? {}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function requiredWorkerLeaseHeaders(itemId) {
|
|
275
|
+
const headers = workerLeaseHeaders(itemId);
|
|
276
|
+
if (!headers) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`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.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return headers;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// A caller may legitimately use the API maximum (255 characters). Appending
|
|
285
|
+
// an operation suffix would then turn every child write into a 400 and leave
|
|
286
|
+
// a composite half-finished. Hash the caller key into short, deterministic,
|
|
287
|
+
// endpoint-specific keys instead. The original key never leaves this process
|
|
288
|
+
// through a child request.
|
|
289
|
+
function childIdempotencyKey(baseKey, operation) {
|
|
290
|
+
const digest = createHash('sha256').update(String(baseKey)).digest('hex');
|
|
291
|
+
return `qa-${operation}-${digest}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function releaseHeldPass(args, releaseKind, leaseHeaders) {
|
|
295
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/release`;
|
|
296
|
+
try {
|
|
297
|
+
const data = await api('POST', path, {
|
|
298
|
+
body: { releaseKind },
|
|
299
|
+
headers: { ...buildMutationHeaders(args), ...leaseHeaders },
|
|
300
|
+
});
|
|
301
|
+
workerLeaseCapabilities.delete(args.itemId);
|
|
302
|
+
return { workerLease: data?.data, recoveredAfterResponseLoss: false };
|
|
303
|
+
} catch (releaseError) {
|
|
304
|
+
// A successful release whose HTTP response was lost is already safe on
|
|
305
|
+
// the server. Confirm the public state before retaining a phantom local
|
|
306
|
+
// capability that would prevent this worker from taking its next card.
|
|
307
|
+
try {
|
|
308
|
+
const current = await api(
|
|
309
|
+
'GET',
|
|
310
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease`
|
|
311
|
+
);
|
|
312
|
+
if (!current?.data) {
|
|
313
|
+
workerLeaseCapabilities.delete(args.itemId);
|
|
314
|
+
return { workerLease: null, recoveredAfterResponseLoss: true };
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// Preserve the original release error; an unavailable status read is
|
|
318
|
+
// not evidence that the lease did or did not close.
|
|
319
|
+
}
|
|
320
|
+
throw releaseError;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
158
323
|
|
|
159
324
|
// =========================================================================
|
|
160
325
|
// READS
|
|
@@ -180,7 +345,9 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
180
345
|
limit: z
|
|
181
346
|
.number()
|
|
182
347
|
.optional()
|
|
183
|
-
.describe(
|
|
348
|
+
.describe(
|
|
349
|
+
'Cap each list. Defaults to 50; a queue that needs a second page is not a queue.'
|
|
350
|
+
),
|
|
184
351
|
},
|
|
185
352
|
async (args) => {
|
|
186
353
|
const params = new URLSearchParams();
|
|
@@ -188,10 +355,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
188
355
|
if (args.boardId) params.set('boardId', args.boardId);
|
|
189
356
|
if (args.limit !== undefined) params.set('limit', String(args.limit));
|
|
190
357
|
const query = params.toString();
|
|
191
|
-
const data = await api(
|
|
192
|
-
'GET',
|
|
193
|
-
`/api/v1/work-items/assigned-to-me${query ? `?${query}` : ''}`
|
|
194
|
-
);
|
|
358
|
+
const data = await api('GET', `/api/v1/work-items/assigned-to-me${query ? `?${query}` : ''}`);
|
|
195
359
|
const queue = data?.data || {};
|
|
196
360
|
const mine = queue.assignedToMe || [];
|
|
197
361
|
return ok({
|
|
@@ -243,14 +407,14 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
243
407
|
// Triage is alarming, three days in In review is a Tuesday. Say so, or
|
|
244
408
|
// an agent will apply one threshold across the whole board.
|
|
245
409
|
howToReadStaleness:
|
|
246
|
-
|
|
410
|
+
"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.",
|
|
247
411
|
});
|
|
248
412
|
}
|
|
249
413
|
);
|
|
250
414
|
|
|
251
415
|
server.tool(
|
|
252
416
|
'get_work_item',
|
|
253
|
-
|
|
417
|
+
"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.",
|
|
254
418
|
{
|
|
255
419
|
itemId: z.string().describe('Card id.'),
|
|
256
420
|
},
|
|
@@ -260,6 +424,367 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
260
424
|
}
|
|
261
425
|
);
|
|
262
426
|
|
|
427
|
+
server.tool(
|
|
428
|
+
'get_work_item_worker_lease',
|
|
429
|
+
'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.',
|
|
430
|
+
{ itemId: z.string().describe('Card id.') },
|
|
431
|
+
async (args) => {
|
|
432
|
+
const data = await api(
|
|
433
|
+
'GET',
|
|
434
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease`
|
|
435
|
+
);
|
|
436
|
+
return ok({ workerLease: data?.data ?? null });
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
server.tool(
|
|
441
|
+
'get_work_item_worker_activity',
|
|
442
|
+
'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.',
|
|
443
|
+
{ itemId: z.string().describe('Card id.') },
|
|
444
|
+
async (args) => {
|
|
445
|
+
const data = await api(
|
|
446
|
+
'GET',
|
|
447
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease-events`
|
|
448
|
+
);
|
|
449
|
+
const events = data?.data ?? [];
|
|
450
|
+
return ok({ count: events.length, events });
|
|
451
|
+
}
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
server.tool(
|
|
455
|
+
'claim_work_item_qa_pass',
|
|
456
|
+
`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}`,
|
|
457
|
+
{
|
|
458
|
+
itemId: z.string().describe('Card id.'),
|
|
459
|
+
agentName: z.enum(['claude-code', 'codex', 'grok', 'human']),
|
|
460
|
+
runLabel: z.string().describe('Concise public session label, not a transcript or secret.'),
|
|
461
|
+
workerLabel: z.string().describe('Concise public worker label, not a transcript or secret.'),
|
|
462
|
+
takeoverStalled: z.boolean().optional().default(false),
|
|
463
|
+
dryRun: z.boolean().optional().default(true),
|
|
464
|
+
approved: z.boolean().optional().default(false),
|
|
465
|
+
reason: z.string().optional(),
|
|
466
|
+
idempotencyKey: z.string().optional(),
|
|
467
|
+
},
|
|
468
|
+
async (args) => {
|
|
469
|
+
const heldItemId = conflictingHeldCard(args.itemId, args.idempotencyKey);
|
|
470
|
+
if (heldItemId) {
|
|
471
|
+
return ok({
|
|
472
|
+
error: true,
|
|
473
|
+
message: `This MCP worker already holds card ${heldItemId}. Transition or release it before claiming another pass.`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/claim`;
|
|
477
|
+
const body = {
|
|
478
|
+
agentName: args.agentName,
|
|
479
|
+
runLabel: args.runLabel,
|
|
480
|
+
workerLabel: args.workerLabel,
|
|
481
|
+
takeoverStalled: args.takeoverStalled === true,
|
|
482
|
+
idempotencyKey: args.idempotencyKey,
|
|
483
|
+
};
|
|
484
|
+
const preview = validateApiBridgeRequest({
|
|
485
|
+
method: 'POST',
|
|
486
|
+
path,
|
|
487
|
+
body,
|
|
488
|
+
dryRun: args.dryRun,
|
|
489
|
+
approved: args.approved,
|
|
490
|
+
reason: args.reason,
|
|
491
|
+
idempotencyKey: args.idempotencyKey,
|
|
492
|
+
grantedScope: getGrantedScope(),
|
|
493
|
+
});
|
|
494
|
+
if (preview.dryRun) {
|
|
495
|
+
return ok({
|
|
496
|
+
dryRun: true,
|
|
497
|
+
action: `Take the current QA pass for card ${args.itemId}`,
|
|
498
|
+
note: 'No lease was created. Confirm to claim this pass atomically.',
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const data = await api('POST', path, {
|
|
502
|
+
body,
|
|
503
|
+
headers: buildMutationHeaders(args),
|
|
504
|
+
});
|
|
505
|
+
const visible = rememberWorkerLease(args.itemId, data?.data, args.idempotencyKey);
|
|
506
|
+
return ok({ claimed: true, ...visible });
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
server.tool(
|
|
511
|
+
'claim_next_work_item_qa_pass',
|
|
512
|
+
`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}`,
|
|
513
|
+
{
|
|
514
|
+
qaGate: z.enum(['Staging QA1', 'Staging QA2']).describe('Exact gate this worker pool is allowed to drain.'),
|
|
515
|
+
product: z.string().min(1).max(120).describe('Exact card product to drain, such as Adrata or Starfield, across all visible boards.'),
|
|
516
|
+
agentName: z.enum(['claude-code', 'codex', 'grok', 'human']),
|
|
517
|
+
runLabel: z.string().describe('Concise public session label, not a transcript or secret.'),
|
|
518
|
+
workerLabel: z.string().describe('Concise public worker label, not a transcript or secret.'),
|
|
519
|
+
dryRun: z.boolean().optional().default(true),
|
|
520
|
+
approved: z.boolean().optional().default(false),
|
|
521
|
+
reason: z.string().optional(),
|
|
522
|
+
idempotencyKey: z.string().optional(),
|
|
523
|
+
},
|
|
524
|
+
async (args) => {
|
|
525
|
+
const heldItemId = conflictingHeldCard(null, args.idempotencyKey);
|
|
526
|
+
if (heldItemId) {
|
|
527
|
+
return ok({
|
|
528
|
+
error: true,
|
|
529
|
+
message: `This MCP worker already holds card ${heldItemId}. Transition or release it before claiming another pass.`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const path = '/api/v1/work-boards/worker-leases/claim-next';
|
|
533
|
+
const body = {
|
|
534
|
+
qaGate: args.qaGate,
|
|
535
|
+
product: args.product,
|
|
536
|
+
agentName: args.agentName,
|
|
537
|
+
runLabel: args.runLabel,
|
|
538
|
+
workerLabel: args.workerLabel,
|
|
539
|
+
takeoverStalled: false,
|
|
540
|
+
idempotencyKey: args.idempotencyKey,
|
|
541
|
+
};
|
|
542
|
+
const preview = validateApiBridgeRequest({
|
|
543
|
+
method: 'POST',
|
|
544
|
+
path,
|
|
545
|
+
body,
|
|
546
|
+
dryRun: args.dryRun,
|
|
547
|
+
approved: args.approved,
|
|
548
|
+
reason: args.reason,
|
|
549
|
+
idempotencyKey: args.idempotencyKey,
|
|
550
|
+
grantedScope: getGrantedScope(),
|
|
551
|
+
});
|
|
552
|
+
if (preview.dryRun) {
|
|
553
|
+
return ok({
|
|
554
|
+
dryRun: true,
|
|
555
|
+
action: `Atomically take the first workable ${args.product} ${args.qaGate} pass across all visible boards`,
|
|
556
|
+
note: 'No card was claimed. Confirm to select and claim one pass.',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const data = await api('POST', path, {
|
|
560
|
+
body,
|
|
561
|
+
headers: buildMutationHeaders(args),
|
|
562
|
+
});
|
|
563
|
+
const selected = data?.data;
|
|
564
|
+
if (!selected) {
|
|
565
|
+
return ok({ claimed: false, qaGate: args.qaGate, product: args.product, note: `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.` });
|
|
566
|
+
}
|
|
567
|
+
const visible = rememberWorkerLease(selected.workItemId, selected.grant, args.idempotencyKey);
|
|
568
|
+
return ok({
|
|
569
|
+
claimed: true,
|
|
570
|
+
workItemId: selected.workItemId,
|
|
571
|
+
boardId: selected.boardId,
|
|
572
|
+
boardName: selected.boardName,
|
|
573
|
+
title: selected.title,
|
|
574
|
+
product: selected.product,
|
|
575
|
+
selectionReason: selected.selectionReason,
|
|
576
|
+
...visible,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
server.tool(
|
|
582
|
+
'heartbeat_work_item_qa_pass',
|
|
583
|
+
`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}`,
|
|
584
|
+
{
|
|
585
|
+
itemId: z.string().describe('Card id returned by a winning claim in this MCP session.'),
|
|
586
|
+
dryRun: z.boolean().optional().default(true),
|
|
587
|
+
approved: z.boolean().optional().default(false),
|
|
588
|
+
reason: z.string().optional(),
|
|
589
|
+
idempotencyKey: z.string().optional(),
|
|
590
|
+
},
|
|
591
|
+
async (args) => {
|
|
592
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/heartbeat`;
|
|
593
|
+
const preview = validateApiBridgeRequest({
|
|
594
|
+
method: 'POST',
|
|
595
|
+
path,
|
|
596
|
+
dryRun: args.dryRun,
|
|
597
|
+
approved: args.approved,
|
|
598
|
+
reason: args.reason,
|
|
599
|
+
idempotencyKey: args.idempotencyKey,
|
|
600
|
+
grantedScope: getGrantedScope(),
|
|
601
|
+
});
|
|
602
|
+
if (preview.dryRun) {
|
|
603
|
+
return ok({
|
|
604
|
+
dryRun: true,
|
|
605
|
+
action: `Keep card ${args.itemId} actively held by this worker`,
|
|
606
|
+
note: 'The lease expiry is unchanged until this write is confirmed.',
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const headers = workerLeaseHeaders(args.itemId);
|
|
610
|
+
if (!headers) {
|
|
611
|
+
return ok({
|
|
612
|
+
error: true,
|
|
613
|
+
message: 'This MCP session has no capability for that card. Claim the QA pass first.',
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const data = await api('POST', path, {
|
|
617
|
+
headers: { ...buildMutationHeaders(args), ...headers },
|
|
618
|
+
});
|
|
619
|
+
return ok({ workerLease: data?.data });
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
server.tool(
|
|
624
|
+
'release_work_item_qa_pass',
|
|
625
|
+
`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}`,
|
|
626
|
+
{
|
|
627
|
+
itemId: z.string().describe('Card id returned by a winning claim in this MCP session.'),
|
|
628
|
+
releaseKind: z.enum(['released', 'handoff']).optional().default('released'),
|
|
629
|
+
dryRun: z.boolean().optional().default(true),
|
|
630
|
+
approved: z.boolean().optional().default(false),
|
|
631
|
+
reason: z.string().optional(),
|
|
632
|
+
idempotencyKey: z.string().optional(),
|
|
633
|
+
},
|
|
634
|
+
async (args) => {
|
|
635
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/release`;
|
|
636
|
+
const body = { releaseKind: args.releaseKind ?? 'released' };
|
|
637
|
+
const preview = validateApiBridgeRequest({
|
|
638
|
+
method: 'POST',
|
|
639
|
+
path,
|
|
640
|
+
body,
|
|
641
|
+
dryRun: args.dryRun,
|
|
642
|
+
approved: args.approved,
|
|
643
|
+
reason: args.reason,
|
|
644
|
+
idempotencyKey: args.idempotencyKey,
|
|
645
|
+
grantedScope: getGrantedScope(),
|
|
646
|
+
});
|
|
647
|
+
if (preview.dryRun) {
|
|
648
|
+
return ok({
|
|
649
|
+
dryRun: true,
|
|
650
|
+
action: `${body.releaseKind === 'handoff' ? 'Hand off' : 'Release'} card ${args.itemId}`,
|
|
651
|
+
note: 'The active worker remains unchanged until this write is confirmed.',
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const leaseHeaders = workerLeaseHeaders(args.itemId);
|
|
655
|
+
if (!leaseHeaders) {
|
|
656
|
+
return ok({
|
|
657
|
+
error: true,
|
|
658
|
+
message: 'This MCP session has no capability for that card. Nothing was released.',
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const release = await releaseHeldPass(args, body.releaseKind, leaseHeaders);
|
|
662
|
+
return ok({ released: true, releaseKind: body.releaseKind, ...release });
|
|
663
|
+
}
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
server.tool(
|
|
667
|
+
'record_work_item_qa_failure_and_release',
|
|
668
|
+
`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}`,
|
|
669
|
+
{
|
|
670
|
+
itemId: z.string().describe('Card currently claimed by this MCP worker.'),
|
|
671
|
+
disposition: z.enum(['failed_criterion', 'blocked_dependency']),
|
|
672
|
+
criterionId: z.string().optional().describe('Required for failed_criterion; the exact acceptance criterion contradicted.'),
|
|
673
|
+
details: z.string().describe('Concrete observed failure or blocker and the recovery needed. Stored on the card.'),
|
|
674
|
+
dryRun: z.boolean().optional().default(true),
|
|
675
|
+
approved: z.boolean().optional().default(false),
|
|
676
|
+
reason: z.string().optional(),
|
|
677
|
+
idempotencyKey: z.string().optional().describe('Required live base key; child writes derive stable endpoint-specific keys.'),
|
|
678
|
+
},
|
|
679
|
+
async (args) => {
|
|
680
|
+
if (!args.details?.trim()) {
|
|
681
|
+
return ok({ error: true, message: 'details must name the observed failure or blocker.' });
|
|
682
|
+
}
|
|
683
|
+
if (args.disposition === 'failed_criterion' && !args.criterionId) {
|
|
684
|
+
return ok({ error: true, message: 'criterionId is required for a failed criterion.' });
|
|
685
|
+
}
|
|
686
|
+
const preview = validateApiBridgeRequest({
|
|
687
|
+
method: 'POST',
|
|
688
|
+
path: `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`,
|
|
689
|
+
body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details },
|
|
690
|
+
dryRun: args.dryRun, approved: args.approved, reason: args.reason,
|
|
691
|
+
idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
|
|
692
|
+
});
|
|
693
|
+
if (preview.dryRun) {
|
|
694
|
+
return ok({
|
|
695
|
+
...preview,
|
|
696
|
+
wouldRecordQaFailure: {
|
|
697
|
+
itemId: args.itemId,
|
|
698
|
+
disposition: args.disposition,
|
|
699
|
+
criterionId: args.criterionId ?? null,
|
|
700
|
+
details: args.details,
|
|
701
|
+
},
|
|
702
|
+
note: args.disposition === 'failed_criterion'
|
|
703
|
+
? 'The card will stay in its QA column, be flagged and un-ticked, then release the pass while waiting for a fixed deployed build.'
|
|
704
|
+
: 'The card will be flagged in place and the pass handed off.',
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
const leaseHeaders = workerLeaseHeaders(args.itemId);
|
|
708
|
+
if (!leaseHeaders) {
|
|
709
|
+
return ok({ error: true, message: 'This MCP session has no capability for that card. Nothing was recorded or released.' });
|
|
710
|
+
}
|
|
711
|
+
const recordKey = childIdempotencyKey(args.idempotencyKey, 'record');
|
|
712
|
+
const untickKey = childIdempotencyKey(args.idempotencyKey, 'untick');
|
|
713
|
+
const releaseKey = childIdempotencyKey(args.idempotencyKey, 'release');
|
|
714
|
+
const flagReason = args.disposition === 'failed_criterion'
|
|
715
|
+
? `QA criterion failure (${args.criterionId}): ${args.details.trim()} Waiting for a fixed deployed build before requeue.`
|
|
716
|
+
: args.details.trim();
|
|
717
|
+
// Flag first. If this composite is interrupted after any following step,
|
|
718
|
+
// claim-next still cannot put another worker into a hot loop on the same
|
|
719
|
+
// broken build. Every child is endpoint-idempotent, so a retry continues.
|
|
720
|
+
await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
|
|
721
|
+
body: { flagged: true, reason: flagReason, idempotencyKey: recordKey },
|
|
722
|
+
headers: mutationHeadersForItem({ ...args, idempotencyKey: recordKey }, args.itemId),
|
|
723
|
+
});
|
|
724
|
+
if (args.disposition === 'failed_criterion') {
|
|
725
|
+
await api('DELETE', criterionSatisfactionPath(args.itemId, args.criterionId), {
|
|
726
|
+
headers: mutationHeadersForItem({ ...args, idempotencyKey: untickKey }, args.itemId),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
const releaseKind = args.disposition === 'failed_criterion' ? 'released' : 'handoff';
|
|
730
|
+
const release = await releaseHeldPass(
|
|
731
|
+
{ ...args, idempotencyKey: releaseKey },
|
|
732
|
+
releaseKind,
|
|
733
|
+
leaseHeaders
|
|
734
|
+
);
|
|
735
|
+
return ok({
|
|
736
|
+
recorded: true,
|
|
737
|
+
disposition: args.disposition,
|
|
738
|
+
requeued: false,
|
|
739
|
+
blocked: true,
|
|
740
|
+
waitingForFix: args.disposition === 'failed_criterion',
|
|
741
|
+
releaseKind,
|
|
742
|
+
...release,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
server.tool(
|
|
748
|
+
'requeue_work_item_qa_after_fix',
|
|
749
|
+
`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}`,
|
|
750
|
+
{
|
|
751
|
+
itemId: z.string().describe('Flagged QA card left in place by record_work_item_qa_failure_and_release.'),
|
|
752
|
+
failedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the build that failed.'),
|
|
753
|
+
deployedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the newer deployed descendant build.'),
|
|
754
|
+
details: z.string().describe('How ancestry and deployment were verified, including the fix PR or deployment reference.'),
|
|
755
|
+
dryRun: z.boolean().optional().default(true),
|
|
756
|
+
approved: z.boolean().optional().default(false),
|
|
757
|
+
reason: z.string().optional(),
|
|
758
|
+
idempotencyKey: z.string().optional(),
|
|
759
|
+
},
|
|
760
|
+
async (args) => {
|
|
761
|
+
const exactSha = (value) => /^[0-9a-f]{40}$/.test(value ?? '');
|
|
762
|
+
if (!exactSha(args.failedBuildSha) || !exactSha(args.deployedBuildSha)) {
|
|
763
|
+
return ok({ error: true, message: 'Both build SHAs must be exact lowercase 40-character Git SHAs.' });
|
|
764
|
+
}
|
|
765
|
+
if (args.failedBuildSha === args.deployedBuildSha) {
|
|
766
|
+
return ok({ error: true, message: 'The unchanged failed build cannot be requeued.' });
|
|
767
|
+
}
|
|
768
|
+
if (!args.details?.trim()) {
|
|
769
|
+
return ok({ error: true, message: 'details must name the ancestry check and deployment/fix reference.' });
|
|
770
|
+
}
|
|
771
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
|
|
772
|
+
const clearReason = `QA requeue: deployed ${args.deployedBuildSha} after failed ${args.failedBuildSha}. ${args.details.trim()}`;
|
|
773
|
+
const preview = validateApiBridgeRequest({
|
|
774
|
+
method: 'POST', path, dryRun: args.dryRun, approved: args.approved,
|
|
775
|
+
reason: args.reason, idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
|
|
776
|
+
});
|
|
777
|
+
if (preview.dryRun) {
|
|
778
|
+
return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha } });
|
|
779
|
+
}
|
|
780
|
+
const data = await api('POST', path, {
|
|
781
|
+
body: { flagged: false, reason: clearReason, idempotencyKey: args.idempotencyKey },
|
|
782
|
+
headers: buildMutationHeaders(args),
|
|
783
|
+
});
|
|
784
|
+
return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha });
|
|
785
|
+
}
|
|
786
|
+
);
|
|
787
|
+
|
|
263
788
|
server.tool(
|
|
264
789
|
'get_work_item_history',
|
|
265
790
|
'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.',
|
|
@@ -307,6 +832,20 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
307
832
|
}
|
|
308
833
|
);
|
|
309
834
|
|
|
835
|
+
server.tool(
|
|
836
|
+
'list_work_item_qa_evidence',
|
|
837
|
+
'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.',
|
|
838
|
+
{ itemId: z.string().describe('Card id.') },
|
|
839
|
+
async (args) => {
|
|
840
|
+
const data = await api(
|
|
841
|
+
'GET',
|
|
842
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence`
|
|
843
|
+
);
|
|
844
|
+
const evidence = data?.data ?? [];
|
|
845
|
+
return ok({ count: evidence.length, evidence });
|
|
846
|
+
}
|
|
847
|
+
);
|
|
848
|
+
|
|
310
849
|
server.tool(
|
|
311
850
|
'audit_work_hub',
|
|
312
851
|
`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.`,
|
|
@@ -335,10 +874,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
335
874
|
},
|
|
336
875
|
async (args) => {
|
|
337
876
|
const rollupId = args.rollupId || 'all';
|
|
338
|
-
const data = await api(
|
|
339
|
-
'GET',
|
|
340
|
-
`/api/v1/work-board-rollups/${encodeURIComponent(rollupId)}`
|
|
341
|
-
);
|
|
877
|
+
const data = await api('GET', `/api/v1/work-board-rollups/${encodeURIComponent(rollupId)}`);
|
|
342
878
|
return ok({ rollup: data?.data });
|
|
343
879
|
}
|
|
344
880
|
);
|
|
@@ -356,7 +892,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
356
892
|
|
|
357
893
|
server.tool(
|
|
358
894
|
'get_work_item_comments',
|
|
359
|
-
|
|
895
|
+
"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.",
|
|
360
896
|
{
|
|
361
897
|
itemId: z.string().describe('Card id.'),
|
|
362
898
|
},
|
|
@@ -370,7 +906,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
370
906
|
count: comments.length,
|
|
371
907
|
comments,
|
|
372
908
|
howToRead:
|
|
373
|
-
'
|
|
909
|
+
'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.',
|
|
374
910
|
});
|
|
375
911
|
}
|
|
376
912
|
);
|
|
@@ -379,6 +915,175 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
379
915
|
// WRITES
|
|
380
916
|
// =========================================================================
|
|
381
917
|
|
|
918
|
+
server.tool(
|
|
919
|
+
'attach_work_item_qa_evidence',
|
|
920
|
+
`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}`,
|
|
921
|
+
{
|
|
922
|
+
itemId: z.string().describe('Card id returned by this MCP session’s winning QA claim.'),
|
|
923
|
+
criterionId: z.string().optional().describe('Acceptance criterion this recording proves. Required by the API when the card has criteria.'),
|
|
924
|
+
filePath: z.string().describe('Local path to the finalized PNG, JPEG, WebP, GIF, MP4, or WebM. The path and bytes stay process-private.'),
|
|
925
|
+
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'video/mp4', 'video/webm']),
|
|
926
|
+
environment: z.enum(['local', 'staging', 'production']),
|
|
927
|
+
declaredBuildSha: z.string().describe('Exact lowercase 40-character deployed Git SHA tested by this recording.'),
|
|
928
|
+
declaredReviewerAgent: z.string().describe('Agent/runtime declaration shown on the receipt; never invent an unknown value.'),
|
|
929
|
+
declaredReviewerModel: z.string().describe('Model declaration shown on the receipt; use an honest unrecorded label rather than guessing.'),
|
|
930
|
+
outcome: z.enum(['pass', 'fail']),
|
|
931
|
+
summary: z.string().describe('Short synthetic-safe result summary. Never paste a transcript, token, customer data, or sensitive URL.'),
|
|
932
|
+
durationMs: z.number().int().positive().optional().describe('Final media duration for video, measured from the finalized file; omit for images.'),
|
|
933
|
+
checkpoints: z.array(z.object({
|
|
934
|
+
offsetMs: z.number().int().nonnegative(),
|
|
935
|
+
label: z.string(),
|
|
936
|
+
})).optional().describe('Ordered video checkpoints beginning at 0; omit for images.'),
|
|
937
|
+
consoleErrorCount: z.number().int().nonnegative(),
|
|
938
|
+
pageErrorCount: z.number().int().nonnegative(),
|
|
939
|
+
networkErrorCount: z.number().int().nonnegative(),
|
|
940
|
+
redactionConfirmed: z.literal(true).describe('Confirms synthetic/approved data and no visible credentials, tokens, customer data, or sensitive query values.'),
|
|
941
|
+
dryRun: z.boolean().optional().default(true),
|
|
942
|
+
approved: z.boolean().optional().default(false),
|
|
943
|
+
reason: z.string().optional().describe('Required for a live attachment: what journey was recorded and why it belongs on this criterion.'),
|
|
944
|
+
idempotencyKey: z.string().optional().describe('Required for a live attachment. Reuse the SAME key on retry.'),
|
|
945
|
+
},
|
|
946
|
+
async (args) => {
|
|
947
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/uploads`;
|
|
948
|
+
const preview = validateApiBridgeRequest({
|
|
949
|
+
method: 'POST',
|
|
950
|
+
path,
|
|
951
|
+
dryRun: args.dryRun,
|
|
952
|
+
approved: args.approved,
|
|
953
|
+
reason: args.reason,
|
|
954
|
+
idempotencyKey: args.idempotencyKey,
|
|
955
|
+
grantedScope: getGrantedScope(),
|
|
956
|
+
});
|
|
957
|
+
if (preview.dryRun) {
|
|
958
|
+
return ok({
|
|
959
|
+
...preview,
|
|
960
|
+
wouldAttachQaEvidence: {
|
|
961
|
+
itemId: args.itemId,
|
|
962
|
+
criterionId: args.criterionId ?? null,
|
|
963
|
+
fileName: basename(String(args.filePath || '')),
|
|
964
|
+
mimeType: args.mimeType,
|
|
965
|
+
environment: args.environment,
|
|
966
|
+
declaredBuildSha: args.declaredBuildSha,
|
|
967
|
+
outcome: args.outcome,
|
|
968
|
+
},
|
|
969
|
+
note: 'The local file has not been read or uploaded. Its path and bytes will remain process-private after approval.',
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const leaseHeaders = workerLeaseHeaders(args.itemId);
|
|
974
|
+
if (!leaseHeaders) {
|
|
975
|
+
return ok({
|
|
976
|
+
error: true,
|
|
977
|
+
message: 'This MCP session has no capability for that card. Claim the QA pass before attaching evidence.',
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
const local = await loadQaEvidenceFile(args.filePath);
|
|
981
|
+
const maxBytes = args.mimeType.startsWith('image/')
|
|
982
|
+
? MAX_QA_IMAGE_BYTES
|
|
983
|
+
: MAX_QA_VIDEO_BYTES;
|
|
984
|
+
if (local.sizeBytes <= 0 || local.sizeBytes > maxBytes) {
|
|
985
|
+
throw new Error(`QA ${args.mimeType.startsWith('image/') ? 'image' : 'video'} exceeds its governed size limit.`);
|
|
986
|
+
}
|
|
987
|
+
const request = {
|
|
988
|
+
acceptanceCriterionId: args.criterionId ?? null,
|
|
989
|
+
environment: args.environment,
|
|
990
|
+
declaredBuildSha: args.declaredBuildSha,
|
|
991
|
+
fileName: local.fileName,
|
|
992
|
+
mimeType: args.mimeType,
|
|
993
|
+
sizeBytes: local.sizeBytes,
|
|
994
|
+
sha256: local.sha256,
|
|
995
|
+
declaredReviewerAgent: args.declaredReviewerAgent,
|
|
996
|
+
declaredReviewerModel: args.declaredReviewerModel,
|
|
997
|
+
outcome: args.outcome,
|
|
998
|
+
summary: args.summary,
|
|
999
|
+
declaredDurationMs: args.durationMs ?? null,
|
|
1000
|
+
checkpoints: args.checkpoints ?? [],
|
|
1001
|
+
declaredConsoleErrorCount: args.consoleErrorCount,
|
|
1002
|
+
declaredPageErrorCount: args.pageErrorCount,
|
|
1003
|
+
declaredNetworkErrorCount: args.networkErrorCount,
|
|
1004
|
+
redactionConfirmed: args.redactionConfirmed === true,
|
|
1005
|
+
};
|
|
1006
|
+
const initiateHeaders = { ...buildMutationHeaders(args), ...leaseHeaders };
|
|
1007
|
+
const initiated = await api('POST', path, { body: request, headers: initiateHeaders });
|
|
1008
|
+
const ticket = initiated?.data;
|
|
1009
|
+
if (!ticket?.id || !ticket?.uploadUrl || !ticket?.headers) {
|
|
1010
|
+
throw new Error('The API returned an incomplete private QA evidence upload capability.');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const heartbeatPath = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/worker-lease/heartbeat`;
|
|
1014
|
+
// Initiation owns the caller's replay key. Heartbeat and completion are
|
|
1015
|
+
// separate resources with their own server-side retry semantics, so they
|
|
1016
|
+
// receive only the private lease capability rather than reusing one
|
|
1017
|
+
// Idempotency-Key across three endpoints.
|
|
1018
|
+
await api('POST', heartbeatPath, { headers: leaseHeaders });
|
|
1019
|
+
try {
|
|
1020
|
+
await uploadQaEvidenceFile(ticket, local.bytes);
|
|
1021
|
+
} catch {
|
|
1022
|
+
throw new Error(`QA evidence ${ticket.id} could not be uploaded to private storage.`);
|
|
1023
|
+
}
|
|
1024
|
+
await api('POST', heartbeatPath, { headers: leaseHeaders });
|
|
1025
|
+
|
|
1026
|
+
let completed;
|
|
1027
|
+
try {
|
|
1028
|
+
completed = await api(
|
|
1029
|
+
'POST',
|
|
1030
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/${encodeURIComponent(ticket.id)}/complete`,
|
|
1031
|
+
{ headers: leaseHeaders }
|
|
1032
|
+
);
|
|
1033
|
+
} catch {
|
|
1034
|
+
throw new Error(`QA evidence ${ticket.id} could not be finalized by Adrata.`);
|
|
1035
|
+
}
|
|
1036
|
+
return ok({ attached: true, evidence: completed?.data });
|
|
1037
|
+
}
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
server.tool(
|
|
1041
|
+
'verify_work_item_qa_evidence_playback',
|
|
1042
|
+
`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}`,
|
|
1043
|
+
{
|
|
1044
|
+
itemId: z.string().describe('Card id currently claimed by this MCP worker.'),
|
|
1045
|
+
evidenceId: z.string().describe('Video evidence receipt id actually reopened in the signed-in card UI.'),
|
|
1046
|
+
contentLoaded: z.literal(true).describe('True only after the video rendered usable media.'),
|
|
1047
|
+
playStarted: z.literal(true).describe('True only after playback visibly started.'),
|
|
1048
|
+
seekCompleted: z.literal(true).describe('True only after seeking to another point completed.'),
|
|
1049
|
+
fullscreenEntered: z.literal(true).describe('True only after the video entered native or in-page fullscreen.'),
|
|
1050
|
+
dryRun: z.boolean().optional().default(true),
|
|
1051
|
+
approved: z.boolean().optional().default(false),
|
|
1052
|
+
reason: z.string().optional().describe('Required live audit reason naming the playback review.'),
|
|
1053
|
+
idempotencyKey: z.string().optional().describe('Required live key. Reuse the SAME key only for the same playback review.'),
|
|
1054
|
+
},
|
|
1055
|
+
async (args) => {
|
|
1056
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/qa-evidence/${encodeURIComponent(args.evidenceId)}/playback-verifications`;
|
|
1057
|
+
const body = {
|
|
1058
|
+
contentLoaded: args.contentLoaded,
|
|
1059
|
+
playStarted: args.playStarted,
|
|
1060
|
+
seekCompleted: args.seekCompleted,
|
|
1061
|
+
fullscreenEntered: args.fullscreenEntered,
|
|
1062
|
+
};
|
|
1063
|
+
const preview = validateApiBridgeRequest({
|
|
1064
|
+
method: 'POST', path, body, dryRun: args.dryRun, approved: args.approved,
|
|
1065
|
+
reason: args.reason, idempotencyKey: args.idempotencyKey,
|
|
1066
|
+
grantedScope: getGrantedScope(),
|
|
1067
|
+
});
|
|
1068
|
+
if (preview.dryRun) {
|
|
1069
|
+
return ok({
|
|
1070
|
+
...preview,
|
|
1071
|
+
wouldVerifyPlayback: { itemId: args.itemId, evidenceId: args.evidenceId },
|
|
1072
|
+
note: 'No receipt was written. Confirm only after the signed-in video loaded, played, sought, and entered fullscreen.',
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
const leaseHeaders = workerLeaseHeaders(args.itemId);
|
|
1076
|
+
if (!leaseHeaders) {
|
|
1077
|
+
return ok({ error: true, message: 'This MCP session has no capability for that card. Claim the QA pass before recording playback.' });
|
|
1078
|
+
}
|
|
1079
|
+
const data = await api('POST', path, {
|
|
1080
|
+
body,
|
|
1081
|
+
headers: { ...buildMutationHeaders(args), ...leaseHeaders },
|
|
1082
|
+
});
|
|
1083
|
+
return ok({ playbackVerification: data?.data });
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
|
|
382
1087
|
server.tool(
|
|
383
1088
|
'set_work_board_archived',
|
|
384
1089
|
`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}`,
|
|
@@ -387,7 +1092,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
387
1092
|
archived: z.boolean().describe('true hides the board; false restores it.'),
|
|
388
1093
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
|
|
389
1094
|
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
390
|
-
reason: z
|
|
1095
|
+
reason: z
|
|
1096
|
+
.string()
|
|
1097
|
+
.optional()
|
|
1098
|
+
.describe('Required for a live change: why this board is being hidden or restored.'),
|
|
391
1099
|
idempotencyKey: z.string().optional().describe('Required for a live change.'),
|
|
392
1100
|
},
|
|
393
1101
|
async (args) => {
|
|
@@ -444,7 +1152,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
|
|
|
444
1152
|
.describe('Positive ceiling, or null to clear it (unlimited).'),
|
|
445
1153
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false to change it.'),
|
|
446
1154
|
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
447
|
-
reason: z
|
|
1155
|
+
reason: z
|
|
1156
|
+
.string()
|
|
1157
|
+
.optional()
|
|
1158
|
+
.describe('Required for a live change: why this capacity is right.'),
|
|
448
1159
|
idempotencyKey: z.string().optional().describe('Required for a live change. Reuse on retry.'),
|
|
449
1160
|
},
|
|
450
1161
|
async (args) => {
|
|
@@ -487,7 +1198,9 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
487
1198
|
itemId: z.string().describe('Card id to move.'),
|
|
488
1199
|
toColumnId: z
|
|
489
1200
|
.string()
|
|
490
|
-
.describe(
|
|
1201
|
+
.describe(
|
|
1202
|
+
'Target column id. Must be a column on the SAME board — get it from get_work_board.'
|
|
1203
|
+
),
|
|
491
1204
|
position: z
|
|
492
1205
|
.number()
|
|
493
1206
|
.optional()
|
|
@@ -516,6 +1229,51 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
516
1229
|
.string()
|
|
517
1230
|
.optional()
|
|
518
1231
|
.describe('Required for a live move. Reuse the SAME key on retry; the server replays.'),
|
|
1232
|
+
receipt: z
|
|
1233
|
+
.object({
|
|
1234
|
+
commitSha: z
|
|
1235
|
+
.string()
|
|
1236
|
+
.optional()
|
|
1237
|
+
.describe('The commit or merge SHA the claim rests on — 7 to 40 hex characters.'),
|
|
1238
|
+
pullRequestNumber: z.number().int().optional().describe('The PR number.'),
|
|
1239
|
+
pullRequestUrl: z.string().optional().describe('The PR URL (https).'),
|
|
1240
|
+
repositoryFullName: z.string().optional().describe('owner/name.'),
|
|
1241
|
+
ciProvider: z
|
|
1242
|
+
.enum(['github_actions', 'gitlab_ci', 'local'])
|
|
1243
|
+
.optional()
|
|
1244
|
+
.describe(
|
|
1245
|
+
'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.'
|
|
1246
|
+
),
|
|
1247
|
+
ciRunId: z
|
|
1248
|
+
.string()
|
|
1249
|
+
.optional()
|
|
1250
|
+
.describe(
|
|
1251
|
+
'The CI or test run BY ID, so a reader can re-fetch it instead of believing you.'
|
|
1252
|
+
),
|
|
1253
|
+
ciRunUrl: z
|
|
1254
|
+
.string()
|
|
1255
|
+
.optional()
|
|
1256
|
+
.describe('Where to fetch it, when the URL is not derivable.'),
|
|
1257
|
+
environment: z
|
|
1258
|
+
.enum(['local', 'staging', 'production'])
|
|
1259
|
+
.optional()
|
|
1260
|
+
.describe('Where the check ran, if it ran against a running system.'),
|
|
1261
|
+
verifiedUrl: z.string().optional().describe('The exact URL that was checked.'),
|
|
1262
|
+
sessionRef: z
|
|
1263
|
+
.string()
|
|
1264
|
+
.optional()
|
|
1265
|
+
.describe(
|
|
1266
|
+
'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.'
|
|
1267
|
+
),
|
|
1268
|
+
sessionRefKind: z
|
|
1269
|
+
.enum(['claude_session_id', 'codex_session_id', 'sha256'])
|
|
1270
|
+
.optional()
|
|
1271
|
+
.describe('What kind of pointer sessionRef is. Required alongside it.'),
|
|
1272
|
+
})
|
|
1273
|
+
.optional()
|
|
1274
|
+
.describe(
|
|
1275
|
+
'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.'
|
|
1276
|
+
),
|
|
519
1277
|
},
|
|
520
1278
|
async (args) => {
|
|
521
1279
|
if (args.force === true && args.claim !== true) {
|
|
@@ -539,10 +1297,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
539
1297
|
grantedScope: getGrantedScope(),
|
|
540
1298
|
});
|
|
541
1299
|
if (preview?.dryRun) {
|
|
542
|
-
const itemData = await api(
|
|
543
|
-
'GET',
|
|
544
|
-
`/api/v1/work-items/${encodeURIComponent(args.itemId)}`
|
|
545
|
-
);
|
|
1300
|
+
const itemData = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`);
|
|
546
1301
|
const item = itemData?.data;
|
|
547
1302
|
const boardData = await api(
|
|
548
1303
|
'GET',
|
|
@@ -553,7 +1308,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
553
1308
|
if (!target) {
|
|
554
1309
|
return ok({
|
|
555
1310
|
error: true,
|
|
556
|
-
message:
|
|
1311
|
+
message: "toColumnId is not a column on this card's board.",
|
|
557
1312
|
});
|
|
558
1313
|
}
|
|
559
1314
|
const count = (board.items ?? []).filter(
|
|
@@ -567,6 +1322,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
567
1322
|
position: args.position,
|
|
568
1323
|
claim: args.claim === true,
|
|
569
1324
|
force: args.force === true,
|
|
1325
|
+
receipt: args.receipt !== undefined,
|
|
570
1326
|
},
|
|
571
1327
|
wip: projectColumnWip({
|
|
572
1328
|
limit: target.wipLimit,
|
|
@@ -577,6 +1333,19 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
577
1333
|
});
|
|
578
1334
|
}
|
|
579
1335
|
|
|
1336
|
+
// A same-column move is only a reorder: the QA dwell and server lease
|
|
1337
|
+
// remain open. Read the source immediately before the governed write so
|
|
1338
|
+
// this process drops its capability only when the move truly transitions
|
|
1339
|
+
// columns. A stale capability is still rejected by the server fence, but
|
|
1340
|
+
// prematurely forgetting a live one would strand the claimed pass.
|
|
1341
|
+
let sourceColumnId = null;
|
|
1342
|
+
if (workerLeaseCapabilities.has(args.itemId)) {
|
|
1343
|
+
const beforeMove = await api(
|
|
1344
|
+
'GET',
|
|
1345
|
+
`/api/v1/work-items/${encodeURIComponent(args.itemId)}`
|
|
1346
|
+
);
|
|
1347
|
+
sourceColumnId = beforeMove?.data?.columnId ?? null;
|
|
1348
|
+
}
|
|
580
1349
|
const data = await api('POST', path, {
|
|
581
1350
|
body: {
|
|
582
1351
|
toColumnId: args.toColumnId,
|
|
@@ -587,13 +1356,22 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
587
1356
|
// about the assignee at all.
|
|
588
1357
|
claim: args.claim === true ? true : undefined,
|
|
589
1358
|
force: args.force === true ? true : undefined,
|
|
1359
|
+
receipt: args.receipt,
|
|
590
1360
|
},
|
|
591
|
-
headers:
|
|
1361
|
+
headers: mutationHeadersForItem(args, args.itemId),
|
|
592
1362
|
});
|
|
593
1363
|
const item = data?.data;
|
|
1364
|
+
if (sourceColumnId !== args.toColumnId) {
|
|
1365
|
+
workerLeaseCapabilities.delete(args.itemId);
|
|
1366
|
+
}
|
|
594
1367
|
return ok({
|
|
595
1368
|
moved: true,
|
|
596
1369
|
claimed: args.claim === true,
|
|
1370
|
+
// Echoed as a BOOLEAN, never as the receipt's contents. What the agent
|
|
1371
|
+
// needs to know is whether the evidence was accepted; repeating a
|
|
1372
|
+
// session pointer back into a conversation transcript is the leak this
|
|
1373
|
+
// whole field is shaped to avoid.
|
|
1374
|
+
receiptRecorded: args.receipt !== undefined,
|
|
597
1375
|
// Read back from the server rather than echoed from the request, because
|
|
598
1376
|
// the two halves of a claim are decided server-side and an agent that
|
|
599
1377
|
// assumed "claimed" meant "mine now" would report a QA pick-up as having
|
|
@@ -605,6 +1383,117 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
|
|
|
605
1383
|
}
|
|
606
1384
|
);
|
|
607
1385
|
|
|
1386
|
+
server.tool(
|
|
1387
|
+
'transfer_work_item_between_boards',
|
|
1388
|
+
`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}`,
|
|
1389
|
+
{
|
|
1390
|
+
itemId: z.string().describe('Existing card id. It is preserved; no new card is created.'),
|
|
1391
|
+
toBoardId: z.string().describe('Destination operating-board id from list_work_boards.'),
|
|
1392
|
+
toColumnId: z
|
|
1393
|
+
.string()
|
|
1394
|
+
.describe('Destination column id from get_work_board(toBoardId). Must belong to toBoardId.'),
|
|
1395
|
+
position: z
|
|
1396
|
+
.number()
|
|
1397
|
+
.optional()
|
|
1398
|
+
.describe('Sort position within the target column. Omit to append.'),
|
|
1399
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live transfer.'),
|
|
1400
|
+
approved: z.boolean().optional().describe('Required true for a live transfer.'),
|
|
1401
|
+
reason: z
|
|
1402
|
+
.string()
|
|
1403
|
+
.optional()
|
|
1404
|
+
.describe('Required for a live transfer: why this operating board is canonical.'),
|
|
1405
|
+
idempotencyKey: z
|
|
1406
|
+
.string()
|
|
1407
|
+
.optional()
|
|
1408
|
+
.describe('Required for a live transfer. Reuse the SAME key on retry.'),
|
|
1409
|
+
receipt: z
|
|
1410
|
+
.object({
|
|
1411
|
+
commitSha: z.string().optional(),
|
|
1412
|
+
pullRequestNumber: z.number().int().optional(),
|
|
1413
|
+
pullRequestUrl: z.string().optional(),
|
|
1414
|
+
repositoryFullName: z.string().optional(),
|
|
1415
|
+
ciProvider: z.enum(['github_actions', 'gitlab_ci', 'local']).optional(),
|
|
1416
|
+
ciRunId: z.string().optional(),
|
|
1417
|
+
ciRunUrl: z.string().optional(),
|
|
1418
|
+
environment: z.enum(['local', 'staging', 'production']).optional(),
|
|
1419
|
+
verifiedUrl: z.string().optional(),
|
|
1420
|
+
sessionRef: z.string().optional(),
|
|
1421
|
+
sessionRefKind: z
|
|
1422
|
+
.enum(['claude_session_id', 'codex_session_id', 'sha256'])
|
|
1423
|
+
.optional(),
|
|
1424
|
+
})
|
|
1425
|
+
.optional()
|
|
1426
|
+
.describe('Optional structured references supporting the reconciliation decision.'),
|
|
1427
|
+
},
|
|
1428
|
+
async (args) => {
|
|
1429
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
1430
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
1431
|
+
|
|
1432
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/transfer`;
|
|
1433
|
+
const preview = validateApiBridgeRequest({
|
|
1434
|
+
method: 'POST',
|
|
1435
|
+
path,
|
|
1436
|
+
dryRun: args.dryRun,
|
|
1437
|
+
approved: args.approved,
|
|
1438
|
+
reason: args.reason,
|
|
1439
|
+
idempotencyKey: args.idempotencyKey,
|
|
1440
|
+
grantedScope: getGrantedScope(),
|
|
1441
|
+
});
|
|
1442
|
+
if (preview?.dryRun) {
|
|
1443
|
+
const [itemData, boardData] = await Promise.all([
|
|
1444
|
+
api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}`),
|
|
1445
|
+
api('GET', `/api/v1/work-boards/${encodeURIComponent(args.toBoardId)}`),
|
|
1446
|
+
]);
|
|
1447
|
+
const item = itemData?.data;
|
|
1448
|
+
const board = boardData?.data;
|
|
1449
|
+
if (item?.boardId === args.toBoardId) {
|
|
1450
|
+
return ok({
|
|
1451
|
+
error: true,
|
|
1452
|
+
message: 'The card already belongs to toBoardId; use move_work_item for same-board movement.',
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
const target = board?.columns?.find((column) => column.id === args.toColumnId);
|
|
1456
|
+
if (!target) {
|
|
1457
|
+
return ok({
|
|
1458
|
+
error: true,
|
|
1459
|
+
message: 'toColumnId is not a visible column on toBoardId.',
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
return ok({
|
|
1463
|
+
...preview,
|
|
1464
|
+
wouldTransfer: {
|
|
1465
|
+
itemId: args.itemId,
|
|
1466
|
+
fromBoardId: item?.boardId,
|
|
1467
|
+
toBoardId: args.toBoardId,
|
|
1468
|
+
toColumnId: args.toColumnId,
|
|
1469
|
+
position: args.position,
|
|
1470
|
+
receipt: args.receipt !== undefined,
|
|
1471
|
+
},
|
|
1472
|
+
identityPreserved: true,
|
|
1473
|
+
freshDestinationDwellRequired: true,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const data = await api('POST', path, {
|
|
1478
|
+
body: {
|
|
1479
|
+
toBoardId: args.toBoardId,
|
|
1480
|
+
toColumnId: args.toColumnId,
|
|
1481
|
+
position: args.position,
|
|
1482
|
+
reason: args.reason,
|
|
1483
|
+
idempotencyKey: args.idempotencyKey,
|
|
1484
|
+
receipt: args.receipt,
|
|
1485
|
+
},
|
|
1486
|
+
headers: buildMutationHeaders(args),
|
|
1487
|
+
});
|
|
1488
|
+
return ok({
|
|
1489
|
+
transferred: true,
|
|
1490
|
+
identityPreserved: true,
|
|
1491
|
+
receiptRecorded: args.receipt !== undefined,
|
|
1492
|
+
item: data?.data,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
);
|
|
1496
|
+
|
|
608
1497
|
server.tool(
|
|
609
1498
|
'set_work_item_tag',
|
|
610
1499
|
`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}`,
|
|
@@ -733,7 +1622,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
|
|
|
733
1622
|
|
|
734
1623
|
server.tool(
|
|
735
1624
|
'create_work_item',
|
|
736
|
-
`Create a card on a board. Lands in the named column, or
|
|
1625
|
+
`Create a card on a board. Lands in the named column, or in Backlog on a standard board when none is given.
|
|
737
1626
|
|
|
738
1627
|
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.
|
|
739
1628
|
|
|
@@ -767,9 +1656,12 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
767
1656
|
.enum(['bug', 'story', 'chore'])
|
|
768
1657
|
.optional()
|
|
769
1658
|
.describe(
|
|
770
|
-
|
|
1659
|
+
"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."
|
|
771
1660
|
),
|
|
772
|
-
columnId: z
|
|
1661
|
+
columnId: z
|
|
1662
|
+
.string()
|
|
1663
|
+
.optional()
|
|
1664
|
+
.describe('Target column. Defaults to Backlog on a standard board.'),
|
|
773
1665
|
assigneeUserId: z
|
|
774
1666
|
.string()
|
|
775
1667
|
.optional()
|
|
@@ -860,7 +1752,10 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
860
1752
|
thenText: z.string().describe('Observable result that must follow.'),
|
|
861
1753
|
dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
|
|
862
1754
|
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
863
|
-
reason: z
|
|
1755
|
+
reason: z
|
|
1756
|
+
.string()
|
|
1757
|
+
.optional()
|
|
1758
|
+
.describe('Required for a live write: why this criterion is being added.'),
|
|
864
1759
|
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
865
1760
|
},
|
|
866
1761
|
async (args) => {
|
|
@@ -885,6 +1780,234 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
|
|
|
885
1780
|
return ok({ added: true, criterion: data?.data });
|
|
886
1781
|
}
|
|
887
1782
|
);
|
|
1783
|
+
|
|
1784
|
+
server.tool(
|
|
1785
|
+
'satisfy_work_item_acceptance_criterion',
|
|
1786
|
+
`Tick one acceptance criterion with the evidence you actually observed.
|
|
1787
|
+
|
|
1788
|
+
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:
|
|
1789
|
+
|
|
1790
|
+
• \`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.
|
|
1791
|
+
• \`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.
|
|
1792
|
+
|
|
1793
|
+
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.
|
|
1794
|
+
|
|
1795
|
+
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.
|
|
1796
|
+
|
|
1797
|
+
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.
|
|
1798
|
+
|
|
1799
|
+
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.
|
|
1800
|
+
|
|
1801
|
+
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}`,
|
|
1802
|
+
{
|
|
1803
|
+
itemId: z.string().describe('Card id.'),
|
|
1804
|
+
criterionId: z
|
|
1805
|
+
.string()
|
|
1806
|
+
.describe(
|
|
1807
|
+
'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
|
|
1808
|
+
),
|
|
1809
|
+
note: z
|
|
1810
|
+
.string()
|
|
1811
|
+
.optional()
|
|
1812
|
+
.describe(
|
|
1813
|
+
'OPTIONAL, and the most useful field here: the evidence. A build number, a PR or run link, or the caveat that makes the tick honest ("only checked on Safari"). Stored on the criterion and shown beside it, so it is what a release review reads instead of taking the tick on trust. Omit it when there is genuinely nothing to add — an invented note is worse than none.'
|
|
1814
|
+
),
|
|
1815
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to record the tick.'),
|
|
1816
|
+
approved: z.boolean().optional().describe('Required true for a live tick.'),
|
|
1817
|
+
reason: z
|
|
1818
|
+
.string()
|
|
1819
|
+
.optional()
|
|
1820
|
+
.describe(
|
|
1821
|
+
'Required for a live tick: the audit reason — how you checked this, not what the criterion already says. The evidence a human reads on the card is `note`.'
|
|
1822
|
+
),
|
|
1823
|
+
idempotencyKey: z
|
|
1824
|
+
.string()
|
|
1825
|
+
.optional()
|
|
1826
|
+
.describe('Required for a live tick. Reuse the SAME key on retry.'),
|
|
1827
|
+
},
|
|
1828
|
+
async (args) => {
|
|
1829
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
1830
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
1831
|
+
|
|
1832
|
+
const path = criterionSatisfactionPath(args.itemId, args.criterionId);
|
|
1833
|
+
const preview = validateApiBridgeRequest({
|
|
1834
|
+
method: 'POST',
|
|
1835
|
+
path,
|
|
1836
|
+
dryRun: args.dryRun,
|
|
1837
|
+
approved: args.approved,
|
|
1838
|
+
reason: args.reason,
|
|
1839
|
+
idempotencyKey: args.idempotencyKey,
|
|
1840
|
+
grantedScope: getGrantedScope(),
|
|
1841
|
+
});
|
|
1842
|
+
if (preview?.dryRun) {
|
|
1843
|
+
return ok({
|
|
1844
|
+
...preview,
|
|
1845
|
+
wouldSatisfy: {
|
|
1846
|
+
itemId: args.itemId,
|
|
1847
|
+
criterionId: args.criterionId,
|
|
1848
|
+
note: args.note ?? null,
|
|
1849
|
+
},
|
|
1850
|
+
// Said in the preview because the preview is where a caller decides
|
|
1851
|
+
// whether to go live, and the one thing it CANNOT tell them is the
|
|
1852
|
+
// answer they want. Predicting the grade here would need the card's
|
|
1853
|
+
// current column and the criterion's author, and a prediction made
|
|
1854
|
+
// from a stale read is worse than an honest refusal to predict.
|
|
1855
|
+
gradeIsDerived:
|
|
1856
|
+
'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.',
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const data = await api('POST', path, {
|
|
1861
|
+
// The note is the only field this endpoint takes, and it is optional:
|
|
1862
|
+
// `SatisfyCriterionRequest` defaults it, so an omitted note is a valid
|
|
1863
|
+
// body rather than a missing one. Sending `{}` is deliberate — a tick
|
|
1864
|
+
// with nothing to add must stay recordable, because the alternative is
|
|
1865
|
+
// an agent inventing evidence to satisfy a required field.
|
|
1866
|
+
body: args.note === undefined ? {} : { note: args.note },
|
|
1867
|
+
headers: mutationHeadersForItem(args, args.itemId),
|
|
1868
|
+
});
|
|
1869
|
+
const criterion = data?.data;
|
|
1870
|
+
return ok({
|
|
1871
|
+
satisfied: true,
|
|
1872
|
+
criterion,
|
|
1873
|
+
// The grade is the answer, and it is the server's to give. Echoing an
|
|
1874
|
+
// optimistic "verified" from the request would be this tool asserting
|
|
1875
|
+
// the exact fact the grading rule exists to withhold.
|
|
1876
|
+
grade: criterion?.grade,
|
|
1877
|
+
...(criterion?.grade === 'claimed'
|
|
1878
|
+
? {
|
|
1879
|
+
gradeNote:
|
|
1880
|
+
'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.',
|
|
1881
|
+
}
|
|
1882
|
+
: {}),
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
);
|
|
1886
|
+
|
|
1887
|
+
server.tool(
|
|
1888
|
+
'unsatisfy_work_item_acceptance_criterion',
|
|
1889
|
+
`Un-tick one acceptance criterion, back to \`unmet\`. Use it when the check turns out not to hold, when it was ticked against the wrong build, or when it was ticked from a build column and has to be re-run at the gate to count as verified.
|
|
1890
|
+
|
|
1891
|
+
This exists so that ticking is safe to do. A box nobody can clear is a box people hesitate to tick, and that hesitation is exactly how a board ends up with a full checklist that nobody has ever touched — so shipping the tick without its counterpart would have left the same problem in a new shape.
|
|
1892
|
+
|
|
1893
|
+
It clears the WHOLE circumstance together — the ticker, the column, the note and the timestamp — never just the timestamp. Otherwise the next tick would inherit somebody else's evidence: a criterion re-ticked in Aligning would keep reading as verified in QA1, attributed to a person who was not there. So the cost is real, and it is the note: the first ticker's evidence is gone. If it is worth keeping, put it on the card with comment_on_work_item BEFORE you clear it.
|
|
1894
|
+
|
|
1895
|
+
Anyone who can see the card may un-tick, including somebody undoing another person's tick. The audit reason is the only record of why, so write it for them.${GOVERNED_NOTE}`,
|
|
1896
|
+
{
|
|
1897
|
+
itemId: z.string().describe('Card id.'),
|
|
1898
|
+
criterionId: z
|
|
1899
|
+
.string()
|
|
1900
|
+
.describe(
|
|
1901
|
+
'Criterion id from list_work_item_acceptance_criteria — the `id`, not the `ordinal`.'
|
|
1902
|
+
),
|
|
1903
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false to clear the tick.'),
|
|
1904
|
+
approved: z.boolean().optional().describe('Required true for a live change.'),
|
|
1905
|
+
reason: z
|
|
1906
|
+
.string()
|
|
1907
|
+
.optional()
|
|
1908
|
+
.describe(
|
|
1909
|
+
'Required for a live change: why this tick is coming off — the check failed on a later build, it was run against the wrong environment, it needs re-running at the gate. This is the only record the original ticker will have of losing their evidence.'
|
|
1910
|
+
),
|
|
1911
|
+
idempotencyKey: z
|
|
1912
|
+
.string()
|
|
1913
|
+
.optional()
|
|
1914
|
+
.describe('Required for a live change. Reuse the SAME key on retry.'),
|
|
1915
|
+
},
|
|
1916
|
+
async (args) => {
|
|
1917
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
1918
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
1919
|
+
|
|
1920
|
+
const path = criterionSatisfactionPath(args.itemId, args.criterionId);
|
|
1921
|
+
const preview = validateApiBridgeRequest({
|
|
1922
|
+
method: 'DELETE',
|
|
1923
|
+
path,
|
|
1924
|
+
dryRun: args.dryRun,
|
|
1925
|
+
approved: args.approved,
|
|
1926
|
+
reason: args.reason,
|
|
1927
|
+
idempotencyKey: args.idempotencyKey,
|
|
1928
|
+
grantedScope: getGrantedScope(),
|
|
1929
|
+
});
|
|
1930
|
+
if (preview?.dryRun) {
|
|
1931
|
+
return ok({
|
|
1932
|
+
...preview,
|
|
1933
|
+
wouldUnsatisfy: { itemId: args.itemId, criterionId: args.criterionId },
|
|
1934
|
+
clears:
|
|
1935
|
+
'The ticker, the column the tick happened in, the evidence note, and the timestamp — all four together, so a later tick cannot inherit this one. Copy the note onto the card first if it is worth keeping.',
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const data = await api('DELETE', path, {
|
|
1940
|
+
headers: mutationHeadersForItem(args, args.itemId),
|
|
1941
|
+
});
|
|
1942
|
+
const criterion = data?.data;
|
|
1943
|
+
return ok({ unsatisfied: true, criterion, grade: criterion?.grade });
|
|
1944
|
+
}
|
|
1945
|
+
);
|
|
1946
|
+
|
|
1947
|
+
server.tool(
|
|
1948
|
+
'record_work_item_criterion_engineering_proof',
|
|
1949
|
+
`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}`,
|
|
1950
|
+
{
|
|
1951
|
+
itemId: z.string().describe('Card id.'),
|
|
1952
|
+
criterionId: z.string().describe('Criterion id, not its display ordinal.'),
|
|
1953
|
+
environment: z.enum(['staging', 'production']),
|
|
1954
|
+
outcome: z.enum(['pass', 'fail']),
|
|
1955
|
+
summary: z.string().describe('Short result summary; no raw logs or transcript content.'),
|
|
1956
|
+
buildSha: z.string().regex(/^[0-9a-f]{40}$/).describe('Exact deployed 40-character commit SHA.'),
|
|
1957
|
+
stagingSmoke: z.string().describe('The narrowest honest staging/runtime smoke performed.'),
|
|
1958
|
+
proofs: z
|
|
1959
|
+
.array(
|
|
1960
|
+
z.object({
|
|
1961
|
+
proofType: z.enum(['code', 'test', 'configuration', 'data', 'runtime']),
|
|
1962
|
+
name: z.string(),
|
|
1963
|
+
resultReference: z.string(),
|
|
1964
|
+
})
|
|
1965
|
+
)
|
|
1966
|
+
.min(1),
|
|
1967
|
+
declaredReviewerModel: z
|
|
1968
|
+
.string()
|
|
1969
|
+
.max(120)
|
|
1970
|
+
.describe('Exact model used for this review, explicitly declared; never guessed.'),
|
|
1971
|
+
dryRun: z.boolean().optional().describe('Defaults to true.'),
|
|
1972
|
+
approved: z.boolean().optional().describe('Required true for a live receipt.'),
|
|
1973
|
+
reason: z.string().optional().describe('Required audit reason for a live receipt.'),
|
|
1974
|
+
idempotencyKey: z.string().optional().describe('Required for live writes; reuse on retry.'),
|
|
1975
|
+
},
|
|
1976
|
+
async (args) => {
|
|
1977
|
+
const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
|
|
1978
|
+
if (unauditable) return ok({ error: true, message: unauditable });
|
|
1979
|
+
const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria/${encodeURIComponent(args.criterionId)}/engineering-proof`;
|
|
1980
|
+
const preview = validateApiBridgeRequest({
|
|
1981
|
+
method: 'POST',
|
|
1982
|
+
path,
|
|
1983
|
+
dryRun: args.dryRun,
|
|
1984
|
+
approved: args.approved,
|
|
1985
|
+
reason: args.reason,
|
|
1986
|
+
idempotencyKey: args.idempotencyKey,
|
|
1987
|
+
grantedScope: getGrantedScope(),
|
|
1988
|
+
});
|
|
1989
|
+
const receipt = {
|
|
1990
|
+
environment: args.environment,
|
|
1991
|
+
outcome: args.outcome,
|
|
1992
|
+
summary: args.summary,
|
|
1993
|
+
buildSha: args.buildSha,
|
|
1994
|
+
stagingSmoke: args.stagingSmoke,
|
|
1995
|
+
proofs: args.proofs,
|
|
1996
|
+
declaredReviewerModel: args.declaredReviewerModel,
|
|
1997
|
+
idempotencyKey: args.idempotencyKey,
|
|
1998
|
+
};
|
|
1999
|
+
if (preview?.dryRun) return ok({ ...preview, wouldRecord: receipt });
|
|
2000
|
+
const data = await api('POST', path, {
|
|
2001
|
+
body: receipt,
|
|
2002
|
+
headers: {
|
|
2003
|
+
...buildMutationHeaders(args),
|
|
2004
|
+
...requiredWorkerLeaseHeaders(args.itemId),
|
|
2005
|
+
},
|
|
2006
|
+
});
|
|
2007
|
+
return ok({ recorded: true, engineeringProof: data?.data });
|
|
2008
|
+
}
|
|
2009
|
+
);
|
|
2010
|
+
|
|
888
2011
|
server.tool(
|
|
889
2012
|
'comment_on_work_item',
|
|
890
2013
|
`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.
|
|
@@ -904,7 +2027,9 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
|
|
|
904
2027
|
reason: z
|
|
905
2028
|
.string()
|
|
906
2029
|
.optional()
|
|
907
|
-
.describe(
|
|
2030
|
+
.describe(
|
|
2031
|
+
'Required for a live post: why you are commenting. This is the audit reason, NOT the comment — the comment is `body`.'
|
|
2032
|
+
),
|
|
908
2033
|
idempotencyKey: z
|
|
909
2034
|
.string()
|
|
910
2035
|
.optional()
|
|
@@ -948,7 +2073,9 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
|
|
|
948
2073
|
itemId: z.string().describe('Card id.'),
|
|
949
2074
|
flagged: z
|
|
950
2075
|
.boolean()
|
|
951
|
-
.describe(
|
|
2076
|
+
.describe(
|
|
2077
|
+
'true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'
|
|
2078
|
+
),
|
|
952
2079
|
reason: z
|
|
953
2080
|
.string()
|
|
954
2081
|
.describe(
|
|
@@ -959,7 +2086,9 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
|
|
|
959
2086
|
idempotencyKey: z
|
|
960
2087
|
.string()
|
|
961
2088
|
.optional()
|
|
962
|
-
.describe(
|
|
2089
|
+
.describe(
|
|
2090
|
+
'Required for a live change. Reuse the SAME key on retry — the flag also appends a comment.'
|
|
2091
|
+
),
|
|
963
2092
|
},
|
|
964
2093
|
async (args) => {
|
|
965
2094
|
if (!args.reason || !args.reason.trim()) {
|
|
@@ -1009,19 +2138,34 @@ export const WORK_BOARD_TOOL_NAMES = [
|
|
|
1009
2138
|
'list_work_boards',
|
|
1010
2139
|
'get_work_board',
|
|
1011
2140
|
'get_work_item',
|
|
2141
|
+
'get_work_item_worker_lease',
|
|
2142
|
+
'get_work_item_worker_activity',
|
|
2143
|
+
'claim_work_item_qa_pass',
|
|
2144
|
+
'claim_next_work_item_qa_pass',
|
|
2145
|
+
'heartbeat_work_item_qa_pass',
|
|
2146
|
+
'release_work_item_qa_pass',
|
|
2147
|
+
'record_work_item_qa_failure_and_release',
|
|
2148
|
+
'requeue_work_item_qa_after_fix',
|
|
1012
2149
|
'get_work_item_history',
|
|
1013
2150
|
'get_work_item_delivery_evidence',
|
|
1014
2151
|
'audit_work_hub',
|
|
1015
2152
|
'list_work_item_acceptance_criteria',
|
|
2153
|
+
'list_work_item_qa_evidence',
|
|
1016
2154
|
'get_work_board_rollup',
|
|
1017
2155
|
'list_work_board_rollups',
|
|
1018
2156
|
'set_work_board_archived',
|
|
1019
2157
|
'set_work_board_column_wip_limit',
|
|
2158
|
+
'attach_work_item_qa_evidence',
|
|
2159
|
+
'verify_work_item_qa_evidence_playback',
|
|
1020
2160
|
'move_work_item',
|
|
2161
|
+
'transfer_work_item_between_boards',
|
|
1021
2162
|
'set_work_item_tag',
|
|
1022
2163
|
'set_work_item_kind',
|
|
1023
2164
|
'create_work_item',
|
|
1024
2165
|
'add_work_item_acceptance_criterion',
|
|
2166
|
+
'satisfy_work_item_acceptance_criterion',
|
|
2167
|
+
'record_work_item_criterion_engineering_proof',
|
|
2168
|
+
'unsatisfy_work_item_acceptance_criterion',
|
|
1025
2169
|
'get_work_item_comments',
|
|
1026
2170
|
'comment_on_work_item',
|
|
1027
2171
|
'flag_work_item',
|