@taiga-ai/mcp-server 0.19.0-dev.451 → 0.20.0-dev.454

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.
@@ -35,6 +35,8 @@ export const BUILD_SUMMARY_FIELDS = [
35
35
  'prState',
36
36
  'prDraft',
37
37
  'prMerged',
38
+ 'reviewEscalation',
39
+ 'error',
38
40
  'actions',
39
41
  'createdAt',
40
42
  'startedAt',
@@ -52,7 +54,10 @@ const RUN_OUTCOME_FIELDS = [
52
54
  'totalSteps',
53
55
  'prNumber',
54
56
  'prUrl',
57
+ 'prDraft',
55
58
  'prMerged',
59
+ 'reviewEscalation',
60
+ 'error',
56
61
  'actions',
57
62
  ] as const;
58
63
 
@@ -111,6 +116,75 @@ const RUNNING: Partial<Record<BuildRunStatus, string>> = {
111
116
  reviewing: 'answering review comments on the pull request',
112
117
  };
113
118
 
119
+ /** Why the agent handed a run back, in the web's words (apps/web handback), with the checks it names. */
120
+ export function handbackSaid(escalation: unknown): string | null {
121
+ if (!escalation || typeof escalation !== 'object') return null;
122
+ const e = escalation as {
123
+ reason?: unknown;
124
+ rounds?: unknown;
125
+ failingChecks?: unknown;
126
+ file?: unknown;
127
+ blockers?: unknown;
128
+ };
129
+ const checks = Array.isArray(e.failingChecks)
130
+ ? (e.failingChecks as { name?: unknown }[]).map((check) => String(check.name)).join(', ')
131
+ : '';
132
+ const on = checks ? ` on ${checks}` : '';
133
+ const rounds =
134
+ typeof e.rounds === 'number' && e.rounds > 0
135
+ ? ` after ${e.rounds} automatic round${e.rounds === 1 ? '' : 's'}`
136
+ : '';
137
+ const blockers = Array.isArray(e.blockers)
138
+ ? (e.blockers as unknown[]).map((b) => READY_BLOCKER_SAID[String(b)] ?? String(b))
139
+ : [];
140
+ switch (e.reason) {
141
+ case 'ci_fix_cap':
142
+ return `CI is still failing${on}${rounds}`;
143
+ case 'ci_fix_stalled':
144
+ return `CI is still failing${on}, and the last fix round changed nothing`;
145
+ case 'conflict_cap':
146
+ return `the branch still conflicts with its base${rounds}`;
147
+ case 'review_bot_cap':
148
+ return `review bots still have open comments${rounds}`;
149
+ case 'ci_never_settled':
150
+ return 'CI did not finish in time';
151
+ case 'ready_blocked':
152
+ return `CI is green, but Taiga kept the pull request a draft${blockers.length ? `: ${blockers.join('; ')}` : ''}`;
153
+ case 'check_weakened':
154
+ return `CI went green only after a check was loosened${typeof e.file === 'string' ? ` in ${e.file}` : ''}`;
155
+ default:
156
+ return typeof e.reason === 'string' ? e.reason : null;
157
+ }
158
+ }
159
+
160
+ /** What a person does about a handback, by its reason, as the web's handback detail says it. */
161
+ export function handbackNextStep(escalation: unknown): string {
162
+ const reason = escalation && typeof escalation === 'object' ? (escalation as { reason?: unknown }).reason : null;
163
+ if (reason === 'check_weakened') return 'review the loosened check before merging, or review it back to the agent';
164
+ if (reason === 'ci_never_settled')
165
+ return 'look at what is still running or queued in CI, then review it back to the agent';
166
+ return 'fix it by hand or review it back to the agent';
167
+ }
168
+
169
+ /** The web's words for why Taiga keeps a pull request a draft. */
170
+ const READY_BLOCKER_SAID: Record<string, string> = {
171
+ plan_incomplete: 'not every planned step shipped',
172
+ tests_failed: 'the tests Taiga ran after the build failed',
173
+ bar_lowering: 'a fix lowered a check instead of fixing the code',
174
+ bar_lowering_unchecked: 'Taiga could not check whether a fix lowered a check',
175
+ criticals_unresolved: "Taiga's own review left a critical issue unresolved",
176
+ };
177
+
178
+ /** The first sentence of a run's error, short enough for a row. */
179
+ function errorSaid(error: unknown): string | null {
180
+ if (typeof error !== 'string' || !error.trim()) return null;
181
+ const first = error
182
+ .trim()
183
+ .split(/\n|(?<=[.!?])\s/)[0]!
184
+ .replace(/[.!?]$/, '');
185
+ return first.length > 240 ? `${first.slice(0, 240).trimEnd()}...` : first;
186
+ }
187
+
114
188
  /**
115
189
  * How a run stands, in one sentence, with its disposition: the meaning the
116
190
  * status and the pull request facts carry between them
@@ -127,18 +201,24 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
127
201
  typeof run['completedSteps'] === 'number' && typeof run['totalSteps'] === 'number'
128
202
  ? ` (${run['completedSteps']} of ${run['totalSteps']} steps done)`
129
203
  : '';
130
- const reason = typeof run['failedReason'] === 'string' && run['failedReason'] ? `: ${run['failedReason']}` : '';
204
+ // The error says what happened; the failedReason is only its code (PRO-2800).
205
+ const why = errorSaid(run['error']) ?? (typeof run['failedReason'] === 'string' ? run['failedReason'] : null);
206
+ const handback = handbackSaid(run['reviewEscalation']);
207
+ const draft = run['prDraft'] === true ? `, still a draft` : '';
131
208
  const sentence: Record<string, string> = {
132
209
  in_flight: status === 'pending' ? 'Queued: waiting to start.' : `Running: ${RUNNING[status] ?? status}${progress}.`,
133
- gated: `Waiting for a person to review and merge ${pr}.`,
210
+ // A handback is a person's turn too, but not a plain review: say what the agent could not get past.
211
+ gated: handback
212
+ ? `Handed back to a person: ${handback}. ${pr}${draft ? `${draft},` : ''} waits for them to ${handbackNextStep(run['reviewEscalation'])}.`
213
+ : `Waiting for a person to review and merge ${pr}${draft}.`,
134
214
  succeeded: `Merged: ${pr} is in ${typeof run['baseBranch'] === 'string' ? run['baseBranch'] : 'the base branch'}.`,
135
215
  unmerged:
136
216
  typeof run['prNumber'] === 'number' || run['prUrl']
137
217
  ? `Settled without merging: ${pr} was left unmerged.`
138
218
  : 'Settled without a merged pull request.',
139
- failed: `Failed${reason}${progress}.`,
219
+ failed: `Failed${why ? `: ${why}` : ''}${progress}.`,
140
220
  stopped: `Stopped before it finished${progress}.`,
141
- rejected: `${pr} was closed without merging, so the work did not land.`,
221
+ rejected: `${pr} was closed without merging, so the work did not land.${handback ? ` It had been handed back: ${handback}.` : ''}`,
142
222
  };
143
223
  return { disposition, outcome: sentence[disposition] ?? status };
144
224
  }
@@ -146,19 +226,26 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
146
226
  /**
147
227
  * A run row as an agent reads it: its outcome added, and prDraft only where
148
228
  * there is a pull request. The column defaults to true, so a run that never
149
- * opened one said it had a draft (PRO-2800).
229
+ * opened one said it had a draft (PRO-2800). A list row leaves the full error
230
+ * text out: its first sentence is in the outcome, and taiga_get_run has it all.
150
231
  */
151
- export function readableRun(run: Record<string, unknown>): Record<string, unknown> {
152
- const { prDraft, ...rest } = run;
232
+ export function readableRun(run: Record<string, unknown>, opts: { row?: boolean } = {}): Record<string, unknown> {
233
+ const { prDraft, error, reviewEscalation, ...rest } = run;
153
234
  const said = runOutcome(run);
154
235
  return {
155
236
  ...rest,
237
+ // A row's outcome says the error and the handback; the full read keeps them whole.
238
+ ...(opts.row || error === undefined ? {} : { error }),
239
+ ...(opts.row || reviewEscalation === undefined ? {} : { reviewEscalation }),
156
240
  // A PR number proves a PR exists (isSettledWithoutPr); the URL is nullable apart from it.
157
241
  ...(typeof rest['prNumber'] === 'number' || rest['prUrl'] ? { prDraft } : {}),
158
242
  ...(said ?? {}),
159
243
  };
160
244
  }
161
245
 
246
+ /** readableRun for a list row. */
247
+ export const readableRow = (run: Record<string, unknown>) => readableRun(run, { row: true });
248
+
162
249
  /** How many products the org-wide list names; past this a row keeps its productId only. */
163
250
  const NAMED_PRODUCTS = 100;
164
251
 
@@ -218,7 +305,7 @@ async function activityRows(
218
305
  nameById.set(product.id, { name: product.name, slug: product.slug });
219
306
  }
220
307
  const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS)
221
- .map(readableRun)
308
+ .map(readableRow)
222
309
  .map((row) => {
223
310
  const product = nameById.get(String(row['productId']));
224
311
  return product ? { ...row, product } : row;
@@ -293,7 +380,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
293
380
  });
294
381
  return toolResult(
295
382
  page(
296
- pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRun),
383
+ pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRow),
297
384
  result.nextCursor
298
385
  )
