@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451

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.
@@ -15,8 +15,6 @@ import { pickFields } from './summarize.js';
15
15
  * is taiga_set_auto_build.
16
16
  */
17
17
 
18
- type Row = Record<string, unknown>;
19
-
20
18
  export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
21
19
  if (client.hasCapability('products:write')) {
22
20
  server.registerTool(
@@ -54,7 +52,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
54
52
  ...(params.autoBuild !== undefined ? { autoBuild: params.autoBuild } : {}),
55
53
  ...(autoMerge !== undefined ? { autoMerge } : {}),
56
54
  })
57
- ).data as Row;
55
+ ).data;
58
56
  return toolResult(pickFields([product], ['id', 'name', 'slug', 'autoBuild', 'autoMerge'])[0]);
59
57
  })
60
58
  );
@@ -72,7 +70,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
72
70
  'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
73
71
  'Taiga merges through your rules, never around them.',
74
72
  inputSchema: z.object({
75
- itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
73
+ initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
76
74
  autoMerge: z
77
75
  .boolean()
78
76
  .nullable()
@@ -82,7 +80,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
82
80
  },
83
81
  (params) =>
84
82
  runTool(async () => {
85
- const item = (await client.updateBacklogItem(params.itemId, { autoMerge: params.autoMerge })).data as Row;
83
+ const item = (await client.updateBacklogItem(params.initiativeId, { autoMerge: params.autoMerge })).data;
86
84
  return toolResult(pickFields([item], ['id', 'title', 'state', 'autoMerge', 'autoBuild'])[0]);
87
85
  })
88
86
  );
@@ -1,13 +1,26 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { TaigaApiError, type TaigaClient } from '../client.js';
4
- import { PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES, USER_DROP_STATES, resolveAutoBuild } from '@taiga/types';
3
+ import {
4
+ type ActivityEvent,
5
+ type ActivityActor,
6
+ type BacklogItem,
7
+ BUILD_RUN_STATUSES,
8
+ PLAN_REJECTION_COMMENT_MAX_CHARS,
9
+ PR_CLOSING_STATES,
10
+ USER_DROP_STATES,
11
+ renderHalt,
12
+ resolveAutoBuild,
13
+ type BuildRunStatus,
14
+ type Halt,
15
+ } from '@taiga/types';
16
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
17
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
6
18
  import { idInput, pagination, productId } from './inputs.js';
7
19
  import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
8
20
  import { jobReport } from './jobs.js';
9
21
  import { requestPrompt } from './request-prompt.js';
10
22
  import { queueView } from './queue.js';
