@adrata/adrata-mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
2
+
1
3
  /**
2
4
  * Work-board tools for the Adrata MCP Server.
3
5
  *
@@ -110,14 +112,9 @@ export function describeUnauditableReason(reason) {
110
112
  /**
111
113
  * Why a card with no body will not be workable, or `null` if it has one.
112
114
  *
113
- * Deliberately the weakest possible check. The card model says a Task carries
114
- * acceptance criteria the artifact with two consumers, the build spec for the
115
- * agent and the test plan for QA and today those criteria live in `body`,
116
- * because there is no `acceptance_criteria` column yet. But "does this prose
117
- * contain acceptance criteria" is a judgement, not a pattern: a regex looking
118
- * for the word would pass a heading with nothing under it and fail a perfectly
119
- * good "the Export button is disabled while an export is running". Asserting it
120
- * either way would be the tool inventing a verdict.
115
+ * The body is working context: request, evidence, repro, constraints. The
116
+ * executable definition of done lives in first-class acceptance-criteria rows
117
+ * and has its own check below, so prose is never regex-classified as criteria.
121
118
  *
122
119
  * A MISSING body is a fact, so that is what this reports — as a note on the
123
120
  * dry-run preview, where a human is already deciding, and never as a refusal.
@@ -126,18 +123,35 @@ export function describeUnauditableReason(reason) {
126
123
  */
127
124
  export function describeMissingBody(body) {
128
125
  if (String(body ?? '').trim()) return undefined;
129
- return 'This card would arrive with a title and nothing else. A card is the unit of QA sign-off, so with no body it carries no acceptance criteria — nobody can validate it and no agent can build from it. Add the criteria to `body` (one checkable outcome per line), or say in the body what is still unknown.';
126
+ return 'This card would arrive with a title and no working context. Add the request, evidence, or repro to `body`, or say what is still unknown. Acceptance criteria are separate first-class records in `acceptanceCriteria`.';
127
+ }
128
+
129
+ export function describeMissingAcceptanceCriteria(criteria) {
130
+ if (Array.isArray(criteria) && criteria.length > 0) return undefined;
131
+ 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
+ }
133
+
134
+ /** One target stage's WIP state before and after a proposed move. */
135
+ export function projectColumnWip({ limit, count, cardAlreadyThere, truncated = false }) {
136
+ const projected = count + (cardAlreadyThere ? 0 : 1);
137
+ return {
138
+ limit: limit ?? null,
139
+ current: count,
140
+ projected,
141
+ overLimit: limit == null ? false : projected > limit,
142
+ completeCount: !truncated,
143
+ };
130
144
  }
131
145
 
132
146
  /**
133
147
  * Register the work-board tools.
134
148
  *
135
149
  * @param {McpServer} server - the MCP server instance (already tier-gated)
136
- * @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
150
+ * @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
137
151
  */
138
152
  export function registerWorkBoardTools(
139
153
  server,
140
- { z, api, ok, validateApiBridgeRequest, buildMutationHeaders }
154
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
141
155
  ) {
142
156
  const GOVERNED_NOTE =
143
157
  ' 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).';
@@ -262,6 +276,54 @@ Start here for "what should I work on". With includeUnassigned it also returns t
262
276
  }
263
277
  );
264
278
 