299
386
  );
@@ -307,7 +394,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
307
394
  {
308
395
  title: 'Get run',
309
396
  description:
310
- 'Get one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and ' +
397
+ 'Read one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and ' +
311
398
  "each step's title, status, commit, files changed, verification and headline, the first paragraph of " +
312
399
  "what the agent reported. A step's verification is Taiga's own check after the step (tests, lint, build), " +
313
400
  'independent of what the agent says it ran: a docs-only step can show tests failed because the ' +
@@ -367,7 +454,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
367
454
  (params) =>
368
455
  runTool(async () => {
369
456
  const result = await client.stopBuildRun(params.runId);
370
- return toolResult(readableRun(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]!));
457
+ return toolResult(readableRow(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]!));
371
458
  })
372
459
  );
373
460
  }
@@ -167,6 +167,6 @@ describe('writeNotice', () => {
167
167
  it('says nothing for a read, and what a write does otherwise', () => {
168
168
  expect(writeNotice('r', { readOnlyHint: true })).toBe('');
169
169
  expect(writeNotice('w', { readOnlyHint: false })).toBe('Writes. ');
170
- expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^Acts on paid or shared work/);
170
+ expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^Ask first: paid or shared work/);
171
171
  });
172
172
  });
@@ -28,7 +28,7 @@ import {
28
28
  type StoredDocument,
29
29
  } from './sections.js';
