@taiga-ai/mcp-server 0.20.0-dev.454 → 0.21.0-dev.455

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.
@@ -22,12 +22,11 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
22
22
  {
23
23
  title: "Set a product's autonomy defaults",
24
24
  description:
25
- "Set the defaults a product's initiatives follow when they have not decided for themselves. autoBuild: " +
26
- 'false makes every landed plan wait for a person to approve or reject it; true builds it as soon as the ' +
27
- 'product has room. autoMerge: false makes pull requests wait for a person to merge them; null follows ' +
28
- "the organization and factory above (autonomous merge needs the organization's switch on, and a factory " +
29
- 'can turn it off for everything under it; neither can be opened from here). Defaults, not rules: an ' +
30
- 'initiative can decide otherwise for itself. Changes how work proceeds.',
25
+ "Set the defaults a product's initiatives follow unless they decide for themselves. autoBuild: false " +
26
+ 'makes every plan wait for a person to approve or reject it; true builds it as soon as the product has ' +
27
+ 'room. autoMerge: false makes pull requests wait for a person to merge; null follows the organization ' +
28
+ 'and factory (autonomous merge needs the organization switch on, a factory can turn it off, and neither ' +
29
+ 'is changed here).',
31
30
  inputSchema: z.object({
32
31
  productId,
33
32
  autoBuild: z.boolean().optional().describe('Build landed plans without a review by default'),
@@ -64,11 +63,10 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
64
63
  {
65
64
  title: "Set whether an initiative's pull request merges on its own",
66
65
  description:
67
- "Set whether one initiative's pull request merges on its own: true lets its pull request merge itself once the " +
68
- "repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false " +
69
- "makes it wait for a person; null follows the product's default. The final word within what the " +
70
- 'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
71
- 'Taiga merges through your rules, never around them.',
66
+ "Set whether one initiative's pull request merges itself once the repository's rules pass (required " +
67
+ 'checks, reviews, a code reviewer Taiga waits for): true, false (a person merges), or null (product ' +
68
+ 'default). Where the organization or factory has turned autonomous merge off, true changes nothing. ' +
69
+ "Taiga merges through the repository's rules, never around them.",
72
70
  inputSchema: z.object({
73
71
  initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
74
72
  autoMerge: z
@@ -5,6 +5,7 @@ import {
5
5
  type ActivityActor,
6
6
  type BacklogItem,
7
7
  BUILD_RUN_STATUSES,
8
+ INITIATIVE_STATES,
8
9
  PLAN_REJECTION_COMMENT_MAX_CHARS,
9
10
  PR_CLOSING_STATES,
10
11
  USER_DROP_STATES,
@@ -74,10 +75,9 @@ const ITEM_OUTCOME_FIELDS = [
74
75
  * leaves it null, and the lifecycle is what says why and what moves it on.
75
76
  */
76
77
  const LIFECYCLE_MEANING =
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, ' +
79
- 'retry_plan taiga_plan_again (planFailureCause says why the plan stopped), start_build taiga_approve_plan, ' +
80
- 'resume_build taiga_resume_initiative, retry_build a fresh run (start over, in the web), review_pr and ' +
78
+ 'situation says in a sentence where it stands and what moves it on. lifecycle.cta names the next call: ' +
79
+ 'start_plan taiga_queue_initiative, retry_plan taiga_plan_again (planFailureCause says why), start_build ' +
80
+ 'taiga_approve_plan, resume_build taiga_resume_initiative, retry_build a fresh run (web only), review_pr and ' +
81
81
  'view_pr the pull request. needsHumanCause is set only when the line stopped on it.';
82
82
 
83
83
  /** The move each call to action is, in the tools' words. */
@@ -304,13 +304,11 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
304
304
  {
305
305
  title: 'List initiatives',
306
306
  description:
307
- "List a product's initiatives (its backlog): titles, category, status, where each one is on the " +
308
- 'line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on, in a ' +
309
- 'sentence) and criteria, how many of its acceptance criteria are met ' +
310
- '(null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked ' +
311
- 'off, so a done item can show fewer met than it has). Use this to find the item you are working on (query ' +
312
- 'finds one by title), then read its acceptance criteria and technical context with taiga_get_initiative. ' +
313
- 'Archived items are left out unless asked for.',
307
+ "List a product's initiatives: title, category, status, place on the line (backlog, todo, queue, plan, " +
308
+ 'build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, ' +
309
+ 'confirmed with evidence or checked off by a person; null before planning, so a done initiative can show ' +
310
+ 'fewer met than it has). query finds one by title; taiga_get_initiative reads it in full. Archived ones ' +
311
+ 'are left out unless asked for.',
314
312
  inputSchema: z.object({
315
313
  productId,
316
314
  query: z
@@ -328,6 +326,12 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
328
326
  .enum(['unplanned', 'planning', 'planned', 'failed'])
329
327
  .optional()
330
328
  .describe('Only items whose plan is in this state'),
329
+ state: z
330
+ .enum(INITIATIVE_STATES)
331
+ .optional()
332
+ .describe(
333
+ 'Only items in this column of the board: backlog, todo, queue, plan, build, done or canceled (status groups them coarser)'
334
+ ),
331
335
  category: z.enum(['feature', 'bug', 'tech_debt', 'improvement']).optional().describe('Only this category'),
332
336
  includeArchived: z.boolean().optional().describe('Include archived items (default false)'),
333
337
  ...pagination,
@@ -336,7 +340,29 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
336
340
  },
337
341
  (params) =>
338
342
  runTool(async () => {
339
- const { query, ...filters } = params;
343
+ const { query, state, ...filters } = params;
344
+ // The API filters by status only; a board column is read across pages here (PRO-2800).
345
+ if (state !== undefined) {
346
+ if (params.cursor !== undefined) {
347
+ return toolError(
348
+ 'VALIDATION_ERROR',
349
+ 'A board-column filter answers one page; narrow it with status or query.'
350
+ );
351
+ }
352
+ const found = await searchPages(
353
+ (cursor) => client.listBacklogItems(params.productId, { ...filters, limit: SEARCH_PAGE_SIZE, cursor }),
354
+ query ?? '',
355
+ (row) => [row.title],
356
+ params.limit ?? 25,
357
+ (row) => row.state === state
358
+ );
359
+ return toolResult({
360
+ ...page(situatedRows(found.rows), null),
361
+ ...(found.more
362
+ ? { more: 'More items may be in that column: narrow it with status or query, or raise the limit.' }
363
+ : {}),
364
+ });
365
+ }
340
366
  if (query === undefined) {
341
367
  const result = await client.listBacklogItems(params.productId, filters);
342
368
  return toolResult(page(situatedRows(result.data as Record<string, unknown>[]), result.nextCursor));
@@ -362,11 +388,9 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
362
388
  {
363
389
  title: 'Get initiative',
364
390
  description:
365
- 'Get one initiative in full: description, acceptance criteria (and which are met), technical ' +
366
- 'context (patterns, constraints, and the assumptions to verify before starting), dependencies and source documents. Read this before ' +
367
- 'implementing an item, and read its runs with taiga_list_runs to see what the coding agent already did. ' +
368
- 'A failed plan carries planFailure: the cause, what it means, when, and by whom. ' +
369
- LIFECYCLE_MEANING,
391
+ 'Read one initiative in full: description, acceptance criteria (which are met), technical context, ' +
392
+ 'dependencies and source documents. Read it before implementing. A failed plan carries planFailure ' +
393
+ `(cause, meaning, when, by whom). ${LIFECYCLE_MEANING}`,
370
394
  inputSchema: z.object({
371
395
  initiativeId,
372
396
  }),
@@ -390,12 +414,11 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
390
414
  {
391
415
  title: 'Get the active plan',
392
416
  description:
393
- "Get an initiative's active plan with its steps, in order: each step's title, layer, dependencies " +
394
- '(the orderIndex of the steps it builds on), the files it writes and reads and the resources it creates. ' +
395
- 'That is enough to ' +
396
- 'approve or reject it (taiga_approve_plan, taiga_reject_plan) and to say what a run will do; pass ' +
397
- "full: true for each step's complete instructions, several thousand tokens on a large plan. NOT_FOUND " +
398
- 'when the initiative has no active plan yet (planningStatus says whether one is being generated).',
417
+ "Read an initiative's active plan: its steps in order, each with title, layer, dependencies (the " +
418
+ 'orderIndex of the steps it builds on), files written and read, and resources created. Enough to approve ' +
419
+ "or reject it (taiga_approve_plan, taiga_reject_plan); full: true adds each step's complete instructions " +
420
+ '(thousands of tokens). NOT_FOUND when there is no active plan yet; planningStatus says whether one is ' +
421
+ 'being generated.',
399
422
  inputSchema: z.object({
400
423
  initiativeId,
401
424
  full: z.boolean().optional().describe("Carry each step's complete instructions (default false: a summary)"),
@@ -421,18 +444,11 @@ function registerRequest(server: McpServer, client: TaigaClient) {
421
444
  {
422
445
  title: 'Request new initiatives',
423
446
  description:
424
- 'Add work the way Taiga adds it: describe it, and an agent turns the request into initiatives in the ' +
425
- "product's Backlog. The agent reads the product documents and its repository, expands the request into a " +
426
- 'full brief, and sizes it: one piece of work stays one initiative, a program of work comes back as an ordered ' +
427
- 'set with its dependencies linked. It can also add nothing, and says why: an existing initiative already ' +
428
- 'covers the request, the request is not readable as work, it is outside what the product builds, or one of ' +
429
- "the organization's policies does not allow it as asked (named, with the line it runs into and the way to " +
430
- 'decide otherwise). Leave both title and description out and the agent suggests the one next initiative the ' +
431
- 'product most needs. It runs in the background, a few minutes: this returns the job, and taiga_wait_for_job ' +
432
- 'or taiga_get_job (both need products:read) say what came of it. One request per product at a time ' +
433
- '(CONFLICT while one runs). Inline images in the description are left out; the web is where a request ' +
434
- 'carries images. It starts nothing on the line: new initiatives land in Backlog. Runs an agent; ask the ' +
435
- 'person first.',
447
+ "Ask an agent to turn described work into initiatives in the product's Backlog, and return the job. One " +
448
+ 'piece of work becomes one initiative, a program an ordered set with dependencies. It may add none and ' +
449
+ 'say why (already covered, not work, outside the product, or a named policy forbids it). Omit title and ' +
450
+ 'description for the one next initiative the product most needs. taiga_wait_for_job gives the outcome. ' +
451
+ 'One request per product at a time (CONFLICT). Nothing goes on the line.',
436
452
  inputSchema: z.object({
437
453
  productId,
438
454
  title: z.string().max(500).optional().describe('What the work is, in a line'),
@@ -466,17 +482,13 @@ function registerMove(server: McpServer, client: TaigaClient) {
466
482
  {
467
483
  title: 'Move initiative on the board',
468
484
  description:
469
- 'Move an initiative on the board, take it off the line, or order it within a column: backlog and todo are yours and the line ' +
470
- 'never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); ' +
471
- 'done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this ' +
472
- 'now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan ' +
473
- 'being written or the run in flight: that is how work is stopped, and it throws that work away. A move to ' +
474
- `${PR_CLOSING_STATES.join(', ')} CLOSES a pull request the initiative is still waiting on (a move to queue ` +
475
- 'keeps it, and done leaves it to be merged); Taiga leaves a comment on the pull request saying why. The ' +
476
- 'state machine refuses a move that is not allowed (CONFLICT). A move into queue starts paid work and ' +
477
- 'needs products:write. Ask the person before a move that starts work, stops work or closes a pull request. ' +
478
- 'archived: true files the initiative where it stands (pass its current column as state; never from the Build station, plan or build) ' +
479
- 'and archived: false brings it back; archived work is left out of lists unless includeArchived asks for it.',
485
+ 'Move an initiative between columns, take it off the line, or reorder it. The line never takes from ' +
486
+ 'backlog or todo; queue puts it on the line (taiga_queue_initiative), the only way to start work; done ' +
487
+ 'and canceled finish it by hand. Nothing moves into Build by hand; moving out of Build stops and ' +
488
+ `discards the plan or run in flight. A move to ${PR_CLOSING_STATES.join(', ')} CLOSES a pull request the ` +
489
+ 'initiative waits on (with a comment on it); queue keeps it open, and done leaves it to merge. Refused (CONFLICT) if not allowed; queue needs products:write. Ask first before ' +
490
+ 'starting or stopping work or closing a pull request. archived: true archives it in place (pass its ' +
491
+ 'current column as state; never from the Build station); false restores it.',
480
492
  inputSchema: z.object({
481
493
  initiativeId,
482
494
  state: z.enum(USER_DROP_STATES).describe('The column to move into'),
@@ -517,14 +529,11 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
517
529
  {
518
530
  title: 'Plan an initiative again',
519
531
  description:
520
- 'Supersede the plan of the initiative at the Build station and ask the planner for a new one: for a plan ' +
521
- 'that failed, a plan you do not want built, or a run whose direction was wrong once it has stopped. The ' +
522
- 'earlier plan is kept, and every run records which plan version it built; a pull request still open ' +
523
- 'stays open. Offered only when the turn is yours: refused (CONFLICT) while an agent holds it (planning, ' +
524
- 'building, reviewing). Also refused while a dependency is still in flight or a UI direction has no ' +
525
- 'chosen concept, unless force says to plan anyway, which only the person decides. Anywhere off the ' +
526
- 'Build station, put the initiative in the queue instead (taiga_queue_initiative): the queue is the ' +
527
- 'only way to start work. To send a plan back with direction, taiga_reject_plan. Starts a planner run.',
532
+ 'Replace the plan of the initiative at the Build station with a new one: for a failed plan, one you do ' +
533
+ 'not want built, or a stopped run that went wrong. The old plan is kept; an open pull request stays ' +
534
+ 'open. Refused (CONFLICT) while an agent holds the turn, and while a dependency is in flight or a UI ' +
535
+ 'direction has no chosen concept unless force is set (the person decides). Off the Build station, use ' +
536
+ 'taiga_queue_initiative; to send a plan back with direction, taiga_reject_plan.',
528
537
  inputSchema: z.object({
529
538
  initiativeId,
530
539
  force: z
@@ -607,15 +616,12 @@ function registerQueue(server: McpServer, client: TaigaClient) {
607
616
  {
608
617
  title: 'Queue initiative',
609
618
  description:
610
- 'Add an initiative to the product queue, the line the coding agent works: queued initiatives are ' +
611
- 'planned and built one at a time, each advancing when the previous pull request merges. This starts ' +
612
- 'paid work: a planner run, then a coding run that pushes commits and opens a pull request in the product ' +
613
- "repository. Whether the plan is built at once or waits for a person is the initiative's review gate: " +
614
- "pass autoBuild: false to read the plan before anything is built (the product's default applies " +
615
- 'otherwise, and the outcome says which it is); the gate is set first and stays set if the move is then ' +
616
- 'refused. An initiative already ' +
617
- 'in the queue stays where it is unless afterId says where to go; one being planned or built cannot move ' +
618
- '(CONFLICT). Returns the initiative with its new state, its review gate and its place in the queue.',
619
+ "Put an initiative on the product's line, where initiatives are planned and built one at a time, each " +
620
+ 'advancing when the previous pull request merges. Starts a planner run, then a coding run that pushes ' +
621
+ 'commits and opens a pull request. autoBuild: false makes the plan wait for a person before it is built ' +
622
+ '(otherwise the product default; the result says which); the gate is set first and stays set if the move ' +
623
+ 'is refused. An initiative already queued stays put unless afterId says where; one being planned or ' +
624
+ 'built cannot move (CONFLICT). Returns the initiative with its state, review gate and place on the line.',
619
625
  inputSchema: z.object({
620
626
  initiativeId,
621
627
  afterId: idInput('initiative', 'taiga_list_initiatives gives it')
@@ -661,10 +667,10 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
661
667
  {
662
668
  title: "Set an initiative's review gate",
663
669
  description:
664
- "Set whether an initiative's plan is built as soon as it lands (true) or waits for a person to read it and " +
665
- "approve or reject it (false); null follows the product's default. Standing: it applies to every plan the " +
666
- 'initiative gets, including one being written now. Opening the gate on a plan that is already waiting ' +
667
- 'starts its build at once. Returns the initiative with the gate that now applies.',
670
+ "Set an initiative's review gate: true builds each plan as soon as it lands, false makes it wait for a " +
671
+ 'person to approve or reject it, null follows the product default. Applies to every plan the initiative ' +
672
+ 'gets, including one being written now; opening the gate on a plan already waiting starts its run at ' +
673
+ 'once. Returns the initiative with the gate now in force.',
668
674
  inputSchema: z.object({ initiativeId, autoBuild: autoBuildInput }),
669
675
  annotations: OPERATE_SETTING,
670
676
  },
@@ -687,13 +693,11 @@ function registerResume(server: McpServer, client: TaigaClient) {
687
693
  {
688
694
  title: 'Resume initiative',
689
695
  description:
690
- 'Carry on an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
691
- 'failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished ' +
692
- 'part (the steps already committed are kept), a refused build start is cleared and retried. This is the ' +
693
- 'way to carry work on: a run is the record of an attempt, and decisions about the work are made on the ' +
694
- 'initiative. Refused (CONFLICT) when nothing is flagged or the cause has no resume: a plan that failed ' +
695
- 'with needsHumanCause null (lifecycle.cta retry_plan, planFailureCause says why) is planned again with ' +
696
- 'taiga_plan_again. Starts paid work.',
696
+ 'Carry on an initiative the line stopped on (needsHumanCause is set): a failed or cancelled plan is ' +
697
+ 'planned again, a stopped or failed run resumes from its first unfinished step (committed steps are ' +
698
+ 'kept), a refused run start is cleared and retried. Refused (CONFLICT) when nothing is flagged or the ' +
699
+ 'cause has no resume; a failed plan with needsHumanCause null (lifecycle.cta retry_plan, ' +
700
+ 'planFailureCause says why) needs taiga_plan_again.',
697
701
  inputSchema: z.object({ initiativeId }),
698
702
  annotations: OPERATE,
699
703
  },
@@ -707,10 +711,9 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
707
711
  {
708
712
  title: 'Approve a plan',
709
713
  description:
710
- "Approve an initiative's plan: its build starts now, as the approver, the same start the Start run " +
711
- 'button makes. Read the plan first with taiga_get_plan and name its ID; an approval of a plan that ' +
712
- 'was replaced since is refused (CONFLICT), as is one while a new plan is being generated or a build is ' +
713
- 'already running. Starts a coding run that pushes commits and opens a pull request.',
714
+ "Approve an initiative's plan and start its run, as the Start run button does. Read the plan first with " +
715
+ 'taiga_get_plan and pass its ID. Refused (CONFLICT) if the plan was replaced since, while a new plan is ' +
716
+ 'being generated, or while a run is already going. The run pushes commits and opens a pull request.',
714
717
  inputSchema: z.object({ planId }),
715
718
  annotations: OPERATE,
716
719
  },
@@ -722,9 +725,9 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
722
725
  {
723
726
  title: 'Reject a plan',
724
727
  description:
725
- "Send an initiative's plan back with direction: a new plan is generated with the comment as reviewer " +
726
- 'feedback and the rejected plan as its starting point, which supersedes it when the new one lands. ' +
727
- 'Read the plan first with taiga_get_plan and name its ID. Starts a planner run.',
728
+ "Send an initiative's plan back with a comment: the planner writes a new plan from it, with the comment " +
729
+ 'as reviewer feedback, and the new plan replaces this one when it lands. Read the plan first with ' +
730
+ 'taiga_get_plan and pass its ID. Starts a planner run.',
728
731
  inputSchema: z.object({
729
732
  planId,
730
733
  comment: z
@@ -160,10 +160,10 @@ export function handbackSaid(escalation: unknown): string | null {
160
160
  /** What a person does about a handback, by its reason, as the web's handback detail says it. */
161
161
  export function handbackNextStep(escalation: unknown): string {
162
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';
163
+ if (reason === 'check_weakened') return 'review the loosened check on GitHub before merging';
164
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';
165
+ return 'look at what is still running or queued in CI on GitHub, then review the pull request';
166
+ return 'fix it by hand, or review the pull request on GitHub, which hands it back to the agent';
167
167
  }
168
168
 
169
169
  /** The web's words for why Taiga keeps a pull request a draft. */
@@ -243,6 +243,72 @@ export function readableRun(run: Record<string, unknown>, opts: { row?: boolean
243
243
  };
244
244
  }
245
245
 
246
+ /** Distinct initiatives a run list reads to say which moved on; past this a waiting row says it was not checked. */
247
+ const INITIATIVE_CHECKS = 10;
248
+
249
+ /**
250
+ * The status of the initiative behind each run still waiting on its pull
251
+ * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
252
+ * "waiting for a person to review and merge" while Action Required called the
253
+ * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
254
+ * cap, one whose read failed, and every one without backlog:read is
255
+ * 'unchecked', which withInitiativeState adds to the row's outcome.
256
+ */
257
+ export async function initiativeStates(
258
+ client: TaigaClient,
259
+ rows: Record<string, unknown>[]
260
+ ): Promise<Map<string, string>> {
261
+ const states = new Map<string, string>();
262
+ // Only the runs that could still be waiting: a settled run's initiative changes nothing about it.
263
+ const waiting = rows.filter(
264
+ (row) => ['needs_review', 'completed'].includes(String(row['status'])) && row['prMerged'] !== true
265
+ );
266
+ const all = [
267
+ ...new Set(waiting.map((row) => row['backlogItemId']).filter((id): id is string => typeof id === 'string')),
268
+ ];
269
+ // Without backlog:read, or past the cap, a waiting run's initiative is not read: it is marked unchecked so
270
+ // the row does not claim a person is waiting when nobody may be.
271
+ const ids = client.hasCapability('backlog:read') ? all.slice(0, INITIATIVE_CHECKS) : [];
272
+ for (const id of all) if (!ids.includes(id)) states.set(id, 'unchecked');
273
+ await Promise.all(
274
+ ids.map(async (id) => {
275
+ try {
276
+ const item = (await client.getBacklogItem(id)).data as unknown as Record<string, unknown>;
277
+ states.set(id, String(item['status'] ?? item['state']));
278
+ } catch (error) {
279
+ if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, 'deleted');
280
+ if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
281
+ states.set(id, 'unchecked');
282
+ }
283
+ })
284
+ );
285
+ return states;
286
+ }
287
+
288
+ /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
289
+ export function withInitiativeState(
290
+ row: Record<string, unknown>,
291
+ states: Map<string, string>
292
+ ): Record<string, unknown> {
293
+ const state = states.get(String(row['backlogItemId']));
294
+ // A run still executing is left as it is: its initiative's cancel stops it, and one still going after that is
295
+ // what taiga_stop_run is for.
296
+ if (!['gated', 'unmerged'].includes(String(row['disposition']))) return row;
297
+ if (state === 'unchecked') {
298
+ return { ...row, outcome: `${String(row['outcome'])} Whether its initiative still wants it was not checked.` };
299
+ }
300
+ if (state !== 'canceled' && state !== 'deleted') return row;
301
+ const pr = typeof row['prNumber'] === 'number' ? `PR #${row['prNumber']}` : 'its pull request';
302
+ const open = row['prState'] === 'open' ? ` ${pr} is still open on the forge.` : '';
303
+ return {
304
+ ...row,
305
+ disposition: 'abandoned',
306
+ // Retry or start over cannot succeed once the initiative is gone.
307
+ actions: [],
308
+ outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`,
309
+ };
310
+ }
311
+
246
312
  /** readableRun for a list row. */
247
313
  export const readableRow = (run: Record<string, unknown>) => readableRun(run, { row: true });
248
314
 
@@ -304,8 +370,10 @@ async function activityRows(
304
370
  for (const product of products?.data ?? []) {
305
371
  nameById.set(product.id, { name: product.name, slug: product.slug });
306
372
  }
373
+ const states = await initiativeStates(client, runs.data as Record<string, unknown>[]);
307
374
  const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS)
308
375
  .map(readableRow)
376
+ .map((row) => withInitiativeState(row, states))
309
377
  .map((row) => {
310
378
  const product = nameById.get(String(row['productId']));
311
379
  return product ? { ...row, product } : row;
@@ -332,14 +400,11 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
332
400
  {
333
401
  title: 'List runs',
334
402
  description:
335
- "List the coding agent's runs, newest first. A run is one attempt at building an initiative: its branch, " +
336
- 'pull request and step progress. Each row says how it stands in one sentence (outcome) and as a ' +
337
- 'disposition: in_flight (the agent is working), gated (a person owes a review and merge), succeeded ' +
338
- '(merged), failed, stopped, rejected (the pull request was closed without merging) or unmerged. actions ' +
339
- 'name what a person may do with it. With productId, the runs of that product, optionally of one ' +
340
- 'initiative; without it, the runs of every product the key can see, each row naming its product, and ' +
341
- 'active: true narrows to the runs executing now. taiga_get_run reads one with its steps. Work is carried ' +
342
- 'on from the initiative (taiga_resume_initiative), not the run.',
403
+ 'List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), ' +
404
+ 'actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), ' +
405
+ 'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled ' +
406
+ "or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run " +
407
+ 'reads one; carry work on from the initiative (taiga_resume_initiative).',
343
408
  inputSchema: z.object({
344
409
  productId: productId.optional(),
345
410
  initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it')
@@ -378,9 +443,13 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
378
443
  limit: params.limit,
379
444
  cursor: params.cursor,
380
445
  });
446
+ const raw = result.data as Record<string, unknown>[];
447
+ const states = await initiativeStates(client, raw);
381
448
  return toolResult(
382
449
  page(
383
- pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRow),
450
+ pickFields(raw, BUILD_SUMMARY_FIELDS)
451
+ .map(readableRow)
452
+ .map((row) => withInitiativeState(row, states)),
384
453
  result.nextCursor
385
454
  )
386
455
  );
@@ -394,15 +463,13 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
394
463
  {
395
464
  title: 'Get run',
396
465
  description:
397
- 'Read one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and ' +
398
- "each step's title, status, commit, files changed, verification and headline, the first paragraph of " +
399
- "what the agent reported. A step's verification is Taiga's own check after the step (tests, lint, build), " +
400
- 'independent of what the agent says it ran: a docs-only step can show tests failed because the ' +
401
- "repository's tests fail. A run that failed or was stopped says where (stoppedIn) and when it last made " +
402
- "progress (lastProgressAt). Pass full: true for each step's complete instructions and report, several " +
403
- 'thousand tokens on a long run. Its actions name what the initiative accepts: resume and ' +
404
- 'retry_from_failed_step are taiga_resume_initiative; start_over is in the web only; to stop it, move the ' +
405
- 'initiative out of Build. A maintenance run has no initiative and is retried from its finding.',
466
+ 'Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, ' +
467
+ "status, commit, files changed, verification and headline (first paragraph of the agent's report). " +
468
+ "verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on " +
469
+ "the repository's failing tests. A failed or stopped run has stoppedIn and lastProgressAt. full: true " +
470
+ 'adds complete instructions and reports. actions: resume and retry_from_failed_step are ' +
471
+ 'taiga_resume_initiative; start_over is web only; to stop it, move the initiative out of Build. A ' +
472
+ 'maintenance run has no initiative and is retried from its finding.',
406
473
  inputSchema: z.object({
407
474
  runId,
408
475
  full: z
@@ -424,9 +491,11 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
424
491
  const rows = steps.data;
425
492
  const where = stoppedIn(runRow, rows);
426
493
  const stop = where ? { stoppedIn: where } : {};
427
- if (params.full) return toolResult({ ...readableRun(runRow), ...stop, steps: steps.data });
494
+ const states = await initiativeStates(client, [runRow as unknown as Record<string, unknown>]);
495
+ const readable = withInitiativeState(readableRun(runRow), states);
496
+ if (params.full) return toolResult({ ...readable, ...stop, steps: steps.data });
428
497
  return toolResult({
429
- ...readableRun(runRow),
498
+ ...readable,
430
499
  ...stop,
431
500
  steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
432
501
  ...step,
@@ -443,11 +512,10 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
443
512
  {
444
513
  title: 'Stop a run',
445
514
  description:
446
- 'The emergency brake: stop a run the coding agent is executing when its initiative cannot be moved. The ' +
447
- 'way to stop work is on the initiative: move it out of Build (taiga_move_initiative), which stops the ' +
448
- 'plan or run in flight. Use this only for a run whose initiative does not offer that (it is finished, ' +
449
- 'canceled or gone) yet the run is still going. The agent halts, its branch and any draft pull request ' +
450
- 'stay. Only a run whose actions include stop; the API refuses the rest with CONFLICT.',
515
+ 'Emergency stop for a run whose initiative cannot be moved out of Build (finished, canceled or gone) but ' +
516
+ 'is still going. Normally stop work by moving the initiative out of Build with taiga_move_initiative. ' +
517
+ 'The agent halts; its branch and any draft pull request stay. Only a run whose actions include stop; ' +
518
+ 'others are refused (CONFLICT).',
451
519
  inputSchema: z.object({ runId }),
452
520
  annotations: OPERATE,
453
521
  },
@@ -145,13 +145,7 @@ describe('tool descriptions against the catalogue', () => {
145
145
  // Claude Code loads only tool names and the instructions until the model
146
146
  // searches, and cuts the instructions at 2,048 characters (PRO-2800).
147
147
  expect(SERVER_INSTRUCTIONS.length).toBeLessThanOrEqual(SERVER_INSTRUCTIONS_LIMIT);
148
- for (const must of [
149
- 'taiga_get_product_context',
150
- 'Before you write',
151
- 'NOT_FOUND',
152
- 'initiative',
153
- 'Only in the web',
154
- ]) {
148
+ for (const must of ['taiga_get_product_context', 'ask-first', 'NOT_FOUND', 'initiative', 'Only in the web']) {
155
149
  expect(SERVER_INSTRUCTIONS).toContain(must);
156
150
  }
157
151
  });
@@ -167,6 +161,9 @@ describe('writeNotice', () => {
167
161
  it('says nothing for a read, and what a write does otherwise', () => {
168
162
  expect(writeNotice('r', { readOnlyHint: true })).toBe('');
169
163
  expect(writeNotice('w', { readOnlyHint: false })).toBe('Writes. ');
170
- expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^Ask first: paid or shared work/);
164
+ expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toBe('Ask first (others see it). ');
165
+ expect(writeNotice('taiga_queue_initiative', { readOnlyHint: false, destructiveHint: true })).toBe(
166
+ 'Ask first (paid agent work). '
167
+ );
171
168
  });
172
169
  });
@@ -66,13 +66,10 @@ export function named(types: readonly string[]): string {
66
66
  * tool and the instructions cannot explain them three different ways.
67
67
  */
68
68
  export const ORIGIN_MEANING: Record<ProductDocumentOrigin, string> = {
69
- plan:
70
- 'plan: the discovery documents, authored intent. Every greenfield product has them; ' +
71
- 'they say what is meant to be built.',
69
+ plan: 'plan: the discovery documents, what is meant to be built.',
72
70
  reality:
73
- 'reality: derived from the code on the main branch, what is actually built. A brownfield product ' +
74
- 'has them from its import; a greenfield product gets them once its first initiative is done (a ' +
75
- 'merged pull request marks it done) or the repository is pushed to, and they refresh as code lands.',
71
+ 'reality: derived from the code on the main branch, what is built; an imported product has them from ' +
72
+ 'its import, any other once its first initiative is done (a merged pull request) or code is pushed.',
76
73
  };
77
74
 
78
75
  /**
@@ -222,5 +219,5 @@ export function setupNextStep(
222
219
  /** What discoveryStep says, for a description that shows it. */
223
220
  export const DISCOVERY_STEP_MEANING =
224
221
  'discoveryStep is where a product in setup is: context (not started), conversation or import (the ' +
225
- 'specification is being talked out, or derived from the code), documents (the rest being generated and ' +
226
- 'reviewed), checks (stamped in the web); null once it is active.';
222
+ 'specification being written or derived from code), documents (generated and reviewed), checks (stamped in ' +
223
+ 'the web); null once it is active.';