279
+ server.tool(
280
+ 'get_work_item_delivery_evidence',
281
+ `Read the four facts that must never be collapsed into one status: the card's workflow column, linked pull-request state, exact-SHA staging presence, and exact-SHA production presence. Use this before calling a card shipped or treating the Production column as proof. Unknown means Adrata has no trustworthy evidence; it never means the work is absent. This tool is read-only and does not move the card.`,
282
+ {
283
+ itemId: z.string().describe('Card id.'),
284
+ },
285
+ async (args) => {
286
+ const data = await api(
287
+ 'GET',
288
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/delivery-evidence`
289
+ );
290
+ const evidence = data?.data;
291
+ const contradictions = deliveryContradictions(evidence);
292
+ return ok({ evidence, contradictions });
293
+ }
294
+ );
295
+
296
+ server.tool(
297
+ 'list_work_item_acceptance_criteria',
298
+ 'Read the ordered, executable definition of done for one card. Each criterion says where to check, any starting state, what action to perform, and the observable result. A zero-length list is a finding: nobody has stated how QA can decide the card is done.',
299
+ { itemId: z.string().describe('Card id.') },
300
+ async (args) => {
301
+ const data = await api(
302
+ 'GET',
303
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`
304
+ );
305
+ const criteria = data?.data ?? [];
306
+ return ok({ count: criteria.length, criteria });
307
+ }
308
+ );
309
+
310
+ server.tool(
311
+ 'audit_work_hub',
312
+ `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.`,
313
+ {},
314
+ async () => {
315
+ const listed = await api('GET', '/api/v1/work-boards');
316
+ const refs = listed?.data ?? [];
317
+ const boards = await Promise.all(
318
+ refs.map(async (board) => {
319
+ const detail = await api('GET', `/api/v1/work-boards/${encodeURIComponent(board.id)}`);
320
+ return detail?.data;
321
+ })
322
+ );
323
+ return ok(auditWorkHubBoards(boards.filter(Boolean)));
324
+ }
325
+ );
326
+
265
327
  server.tool(
266
328
  'get_work_board_rollup',
267
329
  'Read several boards as one prioritisation view. Pass "all" for the implicit roll-up of every board in the workspace (it has no membership rows, so a board created a minute ago is already in it), or a roll-up id for a curated one. Each slice keeps its own board, company, staleness policy, and tag scheme — cards are ranked WITHIN their own scheme and never translated across schemes, so a P1 is never silently equated with a Critical.',
@@ -317,6 +379,103 @@ Start here for "what should I work on". With includeUnassigned it also returns t
317
379
  // WRITES
318
380
  // =========================================================================
319
381
 
382
+ server.tool(
383
+ 'set_work_board_archived',
384
+ `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}`,
385
+ {
386
+ boardId: z.string().describe('Board id from list_work_boards.'),
387
+ archived: z.boolean().describe('true hides the board; false restores it.'),
388
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live change.'),
389
+ approved: z.boolean().optional().describe('Required true for a live change.'),
390
+ reason: z.string().optional().describe('Required for a live change: why this board is being hidden or restored.'),
391
+ idempotencyKey: z.string().optional().describe('Required for a live change.'),
392
+ },
393
+ async (args) => {
394
+ const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/archived`;
395
+ const preview = validateApiBridgeRequest({
396
+ method: 'PATCH',
397
+ path,
398
+ dryRun: args.dryRun,
399
+ approved: args.approved,
400
+ reason: args.reason,
401
+ idempotencyKey: args.idempotencyKey,
402
+ grantedScope: getGrantedScope(),
403
+ });
404
+ if (preview?.dryRun) {
405
+ const data = await api('GET', `/api/v1/work-boards/${encodeURIComponent(args.boardId)}`);
406
+ const board = data?.data;
407
+ const terminalNames = new Set(['production', 'deep backlog']);
408
+ const activeCount = (board?.items ?? []).filter((item) => {
409
+ const column = board?.columns?.find((candidate) => candidate.id === item.columnId);
410
+ return column && !terminalNames.has(String(column.name).trim().toLowerCase());
411
+ }).length;
412
+ return ok({
413
+ ...preview,
414
+ wouldSetBoardArchived: {
415
+ boardId: args.boardId,
416
+ boardName: board?.name,
417
+ archived: args.archived,
418
+ visibleItemCount: board?.itemCount ?? (board?.items ?? []).length,
419
+ activeItemCount: activeCount,
420
+ completeCount: board?.truncated !== true,
421
+ },
422
+ });
423
+ }
424
+
425
+ const data = await api('PATCH', path, {
426
+ body: { archived: args.archived },
427
+ headers: buildMutationHeaders(args),
428
+ });
429
+ return ok({ boardArchiveStateSet: true, ...data?.data });
430
+ }
431
+ );
432
+
433
+ server.tool(
434
+ 'set_work_board_column_wip_limit',
435
+ `Set or clear one board column's visible WIP ceiling. Exceeding the ceiling never blocks a move: the board renders the measured count and limit in red so daily Aligning can answer for it. Only a workspace admin may change this operating policy. Production and Deep backlog cannot have limits because they are output and parking columns, not work centres.${GOVERNED_NOTE}`,
436
+ {
437
+ boardId: z.string().describe('Board id from list_work_boards.'),
438
+ columnId: z.string().describe('Column id from get_work_board.'),
439
+ wipLimit: z
440
+ .number()
441
+ .int()
442
+ .positive()
443
+ .nullable()
444
+ .describe('Positive ceiling, or null to clear it (unlimited).'),
445
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to change it.'),
446
+ approved: z.boolean().optional().describe('Required true for a live change.'),
447
+ reason: z.string().optional().describe('Required for a live change: why this capacity is right.'),
448
+ idempotencyKey: z.string().optional().describe('Required for a live change. Reuse on retry.'),
449
+ },
450
+ async (args) => {
451
+ const unauditable = args.dryRun === false ? describeUnauditableReason(args.reason) : null;
452
+ if (unauditable) return ok({ error: true, message: unauditable });
453
+
454
+ const path = `/api/v1/work-boards/${encodeURIComponent(args.boardId)}/columns/${encodeURIComponent(args.columnId)}/wip-limit`;
455
+ const preview = validateApiBridgeRequest({
456
+ method: 'PATCH',
457
+ path,
458
+ dryRun: args.dryRun,
459
+ approved: args.approved,
460
+ reason: args.reason,
461
+ idempotencyKey: args.idempotencyKey,
462
+ grantedScope: getGrantedScope(),
463
+ });
464
+ if (preview?.dryRun) {
465
+ return ok({
466
+ ...preview,
467
+ wouldSet: { boardId: args.boardId, columnId: args.columnId, wipLimit: args.wipLimit },
468
+ });
469
+ }
470
+
471
+ const data = await api('PATCH', path, {
472
+ body: { wipLimit: args.wipLimit },
473
+ headers: buildMutationHeaders(args),
474
+ });
475
+ return ok({ updated: true, column: data?.data });
476
+ }
477
+ );
478
+
320
479
  server.tool(
321
480
  'move_work_item',
322
481
  `Move a card to another column on the same board — and, with claim:true, 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.
@@ -343,7 +502,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
343
502
  .boolean()
344
503
  .optional()
345
504
  .describe(
346
- 'Take over a pass SOMEBODY ELSE IS HOLDING. Requires claim:true and a reason saying why — the reason is the only record that person will have of losing the pass mid-stage. It does NOT take the card off its owner: reassigning a card is the assignee field on update_work_item, a deliberate act, never a side effect of a move. Do not reach for this to work around a refusal; a pass somebody is running is theirs.'
505
+ 'Take over a pass SOMEBODY ELSE IS HOLDING. Requires claim:true and a reason saying why — the reason is the only record that person will have of losing the pass mid-stage. It does NOT take the card off its owner: reassigning a card is a deliberate edit to its assignee, never a side effect of a move — and there is no board tool here that does it, so reassignment is a human act on the board (or an explicit PATCH via adrata_api_request). Do not reach for this to work around a refusal; a pass somebody is running is theirs.'
347
506
  ),
348
507
  dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
349
508
  approved: z.boolean().optional().describe('Required true for a live move.'),
@@ -377,8 +536,29 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
377
536
  approved: args.approved,
378
537
  reason: args.reason,
379
538
  idempotencyKey: args.idempotencyKey,
539
+ grantedScope: getGrantedScope(),
380
540
  });
381
541
  if (preview?.dryRun) {
542
+ const itemData = await api(
543
+ 'GET',
544
+ `/api/v1/work-items/${encodeURIComponent(args.itemId)}`
545
+ );
546
+ const item = itemData?.data;
547
+ const boardData = await api(
548
+ 'GET',
549
+ `/api/v1/work-boards/${encodeURIComponent(item.boardId)}`
550
+ );
551
+ const board = boardData?.data;
552
+ const target = board?.columns?.find((column) => column.id === args.toColumnId);
553
+ if (!target) {
554
+ return ok({
555
+ error: true,
556
+ message: 'toColumnId is not a column on this card\'s board.',
557
+ });
558
+ }
559
+ const count = (board.items ?? []).filter(
560
+ (candidate) => candidate.columnId === args.toColumnId
561
+ ).length;
382
562
  return ok({
383
563
  ...preview,
384
564
  wouldMove: {
@@ -388,6 +568,12 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
388
568
  claim: args.claim === true,
389
569
  force: args.force === true,
390
570
  },
571
+ wip: projectColumnWip({
572
+ limit: target.wipLimit,
573
+ count,
574
+ cardAlreadyThere: item.columnId === args.toColumnId,
575
+ truncated: board.truncated === true,
576
+ }),
391
577
  });
392
578
  }
393
579
 
@@ -467,6 +653,7 @@ This is both halves of the developer loop. Claiming is a parameter and not a sec
467
653
  approved: args.approved,
468
654
  reason: args.reason,
469
655
  idempotencyKey: args.idempotencyKey,
656
+ grantedScope: getGrantedScope(),
470
657
  });
471
658
  if (preview?.dryRun) {
472
659
  return ok({
@@ -523,6 +710,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
523
710
  approved: args.approved,
524
711
  reason: args.reason,
525
712
  idempotencyKey: args.idempotencyKey,
713
+ grantedScope: getGrantedScope(),
526
714
  });
527
715
  if (preview?.dryRun) {
528
716
  return ok({
@@ -547,7 +735,7 @@ Pass kind:null to clear it back to UNTYPED. Untyped is a real state and is NOT t
547
735
  'create_work_item',
548
736
  `Create a card on a board. Lands in the named column, or the board's first column when none is given.
549
737
 
550
- WRITE THE ACCEPTANCE CRITERIA IN \`body\`. A card is the unit of QA sign-off, so a card that does not say what "done" means cannot be validated by QA and cannot be built from by the next agent those are the two readers of the same list. There is no separate criteria field yet: the criteria live in \`body\`, under a short "Acceptance criteria" heading, one checkable outcome per line. If you cannot write them, you do not yet understand the card well enough to file it as workable — file it with what you DO know and say in the body that the criteria are missing, rather than inventing outcomes nobody agreed to.
738
+ 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.
551
739
 
552
740
  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.
553
741
 
@@ -559,7 +747,20 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
559
747
  .string()
560
748
  .optional()
561
749
  .describe(
562
- 'The request in full AND its acceptance criteria — what QA will validate this against, one checkable outcome per line under an "Acceptance criteria" heading. Repro steps for a bug go here too. Markdown; an implementation checklist belongs here rather than in sub-cards, because there are none by design.'
750
+ 'The request, evidence, and repro/context. Markdown. The executable definition of done belongs in acceptanceCriteria, not in this prose.'
751
+ ),
752
+ acceptanceCriteria: z
753
+ .array(
754
+ z.object({
755
+ whereText: z.string().describe('Surface, environment, account, or role to check.'),
756
+ givenText: z.string().optional().describe('Starting state, when one is required.'),
757
+ whenText: z.string().describe('Action the verifier performs.'),
758
+ thenText: z.string().describe('Observable result that must follow.'),
759
+ })
760
+ )
761
+ .optional()
762
+ .describe(
763
+ 'Executable definition of done. One independently checkable outcome per entry. Omit only for a thin capture that is not ready to build.'
563
764
  ),
564
765
  product: z.string().optional().describe('Product tag — orthogonal to the board.'),
565
766
  kind: z
@@ -589,11 +790,17 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
589
790
  approved: args.approved,
590
791
  reason: args.reason,
591
792
  idempotencyKey: args.idempotencyKey,
793
+ grantedScope: getGrantedScope(),
592
794
  });
593
795
  if (preview?.dryRun) {
594
796
  return ok({
595
797
  ...preview,
596
- wouldCreate: { boardId: args.boardId, title: args.title, kind: args.kind },
798
+ wouldCreate: {
799
+ boardId: args.boardId,
800
+ title: args.title,
801
+ kind: args.kind,
802
+ acceptanceCriteria: args.acceptanceCriteria ?? [],
803
+ },
597
804
  // A statement of FACT about the body, not a guess about its contents.
598
805
  // "Does this text contain acceptance criteria" is not something a
599
806
  // regex can answer honestly, and a heuristic that half-answered it
@@ -604,6 +811,7 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
604
811
  // already deciding whether to approve, and never blocking: a refused
605
812
  // capture is how work stops reaching the board at all.
606
813
  note: describeMissingBody(args.body),
814
+ readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
607
815
  });
608
816
  }
609
817
 
@@ -620,7 +828,61 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
620
828
  },
621
829
  headers: buildMutationHeaders(args),
622
830
  });
623
- return ok({ created: true, item: data?.data });
831
+ const item = data?.data;
832
+ const criteria = [];
833
+ for (const [index, criterion] of (args.acceptanceCriteria ?? []).entries()) {
834
+ const criterionKey = `${args.idempotencyKey}:criterion:${index + 1}`;
835
+ const result = await api(
836
+ 'POST',
837
+ `/api/v1/work-items/${encodeURIComponent(item.id)}/acceptance-criteria`,
838
+ {
839
+ body: criterion,
840
+ headers: buildMutationHeaders({
841
+ ...args,
842
+ idempotencyKey: criterionKey,
843
+ }),
844
+ }
845
+ );
846
+ criteria.push(result?.data);
847
+ }
848
+ return ok({ created: true, item, criteria });
849
+ }
850
+ );
851
+
852
+ server.tool(
853
+ 'add_work_item_acceptance_criterion',
854
+ `Add one executable acceptance criterion to a card. This is grooming, not a comment: write where the check runs, the action, and the observable result. The server keeps criteria ordered and auditable.${GOVERNED_NOTE}`,
855
+ {
856
+ itemId: z.string().describe('Card id.'),
857
+ whereText: z.string().describe('Surface, environment, account, or role to check.'),
858
+ givenText: z.string().optional().describe('Starting state, when one is required.'),
859
+ whenText: z.string().describe('Action the verifier performs.'),
860
+ thenText: z.string().describe('Observable result that must follow.'),
861
+ dryRun: z.boolean().optional().describe('Defaults to true. Set false to add it.'),
862
+ approved: z.boolean().optional().describe('Required true for a live write.'),
863
+ reason: z.string().optional().describe('Required for a live write: why this criterion is being added.'),
864
+ idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
865
+ },
866
+ async (args) => {
867
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-criteria`;
868
+ const preview = validateApiBridgeRequest({
869
+ method: 'POST',
870
+ path,
871
+ ...args,
872
+ grantedScope: getGrantedScope(),
873
+ });
874
+ const criterion = {
875
+ whereText: args.whereText,
876
+ givenText: args.givenText,
877
+ whenText: args.whenText,
878
+ thenText: args.thenText,
879
+ };
880
+ if (preview?.dryRun) return ok({ ...preview, wouldAdd: { itemId: args.itemId, criterion } });
881
+ const data = await api('POST', path, {
882
+ body: criterion,
883
+ headers: buildMutationHeaders(args),
884
+ });
885
+ return ok({ added: true, criterion: data?.data });
624
886
  }
625
887
  );
626
888
  server.tool(
@@ -659,6 +921,7 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
659
921
  approved: args.approved,
660
922
  reason: args.reason,
661
923
  idempotencyKey: args.idempotencyKey,
924
+ grantedScope: getGrantedScope(),
662
925
  });