30
30
  import { pickFields } from './summarize.js';
31
- import { readableRun } from './builds.js';
31
+ import { readableRow } from './builds.js';
32
32
  import { setupReadiness } from './readiness.js';
33
33
  import { standingSummary } from './maintenance.js';
34
34
 
@@ -72,6 +72,9 @@ const CONTEXT_BUILD_FIELDS = [
72
72
  'prUrl',
73
73
  'prState',
74
74
  'prMerged',
75
+ 'prDraft',
76
+ 'reviewEscalation',
77
+ 'error',
75
78
  'createdAt',
76
79
  ] as const;
77
80
 
@@ -127,12 +130,12 @@ const PARTS = {
127
130
  factory: 'factories:read',
128
131
  documents: 'products:read',
129
132
  policies: 'tenant:documents:read',
130
- backlog: 'backlog:read',
133
+ initiatives: 'backlog:read',
131
134
  repositories: 'products:read',
132
- builds: 'products:read',
133
- attention: 'products:read',
135
+ runs: 'products:read',
136
+ actionRequired: 'products:read',
134
137
  maintenance: 'products:read',
135
- standing: 'products:read',
138
+ policyStanding: 'products:read',
136
139
  } as const;
137
140
  type Part = keyof typeof PARTS;
138
141
 
@@ -316,8 +319,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
316
319
  * The linked repositories. The route serves one page of 100 and takes no
317
320
  * cursor, so past that the bundle says the list is cut rather than complete.
318
321
  */
