@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.
@@ -19,6 +19,8 @@ import { registerRepositoryTools } from './repositories.js';
19
19
  import { registerResources } from './resources.js';
20
20
  import { registerSetupTools } from './setup.js';
21
21
  import { notFoundHint, notFoundHints } from './hints.js';
22
+ import { runTool, toolResult } from './errors.js';
23
+ import { READ_ONLY } from './annotations.js';
22
24
  import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wait.js';
23
25
 
24
26
  /**
@@ -30,13 +32,14 @@ import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wai
30
32
  * model learns the tool's real shape. Applied here, once, so no tool can be
31
33
  * registered without it.
32
34
  */
33
- function withStrictInputs(server: McpServer): McpServer {
35
+ function withStrictInputs(server: McpServer, registered: Map<string, ToolAnnotations | undefined>): McpServer {
34
36
  const register = server.registerTool.bind(server) as (...args: unknown[]) => unknown;
35
37
  server.registerTool = ((
36
38
  name: string,
37
39
  config: { inputSchema?: unknown; description?: string; annotations?: ToolAnnotations },
38
40
  callback: unknown
39
41
  ) => {
42
+ registered.set(name, config.annotations);
40
43
  const schema = config.inputSchema;
41
44
  if (schema !== undefined && !(schema instanceof z.ZodObject)) {
42
45
  throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
@@ -56,7 +59,7 @@ function withStrictInputs(server: McpServer): McpServer {
56
59
  return server;
57
60
  }
58
61
 
59
- type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean };
62
+ type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean };
60
63
 
61
64
  /**
62
65
  * The entry points, loaded with their descriptions at session start
@@ -79,7 +82,7 @@ export function writeNotice(name: string, annotations: ToolAnnotations | undefin
79
82
  throw new Error(`Tool ${name}: annotations must say whether it is read-only`);
80
83
  }
81
84
  if (annotations.readOnlyHint) return '';
82
- return annotations.destructiveHint ? 'Acts on paid or shared work: ask the person first. ' : 'Writes. ';
85
+ return annotations.destructiveHint ? 'Ask first: paid or shared work. ' : 'Writes. ';
83
86
  }
84
87
 
85
88
  /**
@@ -88,7 +91,8 @@ export function writeNotice(name: string, annotations: ToolAnnotations | undefin
88
91
  * never sees tools it can't use.
89
92
  */
90
93
  export function registerAllTools(server: McpServer, client: TaigaClient, options: { wait?: WaitOptions } = {}): void {
91
- withStrictInputs(server);
94
+ const registered = new Map<string, ToolAnnotations | undefined>();
95
+ withStrictInputs(server, registered);
92
96
  registerProductTools(server, client);
93
97
  registerDocumentTools(server, client);
94
98
  registerBacklogTools(server, client);
@@ -105,6 +109,52 @@ export function registerAllTools(server: McpServer, client: TaigaClient, options
105
109
  registerRepositoryTools(server, client);
106
110
  registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
107
111
  registerContextTools(server, client);
112
+ // Only beside other tools: a key with no capabilities gets no tools at all.
113
+ if (registered.size > 0) registerWhoami(server, client, registered);
108
114
  registerResources(server, client);
109
115
  registerPrompts(server, client);
110
116
  }
117
+
118
+ /**
119
+ * Who this key is and what it may do (PRO-2800): reviewing agents read tool
120
+ * descriptions saying "owners and admins link repositories" with no way to
121
+ * learn their own role until a write was refused. Registered last, so it can
122
+ * name every tool the key was given.
123
+ */
124
+ function registerWhoami(
125
+ server: McpServer,
126
+ client: TaigaClient,
127
+ registered: Map<string, ToolAnnotations | undefined>
128
+ ): void {
129
+ server.registerTool(
130
+ 'taiga_whoami',
131
+ {
132
+ title: 'Who am I',
133
+ description:
134
+ "Who this API key acts as and what it may do: the person's email and organization role, the key's scopes " +
135
+ '(null means every capability the person has), its capabilities, and the tools it was given, split into ' +
136
+ "reads, writes, and the ask-first writes (paid agent work or changes others see). A tool the key's " +
137
+ 'capabilities do not cover is not offered at all, rather than refused when called.',
138
+ inputSchema: z.object({}),
139
+ annotations: READ_ONLY,
140
+ },
141
+ () =>
142
+ runTool(async () => {
143
+ const me = client.getIntrospection();
144
+ const tools = [...registered.entries()].filter(([name]) => name !== 'taiga_whoami');
145
+ const named = (pick: (notes: ToolAnnotations | undefined) => boolean) =>
146
+ tools.filter(([, notes]) => pick(notes)).map(([name]) => name);
147
+ return toolResult({
148
+ email: me?.email ?? null,
149
+ role: me?.role ?? null,
150
+ scopes: me?.scopes ?? null,
151
+ capabilities: me?.capabilities ?? [],
152
+ tools: {
153
+ reads: named((notes) => notes?.readOnlyHint === true),
154
+ writes: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint !== true),
155
+ askFirst: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint === true),
156
+ },
157
+ });
158
+ })
159
+ );
160
+ }
@@ -13,20 +13,25 @@ export const SERVER_INSTRUCTIONS = [
13
13
  "Taiga is a policy-aware software delivery platform: an organization's policies and knowledge, its factories",
14
14
  "and products, each product's initiatives (its backlog), the line that plans and builds them one at a time, the",
15
15
  "coding agent's runs and pull requests, maintenance findings, and Action Required, where work waits on a person.",
16
- 'Search these tools whenever the person asks about a Taiga product, an initiative, a run or pull request, policy',
17
- 'or compliance, findings, deployments or monitoring, or setting a product up.',
16
+ 'Use these tools (names start with taiga_) whenever the person asks about a Taiga product, an initiative, a run',
17
+ 'or pull request, policy or compliance, findings, deployments, or setting a product up.',
18
18
  '',
19
- '**Start here.** taiga_get_product_context (the product by git remote URL, slug or ID) gives its state in one',
20
- 'call: what it still needs while in setup, its documents as outlines, policies as section ids, open initiatives,',
21
- 'recent runs with their outcome, and what waits on a person. Then read only what you need. Across products,',
22
- 'taiga_list_products and taiga_list_action_required.',
19
+ '**Start here.** taiga_get_product_context (a product by git remote URL, slug or ID) says where it stands in one',
20
+ 'call: what it still needs while in setup, documents as outlines, policies as section ids, open initiatives,',
21
+ 'recent runs with their outcome, what waits on a person, findings and policy violations. Then read only what you',
22
+ 'need. Across products: taiga_list_products, taiga_list_action_required. taiga_whoami says what this key may do.',
23
23
  '',
24
24
  '**Words.** An initiative is a unit of work in the backlog; a run is one attempt by the coding agent to build it;',
25
25
  "the line is the product's queue; a factory owns a set of products. IDs are UUIDs, never names; a NOT_FOUND names",
26
- 'the tool that lists the right ones. A refusal carries the reason: say it as it is.',
26
+ 'the tool that lists the right ones. A GitHub repository id is not a repository link id. Say a refusal as it is.',
27
27
  '',
28
- '**Before you write.** A tool that writes says so first: "Writes." or "Acts on paid or shared work: ask the',
29
- 'person first." Ask before the second kind: it starts paid agent work, interrupts it, or changes what others see.',
28
+ '**Reading.** Lists page with nextCursor (Action Required, setup tasks, findings: totalCount). A document has two',
29
+ 'timelines: plan (what was meant) and reality (derived from the code). outcome, situation and nextStep are',
30
+ 'one-sentence summaries of the record they sit on; read the record for the detail.',
31
+ '',
32
+ '**Before you write.** A tool that writes opens with "Writes." or "Ask first:". Before an "Ask first" tool, tell',
33
+ 'the person what you are about to do and wait for their yes: it starts paid agent work, interrupts it, or changes',
34
+ 'what others see.',
30
35
  '',
31
36
  '**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from',
32
37
  'scratch, stamping checks, starting a run over, and deleting anything.',
@@ -173,7 +173,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
173
173
  {
174
174
  title: 'Add knowledge',
175
175
  description:
176
- 'File a Markdown or plain-text reference document as knowledge, at the level it is relevant: a product ' +
176
+ 'File a reference document as knowledge for agents to search: Markdown or plain text, at the level it is relevant: a product ' +
177
177
  '(productId), a factory (factoryId), or the organization (neither; that takes an owner or admin, ' +
178
178
  'because every factory and product inherits it). Good candidates are what a new engineer would be ' +
179
179
  'handed to read: specifications and contracts to conform to, glossaries and code lists, third-party ' +
@@ -85,7 +85,14 @@ export function standingSummary(standing: {
85
85
  controls?: unknown;
86
86
  divergences?: unknown;
87
87
  resolvedDivergences?: unknown;
88
- }): { controls: number; violations: number; bySeverity: Record<string, number>; resolvedViolations: number } {
88
+ }): {
89
+ controls: number;
90
+ controlsInPolicies: number;
91
+ controlsWithNothingRecorded: number;
92
+ violations: number;
93
+ bySeverity: Record<string, number>;
94
+ resolvedViolations: number;
95
+ } {
89
96
  const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
90
97
  const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
91
98
  const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
@@ -95,7 +102,16 @@ export function standingSummary(standing: {
95
102
  const severity = String(violation['severity'] ?? 'unknown');
96
103
  bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
97
104
  }
98
- return { controls: listed.length, violations: violations.length, bySeverity, resolvedViolations: resolved };
105
+ return {
106
+ controls: listed.length,
107
+ // Every control the product's policies hold, and those nothing is recorded against: the positive side of
108
+ // "how do we stand", which is no evidence against them, not proof they are met.
109
+ controlsInPolicies: controls.length,
110
+ controlsWithNothingRecorded: controls.length - listed.length,
111
+ violations: violations.length,
112
+ bySeverity,
113
+ resolvedViolations: resolved,
114
+ };
99
115
  }
100
116
 
101
117
  const findingId = idInput('finding', 'taiga_list_findings gives it');
@@ -177,7 +193,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
177
193
  {
178
194
  title: 'Get a maintenance finding',
179
195
  description:
180
- 'Read one finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), ' +
196
+ 'Read one maintenance finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), ' +
181
197
  'the advisory and the fixed version, the place in the code, the policy controls it breaches, why it is ' +
182
198
  'ranked where it is, its history (reintroduced, previously resolved) and the remediation under way. A ' +
183
199
  "secret's value is never stored; the finding names the rule and the location only.",
@@ -198,7 +214,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
198
214
  {
199
215
  title: "Get a product's policy standing",
200
216
  description:
201
- "Where a product stands against the organization's own published policies: the controls it violates, " +
217
+ "How a product stands against the organization's own published policies: the controls it violates, " +
202
218
  "each with its violations (critical and high only, each citing the policy's sentence and the document " +
203
219
  'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
204
220
  'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
@@ -208,7 +224,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
208
224
  'done or canceled without the violation going away, so it needs a person or a new fix. A control ' +
209
225
  'breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
210
226
  'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
211
- 'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
227
+ 'is not listed, and it is not "passing" either: there is no such state; summary.controlsWithNothingRecorded counts them. Pass full: true for every ' +
212
228
  "violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
213
229
  inputSchema: z.object({
214
230
  productId,
@@ -228,9 +244,18 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
228
244
  // The counts first, on both answers: the lists run to thousands of
229
245
  // tokens, and "how bad is it" is the first question.
230
246
  const summary = standingSummary(standing);
231
- if (params.full) return toolResult({ summary, ...standing });
247
+ // Never assessed reads like a clean bill otherwise (PRO-2800).
248
+ const assessment = standing.assessment as { state?: unknown } | null | undefined;
249
+ const note =
250
+ assessment?.state === 'never'
251
+ ? 'Never assessed: the documents have not been checked against the policies yet, so nothing here is an ' +
252
+ 'all-clear. An assessment runs when the reality documents are derived or refreshed ' +
253
+ '(taiga_refresh_reality_documents, paid work: ask first).'
254
+ : null;
255
+ if (params.full) return toolResult({ summary, ...(note ? { note } : {}), ...standing });
232
256
  return toolResult({
233
257
  summary,
258
+ ...(note ? { note } : {}),
234
259
  productId: standing.productId,
235
260
  hasPolicies: standing.hasPolicies,
236
261
  assessment: standing.assessment,
@@ -259,7 +284,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
259
284
  {
260
285
  title: 'Ignore or reopen a finding',
261
286
  description:
262
- 'Ignore a finding that does not apply or whose risk is accepted, with the reason (false_positive, ' +
287
+ 'Ignore a maintenance finding, with a reason, or reopen it. Ignore one that does not apply or whose risk is accepted, with the reason (false_positive, ' +
263
288
  'not_reachable, accepted_risk, wont_fix), an optional note and an optional expiry after which it resurfaces ' +
264
289
  'if it still exists; or reopen an ignored one (ignored: false). Ignored findings are kept and reviewable. ' +
265
290
  'There is no "mark fixed": a finding resolves when the next sweep no longer detects it. For a secret that ' +
@@ -302,7 +327,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
302
327
  {
303
328
  title: 'Fix a finding',
304
329
  description:
305
- "Ask for a finding's fix: Taiga raises an initiative and adds it to the end of the product's queue, where " +
330
+ "Ask Taiga to fix a maintenance finding: it raises an initiative and adds it to the end of the product's queue, where " +
306
331
  "the line plans it in turn; whether the build then waits for a person is the initiative's review gate, " +
307
332
  'as for any initiative. The fix is a normal run that ends in a pull request you review and merge. With ' +
308
333
  "wholeGroup: true it fixes the finding's whole group in one initiative, as the page does: for a " +
@@ -353,7 +378,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
353
378
  {
354
379
  title: 'Fix policy violations',
355
380
  description:
356
- "Ask for the fix of a product's policy violations: name a control to cover every violation recorded " +
381
+ "Ask Taiga to fix a product's policy violations (it raises initiatives): name a control to cover every violation recorded " +
357
382
  'against it that has no initiative yet, or a single violation (violationId) when the control has more ' +
358
383
  "than one. Taiga raises one initiative and adds it to the end of the product's queue, exactly as a " +
359
384
  'finding fix does. The violation stays listed while the work is under way: only the next assessment ' +
@@ -100,7 +100,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
100
100
  {
101
101
  title: 'Get what runs where',
102
102
  description:
103
- "What is running in each of a product's environments now, anything deploying to them, and the recent " +
103
+ "What runs in each of a product's environments, and recent deployments: what is running now, anything deploying, and the recent " +
104
104
  'history, newest first: each deployment with its commit, pull request, status, who deployed it and, ' +
105
105
  'for a failure, the error the provider reported. What runs is the most recent deployment that actually ' +
106
106
  'succeeded, so a newer failed attempt shows beside the version still running, not in its place. The ' +
@@ -174,7 +174,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
174
174
  {
175
175
  title: 'Get delivery metrics',
176
176
  description:
177
- "DORA's five software delivery metrics for one environment (the one marked production, as the page " +
177
+ "DORA's five delivery metrics for one environment (the one marked production, as the page " +
178
178
  'reports them), computed from its deployment history over the last 30, 60 or 90 days: deployment ' +
179
179
  'frequency, lead time and failed deployment recovery time (throughput), change-failure rate and rework ' +
180
180
  'rate (instability), each with its trend against the window before, and a performance band for all but ' +
@@ -196,7 +196,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
196
196
  {
197
197
  title: "Get a product's monitoring",
198
198
  description:
199
- "How a product's environments are doing: each environment's current health from Taiga's availability " +
199
+ "How a product's environments are doing (health, uptime): each environment's current health from Taiga's availability " +
200
200
  'checks of its App URL, with its uptime and when it was last checked; a status Taiga cannot verify now ' +
201
201
  'reads as not checked rather than the last thing it knew, and a new environment as baseline learning. ' +
202
202
  "With environmentId, that environment's uptime over a 30, 60 or 90-day window, and the experience " +
@@ -105,7 +105,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
105
105
  {
106
106
  title: 'Mark a product document reviewed',
107
107
  description:
108
- "Record that a person reviewed a product document's latest published version, as the Documents page " +
108
+ 'Record that a person reviewed a product document: its latest published version, as the Documents page ' +
109
109
  "shows it beside each version; once every published document is reviewed, the product's review item in " +
110
110
  'Action Required resolves. The mark says a human read it, so make it only when the person tells you they ' +
111
111
  'have: reading it yourself is not a review.',
@@ -17,7 +17,15 @@ import {
17
17
  type ProductDocumentOrigin,
18
18
  type ProductSettingsView,
19
19
  } from './catalogue.js';
20
- import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
20
+ import {
21
+ apiFailureCode,
22
+ apiFailureText,
23
+ isMissOrRefusal,
24
+ isNetworkFailure,
25
+ runTool,
26
+ toolError,
27
+ toolResult,
28
+ } from './errors.js';
21
29
  import { parseRemote } from './remote.js';
22
30
  import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
23
31
  import { setupReadiness } from './readiness.js';
@@ -30,6 +38,38 @@ type DocumentRow = WithoutTenant<ProductDocumentListItem>;
30
38
  // Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
31
39
  export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
32
40
 
41
+ /**
42
+ * Product rows with their factory's name beside its id (PRO-2800): "what
43
+ * products does the organization have" needed a second list to name the
44
+ * factories. One factories read; without factories:read, or when it fails,
45
+ * the rows keep the id alone.
46
+ */
47
+ async function withFactoryNames<T extends { factoryId?: unknown }>(client: TaigaClient, rows: T[]) {
48
+ if (!client.hasCapability('factories:read') || rows.length === 0) return rows;
49
+ try {
50
+ // Every page until the rows' factories are named, up to a bound.
51
+ const wanted = new Set(rows.map((row) => String(row.factoryId)));
52
+ const names = new Map<string, string>();
53
+ let cursor: string | undefined;
54
+ for (let pageIndex = 0; pageIndex < FACTORY_PAGES; pageIndex += 1) {
55
+ const result = await client.listFactories({ limit: 100, cursor });
56
+ for (const factory of result.data) names.set(factory.id, factory.name);
57
+ if (!result.nextCursor || [...wanted].every((id) => names.has(id))) break;
58
+ cursor = result.nextCursor;
59
+ }
60
+ return rows.map((row) => {
61
+ const name = names.get(String(row.factoryId));
62
+ return name === undefined ? row : { ...row, factoryName: name };
63
+ });
64
+ } catch (error) {
65
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return rows;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /** Factory pages read to name products' factories. */
71
+ const FACTORY_PAGES = 10;
72
+
33
73
  /** A list's name search: part of a name, any case. */
34
74
  const query = (what: string) =>
35
75
  z.string().trim().min(1).max(200).optional().describe(`Part of the ${what}, any case: only the rows that contain it`);
@@ -322,7 +362,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
322
362
  {
323
363
  title: 'List products',
324
364
  description:
325
- 'List products in the organization, as summaries (id, name, slug, status, factory, discoveryStep). ' +
365
+ 'List products in the organization, as summaries (id, name, slug, status, factoryId and factoryName, discoveryStep). ' +
326
366
  'Use this to find a product by name (query); inside a git checkout, taiga_resolve_product finds it ' +
327
367
  `from the remote URL instead. taiga_get_product reads one in full. ${DISCOVERY_STEP_MEANING}`,
328
368
  inputSchema: z.object({
@@ -346,7 +386,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
346
386
  const { query: text, ...filters } = params;
347
387
  if (text === undefined) {
348
388
  const result = await client.listProducts(filters);
349
- return toolResult(page(pickFields(result.data, PRODUCT_SUMMARY_FIELDS), result.nextCursor));
389
+ return toolResult(
390
+ page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor)
391
+ );
350
392
  }
351
393
  if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
352
394
  const found = await searchPages(
@@ -362,7 +404,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
362
404
  params.limit ?? 25
363
405
  );
364
406
  return toolResult({
365
- ...page(pickFields(found.rows, PRODUCT_SUMMARY_FIELDS), null),
407
+ ...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
366
408
  ...(found.more ? { more: SEARCH_NOTE } : {}),
367
409
  });
368
410
  })
@@ -373,7 +415,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
373
415
  {
374
416
  title: 'Get product',
375
417
  description:
376
- 'Get one product in full, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
418
+ 'Read one product record and its settings, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
377
419
  '(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
378
420
  'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
379
421
  'taiga_list_product_documents for its specification and design, ' +
@@ -15,6 +15,7 @@ import { OPERATE_SETTING, READ_ONLY } from './annotations.js';
15
15
  import { isNetworkFailure, runTool, toolResult } from './errors.js';
16
16
  import { productId } from './inputs.js';
17
17
  import { criteriaCounts } from './summarize.js';
18
+ import { handbackSaid } from './builds.js';
18
19
 
19
20
  /** How many entries the read names; past this an entry keeps its initiativeId only. */
20
21
  const NAMED_ENTRIES = 25;
@@ -32,6 +33,8 @@ interface QueueRow {
32
33
  lifecycle?: Pick<BacklogItemLifecycle, 'turn' | 'stage' | 'cta'> | null;
33
34
  runId?: string;
34
35
  prUrl?: string | null;
36
+ /** Why the agent handed the latest run back to a person, when it did. */
37
+ handedBack?: string;
35
38
  halt?: { cause: string | null; reason: string; nextStep: string };
36
39
  }
37
40
 
@@ -89,6 +92,7 @@ function waitingOn(rows: QueueRow[]): string | null {
89
92
  // An active entry with a landed plan is past its review gate (a closed
90
93
  // gate halts it instead): it waits for the Build slot, not for anyone.
91
94
  if (lifecycle.cta === 'start_build') return `${name} has its plan and waits for the Build slot; ${held}`;
95
+ if (head.handedBack) return `${name} was handed back to a person: ${head.handedBack}${pr}; ${held}`;
92
96
  if (lifecycle.turn === 'you') {
93
97
  return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
94
98
  }
@@ -145,6 +149,8 @@ export async function queueView(
145
149
  lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
146
150
  ...(latestBuild ? { runId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
147
151
  } satisfies Partial<QueueRow>);
152
+ const handback = handbackSaid(latestBuild?.reviewEscalation);
153
+ if (handback) row.handedBack = handback;
148
154
  }
149
155
  if (entry.state === 'halted') {
150
156
  const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
@@ -202,7 +208,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
202
208
  {
203
209
  title: 'Get the product queue',
204
210
  description:
205
- "Read a product's queue, the line the coding agent works one initiative at a time: each entry in order " +
211
+ "Read a product's line, the queue the coding agent works one initiative at a time, and what it waits on: each entry in order " +
206
212
  'with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why ' +
207
213
  'it stopped and what lifts it, linePaused (null when the product could not be read), and waitingOn, one ' +
208
214
  'sentence saying what the line is waiting for. The ' +
@@ -18,10 +18,10 @@ export interface Readiness {
18
18
  ready: boolean | null;
19
19
  /** Null when the links could not be read: not the same as none linked. */
20
20
  repository: { linked: false } | { linked: true; name: unknown; branch: unknown } | null;
21
- /** An imported product's import: not started, running, complete, failed. */
21
+ /** An imported product's import, as its settings.importStatus says it: pending (not started), analyzing or generating_docs (running), complete, failed, cancelled. */
22
22
  import?: string;
23
23
  discoveryStep?: unknown;
24
- /** The required documents not yet published, by name; null when the API could not say. */
24
+ /** The required documents not yet published, as the type taiga_get_product_document takes; null when the API could not say. */
25
25
  missingDocuments: string[] | null;
26
26
  /** The required checks not yet stamped (stamped in the web); null when the API could not say. */
27
27
  openChecks: string[] | null;
@@ -49,12 +49,17 @@ function named(types: string[]): string {
49
49
  }
50
50
 
51
51
  /** The product's setup readiness, or null once it has finished setup. */
52
- export async function setupReadiness(client: TaigaClient, product: Row): Promise<Readiness | null> {
52
+ export async function setupReadiness(
53
+ client: TaigaClient,
54
+ product: Row,
55
+ /** The product's repository links when the caller is reading them anyway (the context), so one read serves both. */
56
+ links?: () => Promise<{ data: unknown[] }>
57
+ ): Promise<Readiness | null> {
53
58
  if (product['status'] !== 'draft') return null;
54
59
  const id = String(product['id']);
55
60
  const imported = isImportedProduct(product as ProductSettingsView);
56
61
  const [repos, blockers, full] = await Promise.all([
57
- client.listProductRepositories(id).then(
62
+ (links ? links() : client.listProductRepositories(id)).then(
58
63
  (result) => result.data as Row[],
59
64
  (error: unknown) => {
60
65
  if (isApiSide(error)) return null;
@@ -97,9 +102,9 @@ export async function setupReadiness(client: TaigaClient, product: Row): Promise
97
102
  return {
98
103
  ready,
99
104
  repository,
100
- ...(importState ? { import: importState } : {}),
105
+ ...(imported ? { import: typeof settings.importStatus === 'string' ? settings.importStatus : 'pending' } : {}),
101
106
  ...(imported ? {} : { discoveryStep: full['discoveryStep'] ?? null }),
102
- missingDocuments: missing ? missing.map((type) => named([type])) : null,
107
+ missingDocuments: missing,
103
108
  openChecks,
104
109
  nextStep: nextStepFor({
105
110
  imported,
@@ -211,8 +211,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
211
211
  {
212
212
  title: 'Link a repository to a product',
213
213
  description:
214
- 'Link a repository the GitHub connection can reach (taiga_list_linkable_repositories gives its id) to a ' +
215
- 'product, or with replacesRepositoryId swap the one linked now for it in one step (naming the same ' +
214
+ 'Link a GitHub repository to a product: one the GitHub connection can reach (taiga_list_linkable_repositories gives its id), ' +
215
+ 'or with replacesRepositoryId swap the one linked now for it in one step (naming the same ' +
216
216
  'repository there changes nothing; linking one already linked without it is refused). A link Taiga ' +
217
217
  'manages (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, ' +
218
218
  'sweeps scan it, and its deployment history is imported in the background.',
@@ -168,9 +168,33 @@ export const sectionsInput = z
168
168
  .max(50)
169
169
  .optional()
170
170
  .describe(
171
- 'Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
171
+ 'Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
172
172
  );
173
173
 
174
+ /** A policy control id as the standing cites it: SEC-5.1, SDLC-3.1, GDPR-2.3. */
175
+ const CONTROL_ID = /^[A-Z][A-Z0-9]*-\d+(\.\d+)*$/;
176
+
177
+ /**
178
+ * The sections that state each control id asked for (PRO-2800): policy
179
+ * standing cites SEC-5.1, and the section holding it has an id of its own
180
+ * (secretsManagement) an agent could only guess. A control id is found in
181
+ * the sections' text, matched as a whole id so SEC-5.1 is not SEC-5.10.
182
+ */
183
+ function controlSections(sections: Sections, ids: readonly string[]): Record<string, string[]> {
184
+ const found: Record<string, string[]> = {};
185
+ for (const id of ids) {
186
+ if (!CONTROL_ID.test(id) || Object.hasOwn(sections, id)) continue;
187
+ // Whole id only: not SEC-5.10, SEC-5.1.2, SEC-5.1A or SEC-5.1-appendix; a sentence's full stop is fine.
188
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ const pattern = new RegExp(`(^|[^A-Za-z0-9.-])${escaped}(?![A-Za-z0-9-]|\\.[A-Za-z0-9])`);
190
+ const holders = Object.keys(sections).filter((sectionId) =>
191
+ pattern.test(sectionContent(sections, sectionId) ?? '')
192
+ );
193
+ if (holders.length > 0) found[id] = holders;
194
+ }
195
+ return found;
196
+ }
197
+
174
198
  /**
175
199
  * The answer to a read that names sections: the ones the document has, the
176
200
  * rest listed with their sizes, and the asked-for ids it lacks under
@@ -179,12 +203,17 @@ export const sectionsInput = z
179
203
  * real id with its size, so the retry needs nothing else. No ids is the
180
204
  * outline.
181
205
  */
182
- export function sectionReadResult(doc: StoredDocument, ids: readonly string[], label: string): CallToolResult {
206
+ export function sectionReadResult(doc: StoredDocument, asked: readonly string[], label: string): CallToolResult {
207
+ const before = sectionsOf(doc.data as DocumentData);
208
+ const controls = before ? controlSections(before, asked) : {};
209
+ // A control id reads the sections that state it; the id is then not unknown.
210
+ const ids = [...new Set([...asked.filter((id) => !Object.hasOwn(controls, id)), ...Object.values(controls).flat()])];
183
211
  const cut = pickSections(doc, new Set(ids));
184
212
  if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
185
213
  const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
186
214
  const unknownSections = ids.filter((id) => !all.includes(id));
187
215
  const known = ids.filter((id) => all.includes(id));
216
+ const found = Object.keys(controls).length > 0 ? { controls } : {};
188
217
  if (known.length === 0) {
189
218
  // The outline, or a read naming only ids the document lacks: no text to
190
219
  // carry, so no empty data.sections beside the ids, which read as a
@@ -198,6 +227,7 @@ export function sectionReadResult(doc: StoredDocument, ids: readonly string[], l
198
227
  }
199
228
  return toolResult({
200
229
  ...cut.doc,
230
+ ...found,
201
231
  omittedSections: cut.omittedSections,
202
232
  ...(unknownSections.length > 0 ? { unknownSections } : {}),
203
233
  });
@@ -268,7 +268,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
268
268
  {
269
269
  title: "Change a product's settings",
270
270
  description:
271
- "Change a product's name (its slug follows the name, so links to the product change) or what Taiga " +
271
+ "Change a product's name or what Taiga builds for it. A new name changes the slug, so links to the product change. What Taiga " +
272
272
  'builds for it: its infrastructure code and CI/CD pipelines (null ' +
273
273
  'returns one to the organization default), the sentence on how a half Taiga does not build is done today, ' +
274
274
  "and whether it inherits the organization's design standards (null follows the default for how the " +
@@ -306,7 +306,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
306
306
  {
307
307
  title: "Refresh a product's documents from its code",
308
308
  description:
309
- "Derive the reality documents again from the code on the branch Taiga works on (the link's effectiveBranch: " +
309
+ "Derive the reality documents again from the code, on the branch Taiga works on (the link's effectiveBranch: " +
310
310
  "the chosen branch, or the repository's default): all of them, or the ones named (with " +
311
311
  'what they are derived from). For a document the import misread, refresh it rather than importing ' +
312
312
  'again; unchanged sections are not republished. Runs an agent in the background and returns the job; ' +
package/src/tools/wait.ts CHANGED
@@ -9,7 +9,7 @@ import { isNetworkFailure, runTool, toolResult } from './errors.js';
9
9
  import { SETTLED_JOB_STATUSES, jobReport } from './jobs.js';
10
10
  import { criteriaCounts, pickFields } from './summarize.js';
11
11
  import { situationOf } from './backlogs.js';
12
- import { readableRun } from './builds.js';
12
+ import { readableRow } from './builds.js';
13
13
 
14
14
  /**
15
15
  * Waiting without a timer. A model in an editor has no clock: to learn that
@@ -324,6 +324,8 @@ const RUN_REPORT_FIELDS = [
324
324
  'prState',
325
325
  'prDraft',
326
326
  'prMerged',
327
+ 'reviewEscalation',
328
+ 'error',
327
329
  'prNumber',
328
330
  'baseBranch',
329
331
  'actions',
@@ -358,7 +360,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
358
360
  {
359
361
  title: 'Wait for an initiative',
360
362
  description:
361
- 'Wait until an initiative reaches a point, instead of reading it again and again: changed (anything ' +
363
+ 'Wait for an initiative to reach a point (planned, building, a pull request, done), instead of reading it again and again: changed (anything ' +
362
364
  'about where it is or its latest run), planned (its plan landed), your_turn (it waits on a person: a ' +
363
365
  'plan to review, a pull request, a flag), pr_open (a pull request open now; a merged or closed one does ' +
364
366
  'not count), run_settled (its latest run stopped moving) or done (finished; a canceled one stops the wait). ' +
@@ -423,7 +425,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
423
425
  {
424
426
  title: 'Wait for a run',
425
427
  description:
426
- "Wait until a coding agent's run reaches a point: changed (status, steps or pull request), step_done " +
428
+ 'Wait for a run to reach a point (a step, a pull request, settled): changed (status, steps or pull request), step_done ' +
427
429
  '(one more step finished), pr_open (open now, not merged or closed) or settled (it stopped moving: in ' +
428
430
  'review, completed, failed, stopped). ' +
429
431
  'Returns as soon as that holds (reached: true), early when the run settled without it (stoppedBecause; ' +
@@ -454,7 +456,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
454
456
  return toolResult({
455
457
  until: params.until,
456
458
  ...outcome,
457
- run: now ? readableRun(pickFields([now], RUN_REPORT_FIELDS)[0]!) : null,
459
+ run: now ? readableRow(pickFields([now], RUN_REPORT_FIELDS)[0]!) : null,
458
460
  });
459
461
  })
460
462
  );