663
926
  if (preview?.dryRun) {
664
927
  return ok({ ...preview, wouldComment: { itemId: args.itemId, body: args.body } });
@@ -718,6 +981,7 @@ This is what the ship-the-card skill means by "an unworkable card is a triage pr
718
981
  // reason explains the write and `body` is the content.
719
982
  reason: args.reason,
720
983
  idempotencyKey: args.idempotencyKey,
984
+ grantedScope: getGrantedScope(),
721
985
  });
722
986
  if (preview?.dryRun) {
723
987
  return ok({
@@ -746,12 +1010,18 @@ export const WORK_BOARD_TOOL_NAMES = [
746
1010
  'get_work_board',
747
1011
  'get_work_item',
748
1012
  'get_work_item_history',
1013
+ 'get_work_item_delivery_evidence',
1014
+ 'audit_work_hub',
1015
+ 'list_work_item_acceptance_criteria',
749
1016
  'get_work_board_rollup',
750
1017
  'list_work_board_rollups',
1018
+ 'set_work_board_archived',
1019
+ 'set_work_board_column_wip_limit',
751
1020
  'move_work_item',
752
1021
  'set_work_item_tag',
753
1022
  'set_work_item_kind',
754
1023
  'create_work_item',
1024
+ 'add_work_item_acceptance_criterion',
755
1025
  'get_work_item_comments',
756
1026
  'comment_on_work_item',
757
1027
  'flag_work_item',
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure operating-health projection for Starfield boards.
3
+ *
4
+ * A board is trustworthy when its workflow claims are backed by the minimum
5
+ * facts needed to act: ownership, a work type, a definition of done, and a
6
+ * current stage. Delivery evidence is deliberately added separately because a
7
+ * column is not a deployment receipt.
8
+ */
9
+
10
+ const TERMINAL_STAGES = new Set(['production', 'deep backlog']);
11
+ // Up Next is the deliberately unowned pull buffer. Triage/Aligning are capture
12
+ // and grooming, where thin cards are allowed. Only these stages mean somebody
13
+ // has actually taken a pass and therefore require both an end-to-end owner and
14
+ // a current handler.
15
+ const ACTIVE_STAGES = new Set(['in progress', 'staging qa1', 'staging qa2']);
16
+ // A card crossing the cut line into Up Next must be executable. Production is
17
+ // included because historical evidence does not stop mattering after release.
18
+ const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'production']);
19
+
20
+ function normalized(value) {
21
+ return String(value ?? '').trim().toLowerCase();
22
+ }
23
+
24
+ function hoursSince(iso, nowMs) {
25
+ const entered = Date.parse(iso);
26
+ if (!Number.isFinite(entered)) return null;
27
+ return Math.max(0, (nowMs - entered) / 3_600_000);
28
+ }
29
+
30
+ export function deliveryContradictions(evidence) {
31
+ const contradictions = [];
32
+ const workflowStage = normalized(evidence?.workflow?.columnName);
33
+ if (workflowStage === 'production' && evidence?.production?.state !== 'live') {
34
+ contradictions.push({
35
+ code: 'production_column_without_live_evidence',
36
+ message:
37
+ 'The card is in Production, but exact-SHA production evidence is not live. Treat the column as a workflow claim, not a deployment receipt.',
38
+ });
39
+ }
40
+ if (evidence?.production?.state === 'live' && workflowStage !== 'production') {
41
+ contradictions.push({
42
+ code: 'live_evidence_outside_production_column',
43
+ message:
44
+ 'Exact-SHA production evidence is live, but the workflow card has not reached Production.',
45
+ });
46
+ }
47
+ return contradictions;
48
+ }
49
+
50
+ /**
51
+ * @param {Array<object>} boards Full board payloads from GET /work-boards/{id}.
52
+ * @param {{now?: Date|string|number}} options
53
+ */
54
+ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
55
+ const nowMs = new Date(now).getTime();
56
+ const findings = [];
57
+ const perBoard = [];
58
+ let totalCards = 0;
59
+ let openCards = 0;
60
+ let activeCards = 0;
61
+
62
+ for (const board of boards) {
63
+ const columns = new Map((board.columns ?? []).map((column) => [column.id, column]));
64
+ const boardFindings = [];
65
+ const items = board.items ?? [];
66
+ totalCards += items.length;
67
+
68
+ if (board.truncated === true) {
69
+ boardFindings.push({
70
+ code: 'board_truncated',
71
+ itemId: null,
72
+ title: null,
73
+ detail: `The board returned ${items.length} of ${board.itemCount ?? 'an unknown number of'} cards.`,
74
+ });
75
+ }
76
+
77
+ for (const item of items) {
78
+ const column = columns.get(item.columnId);
79
+ const stage = column?.name ?? 'Unknown';
80
+ const stageKey = normalized(stage);
81
+ const isTerminal = TERMINAL_STAGES.has(stageKey);
82
+ const isActive = ACTIVE_STAGES.has(stageKey);
83
+ const mustBeExecutable = EXECUTABLE_STAGES.has(stageKey);
84
+ if (!isTerminal) openCards += 1;
85
+ if (isActive) activeCards += 1;
86
+
87
+ const add = (code, detail) =>
88
+ boardFindings.push({ code, itemId: item.id, title: item.title, stage, detail });
89
+
90
+ if (mustBeExecutable && !String(item.body ?? '').trim()) {
91
+ add('missing_body', 'The card crossed the cut line without working context.');
92
+ }
93
+ if (mustBeExecutable) {
94
+ if (item.criteria?.total === 0) {
95
+ add('missing_acceptance_criteria', 'Nobody has stated a checkable definition of done.');
96
+ } else if (item.criteria === undefined) {
97
+ add('criteria_not_measured', 'This read did not include acceptance-criteria status.');
98
+ }
99
+ if (!item.kind) add('missing_kind', 'The card is not classified as a story, bug, or chore.');
100
+ }
101
+ if (isActive && !item.assigneeUserId) {
102
+ add('unowned_active_card', 'The card is active but has no end-to-end owner.');
103
+ }
104
+ if (isActive && !item.handler) {
105
+ add('unhandled_active_pass', 'The active build or QA pass has no handler.');
106
+ }
107
+
108
+ const staleAfter = column?.staleness?.staleAfterHours;
109
+ const ageHours = hoursSince(item.enteredColumnAt, nowMs);
110
+ if (!isTerminal && Number.isFinite(staleAfter) && ageHours !== null && ageHours > staleAfter) {
111
+ add(
112
+ 'stale_open_card',
113
+ `The card has spent ${Math.floor(ageHours)}h in ${stage}; this column is stale after ${staleAfter}h.`
114
+ );
115
+ }
116
+ }
117
+
118
+ findings.push(...boardFindings.map((finding) => ({ boardId: board.id, boardName: board.name, ...finding })));
119
+ perBoard.push({
120
+ boardId: board.id,
121
+ boardName: board.name,
122
+ cards: items.length,
123
+ activeCards: items.filter((item) => {
124
+ const stage = columns.get(item.columnId)?.name;
125
+ return ACTIVE_STAGES.has(normalized(stage));
126
+ }).length,
127
+ findings: boardFindings.length,
128
+ });
129
+ }
130
+
131
+ const counts = {};
132
+ for (const finding of findings) counts[finding.code] = (counts[finding.code] ?? 0) + 1;
133
+
134
+ const priorityOrder = [
135
+ 'board_truncated',
136
+ 'unowned_active_card',
137
+ 'unhandled_active_pass',
138
+ 'missing_acceptance_criteria',
139
+ 'stale_open_card',
140
+ 'missing_body',
141
+ 'missing_kind',
142
+ 'criteria_not_measured',
143
+ ];
144
+ const rank = new Map(priorityOrder.map((code, index) => [code, index]));
145
+ findings.sort((a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99));
146
+
147
+ return {
148
+ trustworthy: findings.length === 0,
149
+ generatedAt: new Date(nowMs).toISOString(),
150
+ boards: boards.length,
151
+ totalCards,
152
+ openCards,
153
+ activeCards,
154
+ counts,
155
+ perBoard,
156
+ findings,
157
+ interpretation:
158
+ findings.length === 0
159
+ ? 'Every returned card has the minimum facts required to operate from this hub. Delivery truth must still be read from exact-SHA evidence.'
160
+ : 'The hub is not yet trustworthy as an operating ledger. Work the findings in order; do not infer completion from column position.',
161
+ };
162
+ }