319
- async function repositoriesPart(client: TaigaClient, productId: string) {
320
- const result = await client.listProductRepositories(productId);
322
+ async function repositoriesPart(links: () => ReturnType<TaigaClient['listProductRepositories']>) {
323
+ const result = await links();
321
324
  return {
322
325
  items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
323
326
  truncated: result.nextCursor !== null,
@@ -326,7 +329,7 @@ async function repositoriesPart(client: TaigaClient, productId: string) {
326
329
 
327
330
  async function buildsPart(client: TaigaClient, productId: string) {
328
331
  const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
329
- return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRun);
332
+ return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRow);
330
333
  }
331
334
 
332
335
  /**
@@ -370,8 +373,20 @@ async function maintenancePart(client: TaigaClient, productId: string) {
370
373
 
371
374
  /** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
372
375
  async function standingPart(client: TaigaClient, productId: string) {
373
- const summary = standingSummary((await client.getPolicyStanding(productId)).data);
374
- return { openViolations: summary.violations, bySeverity: summary.bySeverity, controlsViolated: summary.controls };
376
+ const standing = (await client.getPolicyStanding(productId)).data;
377
+ const summary = standingSummary(standing);
378
+ // No violations from an assessment that never ran, or failed, is no all-clear (PRO-2800).
379
+ const state = (standing.assessment as { state?: unknown } | null | undefined)?.state;
380
+ return {
381
+ openViolations: summary.violations,
382
+ bySeverity: summary.bySeverity,
383
+ controlsViolated: summary.controls,
384
+ ...(state === 'never' || state === 'failed'
385
+ ? {
386
+ note: `The assessment ${state === 'never' ? 'has never run' : 'failed'}: no violations here is not an all-clear.`,
387
+ }
388
+ : {}),
389
+ };
375
390
  }
376
391
 
377
392
  export async function productContext(
@@ -383,21 +398,24 @@ export async function productContext(
383
398
  const granted = (part: Part) => client.hasCapability(PARTS[part]);
384
399
  const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
385
400
 
401
+ // One read of the links serves the repositories part and the setup readiness.
402
+ let linksRead: ReturnType<TaigaClient['listProductRepositories']> | undefined;
403
+ const links = () => (linksRead ??= client.listProductRepositories(productId));
386
404
  const fetchers: Record<Part, () => Promise<unknown>> = {
387
405
  factory: () => factoryPart(client, String(product.factoryId)),
388
406
  documents: () => documentsPart(client, product, options.documents),
389
407
  policies: () => policiesPart(client),
390
- backlog: () => backlogPart(client, productId),
391
- repositories: () => repositoriesPart(client, productId),
392
- builds: () => buildsPart(client, productId),
393
- attention: () => attentionPart(client, productId),
408
+ initiatives: () => backlogPart(client, productId),
409
+ repositories: () => repositoriesPart(links),
410
+ runs: () => buildsPart(client, productId),
411
+ actionRequired: () => attentionPart(client, productId),
394
412
  maintenance: () => maintenancePart(client, productId),
395
- standing: () => standingPart(client, productId),
413
+ policyStanding: () => standingPart(client, productId),
396
414
  };
397
415
  const parts = (Object.keys(fetchers) as Part[]).filter(granted);
398
416
  const [settled, readiness] = await Promise.all([
399
417
  Promise.allSettled(parts.map((part) => fetchers[part]())),
400
- setupReadiness(client, product),
418
+ setupReadiness(client, product, links),
401
419
  ]);
402
420
 
403
421
  const bundle: Record<string, unknown> = {
@@ -445,18 +463,17 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
445
463
  {
446
464
  title: 'Get product context',
447
465
  description:
448
- 'Where a product stands and everything a coding session starts from, in one call. Name the product by ' +
449
- 'ID, slug or the git remote URL of a checkout. It carries: the product (and, while in setup, readiness: ' +
450
- 'what it still needs and the next move); its factory with the instructions in full; the specification ' +
451
- 'and architecture as outlines (section ids and sizes); every published policy as its section ids; the ' +
452
- 'open initiatives (not the done or canceled ones: taiga_list_initiatives has those); the linked ' +
453
- 'repositories; the latest runs, each with its outcome in a sentence; attention, what waits on a person ' +
454
- 'for it, counted by kind; maintenance, open findings by severity and the last sweep; standing, open ' +
455
- 'policy violations by severity. A part the API refused or failed is named under unavailable; a part the ' +
456
- 'key cannot read under notGranted; neither is shown as empty. Before writing code, documents: budget ' +
457
- `brings the whole sections that fit, about ${DOCUMENT_BUDGET / 1000}k characters each, and full brings ` +
458
- 'them whole (tens of thousands of tokens on a large product). Unlike taiga_get_product, which is the ' +
459
- 'product record and its settings alone. Call this first, then read what you need.',
466
+ 'Where a product stands, in one call: the first read for any question about a product. Name it by ID, ' +
467
+ 'slug or the git remote URL of a checkout. The parts: product (and, while in setup, readiness: what it ' +
468
+ 'still needs and the next move); factory, with its instructions in full; documents, the specification and ' +
469
+ 'architecture as outlines (section ids and sizes); policies, each published policy with its section ids; ' +
470
+ 'initiatives, the open ones (taiga_list_initiatives has the done and canceled); repositories; runs, the ' +
471
+ 'latest ten, each with its outcome in a sentence; actionRequired, what waits on a person, counted by kind; ' +
472
+ 'maintenance, open findings by severity and the last sweep; policyStanding, open policy violations by ' +
473
+ 'severity. A part the API refused or failed is named under unavailable, a part the key cannot read under ' +
474
+ 'notGranted; neither is shown as empty. Before writing code, documents: budget brings the whole sections ' +
475
+ `that fit, about ${DOCUMENT_BUDGET / 1000}k characters each, and full brings them whole. taiga_get_product ` +
476
+ 'is the product record and its settings alone.',
460
477
  inputSchema: z.object({
461
478
  productId: productId.optional(),
462
479
  slug: slug.optional(),
@@ -8,7 +8,11 @@ import { withNotFoundHint } from './hints.js';
8
8
  * as taiga_get_initiative and taiga_get_run ask for them, rather than the
9
9
  * storage names backlogItemId and buildRunId.
10
10
  */
11
- const PRODUCT_WORDS: Record<string, string> = { backlogItemId: 'initiativeId', buildRunId: 'runId' };
11
+ const PRODUCT_WORDS: Record<string, string> = {
12
+ backlogItemId: 'initiativeId',
13
+ buildRunId: 'runId',
14
+ latestBuild: 'latestRun',
15
+ };
12
16
 
13
17
  /**
14
18
  * Maps whose keys are content, not API fields: a document's sections are keyed
@@ -34,6 +38,14 @@ export function inProductWords(value: unknown): unknown {
34
38
  return out;
35
39
  }
36
40
 
41
+ /** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
42
+ export function inProductWording(said: string): string {
43
+ return said
44
+ .replace(/^Backlog item not found/i, 'Initiative not found')
45
+ .replace(/^Build run step not found/i, 'Run step not found')
46
+ .replace(/^Build run not found/i, 'Run not found');
47
+ }
48
+
37
49
  /** A successful tool result: one JSON text block the model reads. */
38
50
  export function toolResult(value: unknown): CallToolResult {
39
51
  return { content: [{ type: 'text', text: JSON.stringify(inProductWords(value), null, 2) }] };
@@ -116,7 +128,7 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
116
128
  const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
117
129
  const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
118
130
  // A miss says where the right IDs are (hints.ts): the API's own word is bare.
119
- const message = error.status === 404 ? withNotFoundHint(said) : said;
131
+ const message = error.status === 404 ? withNotFoundHint(inProductWording(said)) : said;
120
132
  return toolError(code, message, error.details);
121
133
  }
122
134
  if (isNetworkFailure(error)) {
@@ -64,21 +64,75 @@ function itemRow(row: Row, now: number, full: boolean): Row {
64
64
  }
65
65
 
66
66
  /** What is open, counted by product and kind: the answer to "what needs attention" without reading every row. */
67
- function summaryOf(rows: Row[]): Row[] {
67
+ function summaryOf(rows: Row[], stale: Map<string, string>): Row[] {
68
68
  const byProduct = new Map<
69
69
  string,
70
- { productId: string; product: unknown; open: number; byKind: Record<string, number> }
70
+ { productId: string; product?: unknown; open: number; byKind: Record<string, number>; stale?: number }
71
71
  >();
72
72
  for (const row of rows) {
73
73
  const key = String(row['productId']);
74
- const entry = byProduct.get(key) ?? { productId: key, product: row['productName'] ?? key, open: 0, byKind: {} };
74
+ // A product-scoped read carries no names; the id is the product's, not a name.
75
+ const entry =
76
+ byProduct.get(key) ??
77
+ (row['productName']
78
+ ? { productId: key, product: row['productName'], open: 0, byKind: {} }
79
+ : { productId: key, open: 0, byKind: {} });
75
80
  entry.open += 1;
76
81
  entry.byKind[String(row['kind'])] = (entry.byKind[String(row['kind'])] ?? 0) + 1;
82
+ if (stale.has(String(row['id']))) entry.stale = (entry.stale ?? 0) + 1;
77
83
  byProduct.set(key, entry);
78
84
  }
79
85
  return [...byProduct.values()].sort((a, b) => b.open - a.open);
80
86
  }
81
87
 
88
+ /** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
89
+ const STALE_CHECKS = 10;
90
+
91
+ /**
92
+ * Which "Review & merge" items no longer stand, and why (PRO-2800): the run's
93
+ * pull request closed or merged, or its initiative was canceled or finished
94
+ * without it. Two reviewing agents counted such leftovers as live gates; the
95
+ * row still is open in Action Required, so this says what it is now rather
96
+ * than hiding it. Best effort, a handful of runs: a failed read leaves the
97
+ * row as it was.
98
+ */
99
+ async function staleReviews(
100
+ client: TaigaClient,
101
+ rows: Row[]
102
+ ): Promise<{ stale: Map<string, string>; unchecked: number }> {
103
+ const byId = new Map(
104
+ rows
105
+ .filter((row) => row['kind'] === 'review_pr' && row['subjectType'] === 'build_run')
106
+ .map((row) => [String(row['id']), row])
107
+ );
108
+ const candidates = [...byId.values()].slice(0, STALE_CHECKS);
109
+ // Past the cap, and any check the API refused or failed (a key without
110
+ // backlog:read cannot read the initiative): unknown, not live.
111
+ let unchecked = byId.size - candidates.length;
112
+ const out = new Map<string, string>();
113
+ await Promise.all(
114
+ candidates.map(async (row) => {
115
+ try {
116
+ const run = (await client.getBuildRun(String(row['subjectId']))).data as unknown as Row;
117
+ if (run['prMerged'] === true) return void out.set(String(row['id']), 'its pull request has merged');
118
+ if (run['prState'] === 'closed') return void out.set(String(row['id']), 'its pull request was closed');
119
+ if (typeof run['backlogItemId'] !== 'string') return;
120
+ if (!client.hasCapability('backlog:read')) return void (unchecked += 1);
121
+ const item = (await client.getBacklogItem(run['backlogItemId'])).data as unknown as Row;
122
+ if (item['state'] === 'canceled' || item['status'] === 'canceled') {
123
+ out.set(String(row['id']), 'its initiative was canceled; the pull request is still open on the forge');
124
+ } else if (item['state'] === 'done' || item['status'] === 'done') {
125
+ out.set(String(row['id']), 'its initiative is done; this run is not the one that landed it');
126
+ }
127
+ } catch (error) {
128
+ if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
129
+ unchecked += 1;
130
+ }
131
+ })
132
+ );
133
+ return { stale: out, unchecked };
134
+ }
135
+
82
136
  /** Pages read for the summary; past this it says it counted only those. */
83
137
  const SUMMARY_PAGE = 100;
84
138
 
@@ -146,6 +200,11 @@ export function sameKeyOnce(rows: Row[]): Row[] {
146
200
  return out;
147
201
  }
148
202
 
203
+ /** A page of folded rows, saying how many records it folded so totalCount (records) and the rows reconcile. */
204
+ function folded(rows: Row[], nextCursor: string | null, records: number) {
205
+ return { ...page(rows, nextCursor), ...(rows.length < records ? { recordsOnPage: records } : {}) };
206
+ }
207
+
149
208
  export function registerInboxTools(server: McpServer, client: TaigaClient) {
150
209
  if (!client.hasCapability('products:read')) return;
151
210
 
@@ -154,13 +213,16 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
154
213
  {
155
214
  title: 'List what needs a person',
156
215
  description:
157
- 'Read Action Required, where work comes back to a person, shared by the team: things to review (a plan, ' +
216
+ "What waits on a person: reviews and setup items, with a summary by product. The inbox is the team's, not one " +
217
+ "person's. It holds things to review (a plan, " +
158
218
  'a set of initiatives, a document decision, a pull request, maintenance findings, new policy violations) ' +
159
219
  'and things to set up that only a person can (a secret, an environment variable, an account, a package ' +
160
220
  'registry, a cloud permission). Across every product the key can open, or one product. The first page ' +
161
221
  'opens with summary: what is open, counted by product and kind, so "what needs attention" is one call, ' +
162
222
  'and totalCount. Each row says how many days it has waited (waitingDays) and, for a pull request review, ' +
163
- 'the prUrl; an old item is still open, not stale. category gate is the reviews, setup the setup items; ' +
223
+ 'the prUrl. A pull request review that no longer stands (its pull request merged or closed, or its ' +
224
+ 'initiative canceled or done) says so under stale, and the summary counts those per product. ' +
225
+ 'category gate is the reviews, setup the setup items; ' +
164
226
  'resolved: true reads the archive. An item snoozed until later is left out until then (snoozedHidden ' +
165
227
  "counts them). full: true adds each item's metadata. Unlike taiga_list_setup_tasks, which lists the setup " +
166
228
  'items with exactly what to set and where.',
@@ -210,19 +272,34 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
210
272
  const rows = result.data as Row[];
211
273
  const due = rows.filter(isDue);
212
274
  const hidden = rows.length - due.length;
275
+ const overviewDue = overview ? (overview.data as Row[]).filter(isDue) : [];
276
+ const checked = resolved
277
+ ? { stale: new Map<string, string>(), unchecked: 0 }
278
+ : await staleReviews(client, [...due, ...overviewDue]);
279
+ const stale = checked.stale;
213
280
  return toolResult({
214
281
  ...(overview
215
282
  ? {
216
- summary: summaryOf((overview.data as Row[]).filter(isDue)),
283
+ summary: summaryOf(overviewDue, stale),
217
284
  ...(overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}),
218
285
  }
219
286
  : {}),
220
287
  ...page(
221
- due.map((row) => itemRow(row, now, full === true)),
288
+ due.map((row) => {
289
+ const item = itemRow(row, now, full === true);
290
+ const why = stale.get(String(row['id']));
291
+ return why ? { ...item, stale: why } : item;
292
+ }),
222
293
  result.nextCursor
223
294
  ),
224
295
  ...totalOf(result),
225
296
  ...(hidden > 0 ? { snoozedHidden: hidden } : {}),
297
+ // Past the cap a review is not checked, so no stale on it is not "live".
298
+ ...(checked.unchecked > 0
299
+ ? {
300
+ staleNote: `${checked.unchecked} pull request review${checked.unchecked === 1 ? ' was' : 's were'} not checked for staleness (past the cap, or the read was refused).`,
301
+ }
302
+ : {}),
226
303
  });
227
304
  })
228
305
  );
@@ -232,7 +309,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
232
309
  {
233
310
  title: 'List setup tasks',
234
311
  description:
235
- 'The setup tasks agents raised for work only a person can do, with what exactly to set and where: the ' +
312
+ 'What a person must set up for a product (secrets, variables, accounts), with exactly what and where. These are the tasks agents raised for work only a person can do: the ' +
236
313
  'name of an environment variable, secret or API key to create (key; never its value, which Taiga does ' +
237
314
  'not store or display), the provider page to do it on, and the phase that says when it blocks you ' +
238
315
  '(prereq: before the deploy, which fails without it; wiring: connecting generated values back to ' +
@@ -241,8 +318,8 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
241
318
  'product), or one. The tasks still to do, oldest first, the order they depend on each other; ' +
242
319
  'includeDone: true adds the done and dismissed ones. The first page says how many there are on every ' +
243
320
  'page (totalCount). One row per variable: a variable filed twice (as a secret and as an environment ' +
244
- 'variable) lists the other records under alsoFiledAs, an open one first; within a page, and totalCount ' +
245
- 'counts every record. Unlike taiga_list_action_required, whose setup ' +
321
+ 'variable) lists the other records under alsoFiledAs, an open one first (folded within a page). totalCount counts ' +
322
+ 'records, and a page that folded some says recordsOnPage. Unlike taiga_list_action_required, whose setup ' +
246
323
  'items are the same work as rows to resolve, this says exactly what to set and where.',
247
324
  inputSchema: z.object({
248
325
  productId: productId.optional(),
@@ -260,7 +337,11 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
260
337
  ...(params.cursor === undefined ? { withTotal: true } : {}),
261
338
  });
262
339
  return toolResult({
263
- ...page(sameKeyOnce(pickFields(result.data as Row[], SETUP_TASK_FIELDS)), result.nextCursor),
340
+ ...folded(
341
+ sameKeyOnce(pickFields(result.data as Row[], SETUP_TASK_FIELDS)),
342
+ result.nextCursor,
343
+ result.data.length
344
+ ),
264
345
  ...totalOf(result),
265
346
  });
266
347
  })