23
+ import { landedDisposition } from './builds.js';
11
24
  import {
12
25
  SEARCH_NOTE,
13
26
  SEARCH_PAGE_SIZE,
@@ -61,11 +74,106 @@ const ITEM_OUTCOME_FIELDS = [
61
74
  * leaves it null, and the lifecycle is what says why and what moves it on.
62
75
  */
63
76
  const LIFECYCLE_MEANING =
64
- 'lifecycle says whose turn it is and what moves it on: cta start_plan is taiga_queue_backlog_item, ' +
77
+ 'situation says in one sentence where it stands, whose turn it is and the call that moves it on. The same ' +
78
+ 'as fields: lifecycle says whose turn it is and what moves it on: cta start_plan is taiga_queue_initiative, ' +
65
79
  'retry_plan taiga_plan_again (planFailureCause says why the plan stopped), start_build taiga_approve_plan, ' +
66
- 'resume_build taiga_resume_backlog_item, retry_build a fresh run (start over, in the web), review_pr and ' +
80
+ 'resume_build taiga_resume_initiative, retry_build a fresh run (start over, in the web), review_pr and ' +
67
81
  'view_pr the pull request. needsHumanCause is set only when the line stopped on it.';
68
82
 
83
+ /** The move each call to action is, in the tools' words. */
84
+ const CTA_MOVE: Record<string, string> = {
85
+ start_plan: 'taiga_queue_initiative puts it on the line to be planned and built (paid work: ask first)',
86
+ retry_plan: 'taiga_plan_again asks for a new plan (paid work: ask first)',
87
+ start_build:
88
+ 'read the plan with taiga_get_plan, then taiga_approve_plan builds it or taiga_reject_plan sends it back',
89
+ resume_build: 'taiga_resume_initiative carries the run on (paid work: ask first)',
90
+ retry_build: 'a new run starts over from the web; taiga_move_initiative takes it off the line',
91
+ review_pr: 'a person reviews and merges the pull request',
92
+ view_pr: 'the line merges the pull request once its checks pass',
93
+ };
94
+
95
+ /** How a run stands, as the end of "its latest run ...", by disposition. */
96
+ const RUN_STANDS: Record<string, string> = {
97
+ in_flight: 'is running',
98
+ gated: 'is in review',
99
+ succeeded: 'merged',
100
+ unmerged: 'settled unmerged',
101
+ failed: 'failed',
102
+ stopped: 'was stopped',
103
+ rejected: 'had its pull request closed without merging',
104
+ };
105
+
106
+ /** Where an initiative is, in a phrase, by its lifecycle stage. */
107
+ const STAGE: Record<string, string> = {
108
+ backlog: 'In the backlog, not on the line yet',
109
+ planning: 'Being planned',
110
+ planned: 'Planned',
111
+ building: 'Being built',
112
+ in_review: 'Its pull request is in review',
113
+ done: 'Done',
114
+ canceled: 'Canceled',
115
+ };
116
+
117
+ /**
118
+ * Where an initiative stands and what moves it on, in one sentence (PRO-2800).
119
+ * Five fields carried it (status, state, planningStatus, needsHumanCause,
120
+ * lifecycle) and a reviewing agent had to read the queue, the initiative and
121
+ * the run together to say what had happened; the lifecycle the API derives
122
+ * already decides it. Null for a row without a lifecycle.
123
+ */
124
+ export function situationOf(item: Record<string, unknown>): string | null {
125
+ const life = item['lifecycle'] as
126
+ | { turn?: unknown; stage?: unknown; cta?: unknown; planFailureCause?: unknown; latestBuild?: unknown }
127
+ | null
128
+ | undefined;
129
+ if (!life || typeof life.stage !== 'string') return null;
130
+ const parts: string[] = [];
131
+ let stage = STAGE[life.stage] ?? life.stage;
132
+ if (life.stage === 'planning' && life.turn === 'you') {
133
+ stage = `Planning stopped${typeof life.planFailureCause === 'string' ? ` (${life.planFailureCause})` : ''}`;
134
+ }
135
+ const build = life.latestBuild as { status?: unknown; prNumber?: unknown; prMerged?: unknown } | null | undefined;
136
+ if (build && typeof build.status === 'string' && life.stage !== 'done' && life.stage !== 'canceled') {
137
+ const pr = typeof build.prNumber === 'number' ? ` (PR #${build.prNumber})` : '';
138
+ const said = (BUILD_RUN_STATUSES as readonly string[]).includes(build.status)
139
+ ? RUN_STANDS[landedDisposition(build.status as BuildRunStatus, build.prMerged === true)]
140
+ : build.status;
141
+ stage += `; its latest run ${said}${pr}`;
142
+ }
143
+ parts.push(`${stage}.`);
144
+ if (typeof item['needsHumanCause'] === 'string') {
145
+ const halt = renderHalt({ cause: item['needsHumanCause'], params: {} } as unknown as Halt);
146
+ const detail = typeof item['needsHumanDetail'] === 'string' ? item['needsHumanDetail'] : '';
147
+ // The halt says what stopped the line; the detail is the run's own reason, kept beside it.
148
+ const why = [halt, detail]
149
+ .filter(Boolean)
150
+ .map((text) => text.replace(/\.$/, ''))
151
+ .join('. ');
152
+ if (why) parts.push(`The line stopped on it: ${why}.`);
153
+ }
154
+ const turn = life.turn === 'you' ? "A person's turn" : life.turn === 'agent' ? "The agent's turn" : null;
155
+ const move = typeof life.cta === 'string' ? CTA_MOVE[life.cta] : undefined;
156
+ if (life.cta === 'view_pr') parts.push(`Nothing to do: ${move}.`);
157
+ else if (turn && move) parts.push(`${turn}: ${move}.`);
158
+ else if (turn) parts.push(`${turn}.`);
159
+ else if (move) parts.push(`Next: ${move}.`);
160
+ return parts.join(' ');
161
+ }
162
+
163
+ /** A row or item with its situation added when it has a lifecycle. */
164
+ export function withSituation(
165
+ item: Record<string, unknown>,
166
+ row: Record<string, unknown> = item
167
+ ): Record<string, unknown> {
168
+ const situation = situationOf(item);
169
+ return situation ? { ...row, situation } : row;
170
+ }
171
+
172
+ /** List rows, each with its situation. */
173
+ export function situatedRows(items: Record<string, unknown>[]): Record<string, unknown>[] {
174
+ return itemRows(items, BACKLOG_SUMMARY_FIELDS).map((row, index) => withSituation(items[index]!, row));
175
+ }
176
+
69
177
  /** What each plan failure cause means for whoever reads it next (PLAN_FAILURE_CAUSES in @taiga/types). */
70
178
  const PLAN_FAILURE_MEANING: Record<string, string> = {
71
179
  agent_error: 'The planning agent failed. Planning again is the usual next step.',
@@ -80,19 +188,19 @@ const PLAN_FAILURE_MEANING: Record<string, string> = {
80
188
  */
81
189
  async function planFailureOf(
82
190
  client: TaigaClient,
83
- itemId: string,
191
+ initiativeId: string,
84
192
  itemCause: unknown
85
193
  ): Promise<Record<string, unknown> | null> {
86
- let events: Record<string, unknown>[];
194
+ let events: WithoutTenant<ActivityEvent>[];
87
195
  try {
88
- events = (await client.listBacklogItemActivity(itemId)).data as Record<string, unknown>[];
196
+ events = (await client.listBacklogItemActivity(initiativeId)).data;
89
197
  } catch (error) {
90
198
  if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
91
199
  throw error;
92
200
  }
93
201
  // Only a failure of the latest attempt: one before the last plan_started
94
202
  // belongs to an earlier attempt, even when it failed for the same reason.
95
- let failed: Record<string, unknown> | undefined;
203
+ let failed: WithoutTenant<ActivityEvent> | undefined;
96
204
  for (const event of [...events].reverse()) {
97
205
  if (event.kind !== 'turn') continue;
98
206
  if (event.trigger === 'plan_started') break;
@@ -102,8 +210,8 @@ async function planFailureOf(
102
210
  }
103
211
  }
104
212
  if (!failed) return null;
105
- const actor = (failed.actor ?? {}) as { type?: unknown; name?: unknown };
106
- const data = (failed.data ?? {}) as { cause?: unknown };
213
+ const actor: Partial<ActivityActor> = failed.actor ?? {};
214
+ const data: { cause?: unknown } = failed.data ?? {};
107
215
  const cause = typeof data.cause === 'string' ? data.cause : null;
108
216
  // The event is recorded best effort: one that disagrees with the item's own
109
217
  // cause belongs to an earlier failure, and its who and when are not this one's.
@@ -125,7 +233,7 @@ async function planFailureOf(
125
233
 
126
234
  const planId = idInput('plan', "taiga_get_plan gives the initiative's active plan and its ID");
127
235
 
128
- const itemId = idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it');
236
+ const initiativeId = idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it');
129
237
 
130
238
  /** The plan without its steps' prose: enough to approve it or say what a run will do. */
131
239
  const PLAN_SUMMARY_FIELDS = ['id', 'backlogItemId', 'version', 'status', 'createdAt'] as const;
@@ -140,13 +248,16 @@ const PLAN_STEP_SUMMARY_FIELDS = [
140
248
  'createsResources',
141
249
  ] as const;
142
250
 
143
- /** The outcome fields of an initiative, with how many of its criteria are met. */
144
- const outcomeOf = (item: unknown) => {
145
- const row = item as Record<string, unknown>;
146
- return { ...pickFields([row], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(row) };
147
- };
251
+ type Item = WithoutTenant<BacklogItem>;
148
252
 
149
- const outcome = (item: unknown) => toolResult(outcomeOf(item));
253
+ /** The outcome fields of an initiative, with how many of its criteria are met, and where it stands. */
254
+ const outcomeOf = (item: Item) =>
255
+ withSituation(item as unknown as Record<string, unknown>, {
256
+ ...pickFields([item], ITEM_OUTCOME_FIELDS)[0],
257
+ criteria: criteriaCounts(item),
258
+ });
259
+
260
+ const outcome = (item: Item) => toolResult(outcomeOf(item));
150
261
 
151
262
  /**
152
263
  * Each tool is registered under the capability its route requires, so a
@@ -180,15 +291,16 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
180
291
 
181
292
  function registerBacklogReads(server: McpServer, client: TaigaClient) {
182
293
  server.registerTool(
183
- 'taiga_list_backlog_items',
294
+ 'taiga_list_initiatives',
184
295
  {
185
- title: 'List backlog items',
296
+ title: 'List initiatives',
186
297
  description:
187
- "List a product's backlog items (initiatives): titles, category, status, where each one is on the " +
188
- 'line (backlog, todo, queue, plan, build, done) and criteria, how many of its acceptance criteria are met ' +
298
+ "List a product's initiatives, its backlog: titles, category, status, where each one is on the " +
299
+ 'line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on, in a ' +
300
+ 'sentence) and criteria, how many of its acceptance criteria are met ' +
189
301
  '(null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked ' +
190
302
  'off, so a done item can show fewer met than it has). Use this to find the item you are working on (query ' +
191
- 'finds one by title), then read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
303
+ 'finds one by title), then read its acceptance criteria and technical context with taiga_get_initiative. ' +
192
304
  'Archived items are left out unless asked for.',
193
305
  inputSchema: z.object({
194
306
  productId,
@@ -218,9 +330,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
218
330
  const { query, ...filters } = params;
219
331
  if (query === undefined) {
220
332
  const result = await client.listBacklogItems(params.productId, filters);
221
- return toolResult(
222
- page(itemRows(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
223
- );
333
+ return toolResult(page(situatedRows(result.data as Record<string, unknown>[]), result.nextCursor));
224
334
  }
225
335
  if (params.cursor !== undefined) {
226
336
  return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
@@ -232,33 +342,37 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
232
342
  params.limit ?? 25
233
343
  );
234
344
  return toolResult({
235
- ...page(itemRows(found.rows, BACKLOG_SUMMARY_FIELDS), null),
345
+ ...page(situatedRows(found.rows), null),
236
346
  ...(found.more ? { more: SEARCH_NOTE } : {}),
237
347
  });
238
348
  })
239
349
  );
240
350
 
241
351
  server.registerTool(
242
- 'taiga_get_backlog_item',
352
+ 'taiga_get_initiative',
243
353
  {
244
- title: 'Get backlog item',
354
+ title: 'Get initiative',
245
355
  description:
246
- 'Get one backlog item in full: description, acceptance criteria (and which are met), technical ' +
356
+ 'Get one initiative in full: description, acceptance criteria (and which are met), technical ' +
247
357
  'context (patterns, constraints, and the assumptions to verify before starting), dependencies and source documents. Read this before ' +
248
- 'implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did. ' +
358
+ 'implementing an item, and read its runs with taiga_list_runs to see what the coding agent already did. ' +
249
359
  'A failed plan carries planFailure: the cause, what it means, when, and by whom. ' +
250
360
  LIFECYCLE_MEANING,
251
361
  inputSchema: z.object({
252
- itemId,
362
+ initiativeId,
253
363
  }),
254
364
  annotations: READ_ONLY,
255
365
  },
256
366
  (params) =>
257
367
  runTool(async () => {
258
- const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
259
- if (item.planningStatus !== 'failed') return toolResult(item);
260
- const planFailure = await planFailureOf(client, params.itemId, item.planFailureCause);
261
- return toolResult(planFailure ? { ...item, planFailure } : item);
368
+ const item = (await client.getBacklogItem(params.initiativeId)).data;
369
+ const row = item as unknown as Record<string, unknown>;
370
+ if (item.planningStatus !== 'failed') return toolResult(withSituation(row));
371
+ // Always undefined: the API sends the cause only under `lifecycle`, so
372
+ // planFailureOf's cross-check never fires (PRO-2799). Kept as it was.
373
+ const ownCause = (item as { planFailureCause?: unknown }).planFailureCause;
374
+ const planFailure = await planFailureOf(client, params.initiativeId, ownCause);
375
+ return toolResult(withSituation(row, planFailure ? { ...item, planFailure } : item));
262
376
  })
263
377
  );
264
378
 
@@ -274,16 +388,16 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
274
388
  "full: true for each step's complete instructions, several thousand tokens on a large plan. NOT_FOUND " +
275
389
  'when the initiative has no active plan yet (planningStatus says whether one is being generated).',
276
390
  inputSchema: z.object({
277
- itemId,
391
+ initiativeId,
278
392
  full: z.boolean().optional().describe("Carry each step's complete instructions (default false: a summary)"),
279
393
  }),
280
394
  annotations: READ_ONLY,
281
395
  },
282
396
  (params) =>
283
397
  runTool(async () => {
284
- const plan = (await client.getActivePlan(params.itemId)).data as Record<string, unknown>;
398
+ const plan = (await client.getActivePlan(params.initiativeId)).data;
285
399
  if (params.full) return toolResult(plan);
286
- const steps = Array.isArray(plan.steps) ? (plan.steps as Record<string, unknown>[]) : [];
400
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
287
401
  return toolResult({
288
402
  ...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
289
403
  steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
@@ -331,7 +445,7 @@ function registerRequest(server: McpServer, client: TaigaClient) {
331
445
  targetId: params.productId,
332
446
  ...(userPrompt ? { metadata: { userPrompt } } : {}),
333
447
  })
334
- ).data as Record<string, unknown>;
448
+ ).data;
335
449
  return toolResult(jobReport(job));
336
450
  })
337
451
  );
@@ -339,12 +453,12 @@ function registerRequest(server: McpServer, client: TaigaClient) {
339
453
 
340
454
  function registerMove(server: McpServer, client: TaigaClient) {
341
455
  server.registerTool(
342
- 'taiga_move_backlog_item',
456
+ 'taiga_move_initiative',
343
457
  {
344
- title: 'Move backlog item on the board',
458
+ title: 'Move initiative on the board',
345
459
  description:
346
460
  'Move an initiative on the board, or order it within a column: backlog and todo are yours and the line ' +
347
- 'never moves an initiative out of them; queue puts it in the line (taiga_queue_backlog_item says more); ' +
461
+ 'never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); ' +
348
462
  'done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this ' +
349
463
  'now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan ' +
350
464
  'being written or the run in flight: that is how work is stopped, and it throws that work away. A move to ' +
@@ -355,9 +469,9 @@ function registerMove(server: McpServer, client: TaigaClient) {
355
469
  'archived: true files the initiative where it stands (pass its current column as state; never from the Build station, plan or build) ' +
356
470
  'and archived: false brings it back; archived work is left out of lists unless includeArchived asks for it.',
357
471
  inputSchema: z.object({
358
- itemId,
472
+ initiativeId,
359
473
  state: z.enum(USER_DROP_STATES).describe('The column to move into'),
360
- afterId: idInput('backlog item', 'taiga_list_backlog_items gives it')
474
+ afterId: idInput('initiative', 'taiga_list_initiatives gives it')
361
475
  .nullable()
362
476
  .optional()
363
477
  .describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
@@ -370,7 +484,7 @@ function registerMove(server: McpServer, client: TaigaClient) {
370
484
  },
371
485
  (params) =>
372
486
  runTool(async () => {
373
- const { itemId: id, ...body } = params;
487
+ const { initiativeId: id, ...body } = params;
374
488
  // What the route checks on top of backlog:write for a drop into the
375
489
  // queue; refused here so the model learns which capability, not just
376
490
  // that it was forbidden.
@@ -400,10 +514,10 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
400
514
  'stays open. Offered only when the turn is yours: refused (CONFLICT) while an agent holds it (planning, ' +
401
515
  'building, reviewing). Also refused while a dependency is still in flight or a UI direction has no ' +
402
516
  'chosen concept, unless force says to plan anyway, which only the person decides. Anywhere off the ' +
403
- 'Build station, put the initiative in the queue instead (taiga_queue_backlog_item): the queue is the ' +
517
+ 'Build station, put the initiative in the queue instead (taiga_queue_initiative): the queue is the ' +
404
518
  'only way to start work. To send a plan back with direction, taiga_reject_plan. Starts a planner run.',
405
519
  inputSchema: z.object({
406
- itemId,
520
+ initiativeId,
407
521
  force: z
408
522
  .boolean()
409
523
  .optional()
@@ -415,16 +529,16 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
415
529
  },
416
530
  (params) =>
417
531
  runTool(async () => {
418
- const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
532
+ const item = (await client.getBacklogItem(params.initiativeId)).data;
419
533
  if (!BUILD_STATION.has(String(item.state))) {
420
534
  return toolError(
421
535
  'CONFLICT',
422
536
  `This initiative is in ${String(item.state)}, not at the Build station. The queue is the only way to ` +
423
- 'start work: put it in the queue with taiga_queue_backlog_item and it is planned when its turn comes.'
537
+ 'start work: put it in the queue with taiga_queue_initiative and it is planned when its turn comes.'
424
538
  );
425
539
  }
426
540
  const body = { state: 'plan', ...(params.force ? { force: true } : {}) };
427
- return outcome((await client.transitionInitiative(params.itemId, body)).data);
541
+ return outcome((await client.transitionInitiative(params.initiativeId, body)).data);
428
542
  })
429
543
  );
430
544
  }
@@ -437,12 +551,11 @@ const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNet
437
551
  * decided it: its own setting, or the product's default when it has none.
438
552
  * Null when the product cannot be read; the outcome then says so instead.
439
553
  */
440
- async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
554
+ async function reviewGate(client: TaigaClient, item: Item) {
441
555
  const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
442
556
  try {
443
557
  // The product's default answers only when the initiative has no answer of its own.
444
- const product =
445
- own === null ? ((await client.getProduct(String(item.productId))).data as { autoBuild?: unknown }) : {};
558
+ const product: { autoBuild?: boolean | null } = own === null ? (await client.getProduct(item.productId)).data : {};
446
559
  // Absent on the product reads as its true default (@taiga/types).
447
560
  const productDefault = typeof product.autoBuild === 'boolean' ? product.autoBuild : true;
448
561
  const builds = resolveAutoBuild({ initiative: own, product: productDefault });
@@ -460,9 +573,9 @@ async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
460
573
  }
461
574
 
462
575
  /** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
463
- async function placeInQueue(client: TaigaClient, item: Record<string, unknown>) {
576
+ async function placeInQueue(client: TaigaClient, item: Item) {
464
577
  try {
465
- const view = await queueView(client, String(item.productId), { name: 'blocking' });
578
+ const view = await queueView(client, item.productId, { name: 'blocking' });
466
579
  const entry = view.entries.find((row) => row.backlogItemId === item.id);
467
580
  return { position: entry?.position ?? null, of: view.entries.length, waitingOn: view.waitingOn };
468
581
  } catch (error) {
@@ -481,9 +594,9 @@ const autoBuildInput = z
481
594
 
482
595
  function registerQueue(server: McpServer, client: TaigaClient) {
483
596
  server.registerTool(
484
- 'taiga_queue_backlog_item',
597
+ 'taiga_queue_initiative',
485
598
  {
486
- title: 'Queue backlog item',
599
+ title: 'Queue initiative',
487
600
  description:
488
601
  'Add an initiative to the product queue, the line the coding agent works: queued initiatives are ' +
489
602
  'planned and built one at a time, each advancing when the previous pull request merges. This starts ' +
@@ -491,12 +604,12 @@ function registerQueue(server: McpServer, client: TaigaClient) {
491
604
  "repository. Whether the plan is built at once or waits for a person is the initiative's review gate: " +
492
605
  "pass autoBuild: false to read the plan before anything is built (the product's default applies " +
493
606
  'otherwise, and the outcome says which it is); the gate is set first and stays set if the move is then ' +
494
- 'refused. Ask the person before calling this. An initiative already ' +
607
+ 'refused. An initiative already ' +
495
608
  'in the queue stays where it is unless afterId says where to go; one being planned or built cannot move ' +
496
609
  '(CONFLICT). Returns the initiative with its new state, its review gate and its place in the queue.',
497
610
  inputSchema: z.object({
498
- itemId,
499
- afterId: idInput('backlog item', 'taiga_list_backlog_items gives it')
611
+ initiativeId,
612
+ afterId: idInput('initiative', 'taiga_list_initiatives gives it')
500
613
  .nullable()
501
614
  .optional()
502
615
  .describe(
@@ -513,16 +626,13 @@ function registerQueue(server: McpServer, client: TaigaClient) {
513
626
  },
514
627
  (params) =>
515
628
  runTool(async () => {
516
- const { itemId: id, autoBuild, ...placement } = params;
629
+ const { initiativeId: id, autoBuild, ...placement } = params;
517
630
  // The gate first: set after the move, a plan landing in between
518
631
  // would be built under the old answer.
519
632
  if (autoBuild !== undefined) await client.setAutoBuild(id, autoBuild);
520
633
  // The move the web makes: one write that answers with the refreshed
521
634
  // item, rather than the queue route plus two reads around it.
522
- const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data as Record<
523
- string,
524
- unknown
525
- >;
635
+ const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data;
526
636
  const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
527
637
  return toolResult({
528
638
  ...outcomeOf(item),
@@ -530,7 +640,7 @@ function registerQueue(server: McpServer, client: TaigaClient) {
530
640
  queue,
531
641
  ...(gate && queue
532
642
  ? {}
533
- : { note: 'Part of the outcome could not be read; taiga_get_queue and taiga_get_product say the rest.' }),
643
+ : { note: 'Part of the outcome could not be read; taiga_get_line and taiga_get_product say the rest.' }),
534
644
  });
535
645
  })
536
646
  );
@@ -546,12 +656,12 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
546
656
  "approve or reject it (false); null follows the product's default. Standing: it applies to every plan the " +
547
657
  'initiative gets, including one being written now. Opening the gate on a plan that is already waiting ' +
548
658
  'starts its build at once. Returns the initiative with the gate that now applies.',
549
- inputSchema: z.object({ itemId, autoBuild: autoBuildInput }),
659
+ inputSchema: z.object({ initiativeId, autoBuild: autoBuildInput }),
550
660
  annotations: OPERATE_SETTING,
551
661
  },
552
662
  (params) =>
553
663
  runTool(async () => {
554
- const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data as Record<string, unknown>;
664
+ const item = (await client.setAutoBuild(params.initiativeId, params.autoBuild)).data;
555
665
  const gate = await reviewGate(client, item);
556
666
  return toolResult({
557
667
  ...outcomeOf(item),
@@ -564,9 +674,9 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
564
674
 
565
675
  function registerResume(server: McpServer, client: TaigaClient) {
566
676
  server.registerTool(
567
- 'taiga_resume_backlog_item',
677
+ 'taiga_resume_initiative',
568
678
  {
569
- title: 'Resume backlog item',
679
+ title: 'Resume initiative',
570
680
  description:
571
681
  'Resume an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
572
682
  'failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished ' +
@@ -575,10 +685,10 @@ function registerResume(server: McpServer, client: TaigaClient) {
575
685
  'initiative. Refused (CONFLICT) when nothing is flagged or the cause has no resume: a plan that failed ' +
576
686
  'with needsHumanCause null (lifecycle.cta retry_plan, planFailureCause says why) is planned again with ' +
577
687
  'taiga_plan_again. Starts paid work.',
578
- inputSchema: z.object({ itemId }),
688
+ inputSchema: z.object({ initiativeId }),
579
689
  annotations: OPERATE,
580
690
  },
581
- (params) => runTool(async () => outcome((await client.resumeInitiative(params.itemId)).data))
691
+ (params) => runTool(async () => outcome((await client.resumeInitiative(params.initiativeId)).data))
582
692
  );
583
693
  }
584
694