@taiga-ai/mcp-server 0.18.0-dev.450 → 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.
@@ -73,6 +73,31 @@ const VIOLATION_FIELDS = [
73
73
  'assessedAt',
74
74
  ] as const;
75
75
 
76
+ type Row = Record<string, unknown>;
77
+
78
+ /**
79
+ * How bad a product's policy standing is, as counts: the controls something is
80
+ * recorded against, the open violations by severity, the resolved ones. The
81
+ * API's standing carries the lists only; this tool and the product context
82
+ * both count them here.
83
+ */
84
+ export function standingSummary(standing: {
85
+ controls?: unknown;
86
+ divergences?: unknown;
87
+ resolvedDivergences?: unknown;
88
+ }): { controls: number; violations: number; bySeverity: Record<string, number>; resolvedViolations: number } {
89
+ const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
90
+ const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
91
+ const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
92
+ const listed = controls.filter((c) => Number(c['divergenceCount'] ?? 0) > 0 || Number(c['findingCount'] ?? 0) > 0);
93
+ const bySeverity: Record<string, number> = {};
94
+ for (const violation of violations) {
95
+ const severity = String(violation['severity'] ?? 'unknown');
96
+ bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
97
+ }
98
+ return { controls: listed.length, violations: violations.length, bySeverity, resolvedViolations: resolved };
99
+ }
100
+
76
101
  const findingId = idInput('finding', 'taiga_list_findings gives it');
77
102
 
78
103
  export function registerMaintenanceTools(server: McpServer, client: TaigaClient) {
@@ -86,8 +111,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
86
111
  "Where a product's repository stands: open findings by severity and kind, the most pressing ones, and " +
87
112
  'the latest sweeps, so you can tell when the product was last checked and whether that check succeeded. A ' +
88
113
  'quiet list from a sweep that last ran long ago, or that failed, is not an all-clear; a product never swept ' +
89
- 'says so. Sweeps run on their own after a push and periodically; nothing here starts one. Read the ' +
90
- 'findings with taiga_list_findings and the policy standing with taiga_get_policy_standing.',
114
+ 'says so. Sweeps run on their own after a push and periodically; nothing here starts one. This is the ' +
115
+ 'overview of the scanners alone; taiga_list_findings lists each finding to act on, and ' +
116
+ 'taiga_get_policy_standing judges the product against the policies (scanner findings and the documents).',
91
117
  inputSchema: z.object({ productId }),
92
118
  annotations: READ_ONLY,
93
119
  },
@@ -120,8 +146,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
120
146
  'severity and its priority tier (act_now: known exploited; urgent: likely and serious; elevated: one of ' +
121
147
  'the two; routine), where it is, who can fix it (remediableBy: coding_agent, or upstream_only for a ' +
122
148
  'submodule or a dependency with no fixed version), the policy controls it breaches, and the initiative ' +
123
- 'already fixing it (backlogItemId). Omit status for the findings that need action; work from the top tier ' +
124
- 'down. Read one in full with taiga_get_finding.',
149
+ 'already fixing it (initiativeId). Omit status for the findings that need action; work from the top tier ' +
150
+ 'down. Read one in full with taiga_get_finding. taiga_get_maintenance has the counts and when the last ' +
151
+ 'sweep ran, which says whether an empty list can be trusted.',
125
152
  inputSchema: z.object({
126
153
  productId,
127
154
  status: z
@@ -157,7 +184,13 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
157
184
  inputSchema: z.object({ findingId }),
158
185
  annotations: READ_ONLY,
159
186
  },
160
- (params) => runTool(async () => toolResult((await client.getFinding(params.findingId)).data))
187
+ (params) =>
188
+ runTool(async () => {
189
+ // The finding's runId is the sweep that found it, not a coding run:
190
+ // in the product's words runId is what taiga_get_run takes (PRO-2800).
191
+ const { runId, ...finding } = (await client.getFinding(params.findingId)).data as Row;
192
+ return toolResult(runId === undefined ? finding : { sweepId: runId, ...finding });
193
+ })
161
194
  );
162
195
 
163
196
  server.registerTool(
@@ -169,7 +202,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
169
202
  "each with its violations (critical and high only, each citing the policy's sentence and the document " +
170
203
  'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
171
204
  'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
172
- 'counts. A control breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
205
+ 'counts. A control is breached when open scanner findings map to it, diverged when the assessment of the ' +
206
+ 'product documents found them contradicting it (a control can be both; state names the stronger, ' +
207
+ "breached). A violation's work is the initiative raised for it; live: false means that initiative is " +
208
+ 'done or canceled without the violation going away, so it needs a person or a new fix. A control ' +
209
+ 'breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
173
210
  'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
174
211
  'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
175
212
  "violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
@@ -187,21 +224,10 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
187
224
  const standing = (await client.getPolicyStanding(params.productId)).data;
188
225
  const controls = Array.isArray(standing.controls) ? standing.controls : [];
189
226
  const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
190
- const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
191
227
  const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
192
- const bySeverity: Record<string, number> = {};
193
- for (const violation of violations) {
194
- const severity = String(violation.severity ?? 'unknown');
195
- bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
196
- }
197
228
  // The counts first, on both answers: the lists run to thousands of
198
229
  // tokens, and "how bad is it" is the first question.
199
- const summary = {
200
- controls: listed.length,
201
- violations: violations.length,
202
- bySeverity,
203
- resolvedViolations: resolved,
204
- };
230
+ const summary = standingSummary(standing);
205
231
  if (params.full) return toolResult({ summary, ...standing });
206
232
  return toolResult({
207
233
  summary,
@@ -93,7 +93,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
93
93
  "Publish a product document's draft as its new version, the one agents work from. Documents generated " +
94
94
  'from it are not rewritten: each is marked outdated, and is regenerated when someone chooses to ' +
95
95
  '(taiga_regenerate_product_document, or taiga_generate_product_documents for every outdated and missing ' +
96
- 'one). Ask the person before publishing.',
96
+ 'one).',
97
97
  inputSchema: z.object({ productId, type: documentType }),
98
98
  annotations: OPERATE,
99
99
  },
@@ -15,12 +15,12 @@ import {
15
15
  isImportedProduct,
16
16
  productDocumentName,
17
17
  type ProductDocumentOrigin,
18
- setupNextStep,
19
18
  type ProductSettingsView,
20
19
  } from './catalogue.js';
21
20
  import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
22
21
  import { parseRemote } from './remote.js';
23
22
  import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
23
+ import { setupReadiness } from './readiness.js';
24
24
  import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
25
25
 
26
26
  type ProductRow = WithoutTenant<Product>;
@@ -377,8 +377,10 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
377
377
  '(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
378
378
  'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
379
379
  'taiga_list_product_documents for its specification and design, ' +
380
- 'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did. A product ' +
381
- 'still in setup carries nextStep: what it waits for and the call that moves it on.',
380
+ 'taiga_list_initiatives for its work and taiga_list_runs for what the coding agent did. A product ' +
381
+ 'still in setup carries readiness: whether it can finish setup now, its repository and import, the ' +
382
+ 'required documents not yet published, the checks not yet stamped, and nextStep, the one move that ' +
383
+ 'gets it closer.',
382
384
  inputSchema: z.object({
383
385
  productId: productId.optional(),
384
386
  slug: slug.optional(),
@@ -389,8 +391,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
389
391
  runTool(async () => {
390
392
  const resolved = await resolveProductRef(client, params, ['productId', 'slug']);
391
393
  if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
392
- const nextStep = setupNextStep(resolved.product);
393
- return toolResult(nextStep ? { ...resolved.product, nextStep } : resolved.product);
394
+ const readiness = await setupReadiness(client, resolved.product);
395
+ return toolResult(readiness ? { ...resolved.product, readiness } : resolved.product);
394
396
  })
395
397
  );
396
398
 
@@ -97,7 +97,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
97
97
  '',
98
98
  'Before writing code: follow the factory instructions; read the policy sections that touch what you are',
99
99
  'about to change with taiga_get_document (security, data, SDLC, architecture at least); read the backlog',
100
- 'item you are implementing with taiga_get_backlog_item. Parts listed under notGranted or unavailable',
100
+ 'item you are implementing with taiga_get_initiative. Parts listed under notGranted or unavailable',
101
101
  'could not be read; say so rather than assuming they are empty.',
102
102
  '',
103
103
  fenced('json', bundle),
@@ -109,22 +109,22 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
109
109
 
110
110
  if (client.hasCapability('backlog:read') && client.hasCapability('products:read')) {
111
111
  server.registerPrompt(
112
- 'taiga_implement_backlog_item',
112
+ 'taiga_implement_initiative',
113
113
  {
114
- title: 'Implement a backlog item',
114
+ title: 'Implement an initiative',
115
115
  description:
116
116
  'Implement one initiative: its description and acceptance criteria, the architecture of the product ' +
117
117
  "from its own timeline, the factory's instructions and the policy sections to check against. " +
118
- 'taiga_list_backlog_items gives the item ID.',
118
+ 'taiga_list_initiatives gives the item ID.',
119
119
  argsSchema: z.object({
120
- itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
120
+ initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
121
121
  }),
122
122
  },
123
123
  async (args) => {
124
124
  const item = (
125
125
  await served(
126
- client.getBacklogItem(args.itemId),
127
- 'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
126
+ client.getBacklogItem(args.initiativeId),
127
+ 'No initiative with that ID the key can see; taiga_list_initiatives shows what exists'
128
128
  )
129
129
  ).data;
130
130
  const productId = String(item.productId);
@@ -16,7 +16,7 @@ import { isNetworkFailure, runTool, toolResult } from './errors.js';
16
16
  import { productId } from './inputs.js';
17
17
  import { criteriaCounts } from './summarize.js';
18
18
 
19
- /** How many entries the read names; past this an entry keeps its backlogItemId only. */
19
+ /** How many entries the read names; past this an entry keeps its initiativeId only. */
20
20
  const NAMED_ENTRIES = 25;
21
21
 
22
22
  /** One queue entry as the read reports it: where it sits, and its initiative when that could be named. */
@@ -30,13 +30,13 @@ interface QueueRow {
30
30
  needsHumanCause?: BacklogItem['needsHumanCause'];
31
31
  criteria?: { met: number; total: number } | null;
32
32
  lifecycle?: Pick<BacklogItemLifecycle, 'turn' | 'stage' | 'cta'> | null;
33
- buildId?: string;
33
+ runId?: string;
34
34
  prUrl?: string | null;
35
35
  halt?: { cause: string | null; reason: string; nextStep: string };
36
36
  }
37
37
 
38
38
  const MOVE_ASIDE =
39
- 'or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.';
39
+ 'or move it down (taiga_queue_initiative with afterId) or off the line (taiga_move_initiative) to let the rest pass.';
40
40
 
41
41
  /**
42
42
  * What lifts a halt, in the tools that do it. Read from the halt registry in
@@ -49,11 +49,11 @@ function haltNextStep(cause: string | null): string {
49
49
  return 'Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).';
50
50
  }
51
51
  const spec = cause ? (HALT_CAUSES as Record<string, { clearedWhen: string } | undefined>)[cause] : undefined;
52
- if (!cause || !spec) return `Read the initiative with taiga_get_backlog_item to see why, ${MOVE_ASIDE}`;
52
+ if (!cause || !spec) return `Read the initiative with taiga_get_initiative to see why, ${MOVE_ASIDE}`;
53
53
  const known = cause as HaltCause;
54
- if (canResumeHalt(known)) return `taiga_resume_backlog_item lifts it, ${MOVE_ASIDE}`;
54
+ if (canResumeHalt(known)) return `taiga_resume_initiative lifts it, ${MOVE_ASIDE}`;
55
55
  if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
56
- return `Only taking it off the line clears it (taiga_move_backlog_item): ${spec.clearedWhen}.`;
56
+ return `Only taking it off the line clears it (taiga_move_initiative): ${spec.clearedWhen}.`;
57
57
  }
58
58
 
59
59
  /**
@@ -143,7 +143,7 @@ export async function queueView(
143
143
  needsHumanCause: item.needsHumanCause,
144
144
  criteria: criteriaCounts(item),
145
145
  lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
146
- ...(latestBuild ? { buildId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
146
+ ...(latestBuild ? { runId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
147
147
  } satisfies Partial<QueueRow>);
148
148
  }
149
149
  if (entry.state === 'halted') {
@@ -163,10 +163,10 @@ export async function queueView(
163
163
  unnamed === 0
164
164
  ? null
165
165
  : !canName
166
- ? 'Entries carry backlogItemId only: naming them needs backlog:read, which this key lacks.'
166
+ ? 'Entries carry initiativeId only: naming them needs backlog:read, which this key lacks.'
167
167
  : entries.length > NAMED_ENTRIES
168
- ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry backlogItemId only.`
169
- : 'An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.';
168
+ ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry initiativeId only.`
169
+ : 'An entry whose initiative could not be read carries initiativeId only; taiga_get_initiative reads it.';
170
170
  // A paused line answers before anything in it: nothing is planned or built until it is resumed.
171
171
  const line =
172
172
  typeof paused === 'string'
@@ -198,7 +198,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
198
198
  if (!client.hasCapability('products:read')) return;
199
199
 
200
200
  server.registerTool(
201
- 'taiga_get_queue',
201
+ 'taiga_get_line',
202
202
  {
203
203
  title: 'Get the product queue',
204
204
  description:
@@ -226,7 +226,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
226
226
  'keep their places. Resuming plans the interrupted initiative again from scratch, carries the interrupted ' +
227
227
  'build on from its first incomplete step, and the line picks up where it was; a build you had stopped ' +
228
228
  'yourself before pausing stays stopped. Idempotent. Pausing interrupts work and resuming starts it: read ' +
229
- 'taiga_get_queue first to see what it would interrupt, and ask the person.',
229
+ 'taiga_get_line first to see what it would interrupt, and ask the person.',
230
230
  inputSchema: z.object({
231
231
  productId,
232
232
  paused: z.boolean().describe('true pauses the line, false resumes it'),
@@ -0,0 +1,172 @@
1
+ import { TaigaApiError, type TaigaClient } from '../client.js';
2
+ import { PRODUCT_DOCUMENT_NAMES, isImportedProduct, setupNextStep, type ProductSettingsView } from './catalogue.js';
3
+ import { isNetworkFailure } from './errors.js';
4
+
5
+ type Row = Record<string, unknown>;
6
+
7
+ /**
8
+ * What a product in setup still needs, read rather than learned from a
9
+ * refused write (PRO-2800). A reviewing agent linked a repository and picked
10
+ * a branch, and every read still said "link its repository": nextStep read
11
+ * the Discovery step alone, and the only way to see the missing documents was
12
+ * to call taiga_finish_discovery and read its refusal. This reads the same
13
+ * preconditions the API refuses on (GET /products/:id/setup), the linked
14
+ * repositories and the import, and says the one next move.
15
+ */
16
+ export interface Readiness {
17
+ /** Whether taiga_finish_discovery would succeed now. Null when the API could not say. */
18
+ ready: boolean | null;
19
+ /** Null when the links could not be read: not the same as none linked. */
20
+ repository: { linked: false } | { linked: true; name: unknown; branch: unknown } | null;
21
+ /** An imported product's import: not started, running, complete, failed. */
22
+ import?: string;
23
+ discoveryStep?: unknown;
24
+ /** The required documents not yet published, by name; null when the API could not say. */
25
+ missingDocuments: string[] | null;
26
+ /** The required checks not yet stamped (stamped in the web); null when the API could not say. */
27
+ openChecks: string[] | null;
28
+ /** The one move that gets it closer; null when too little could be read to say. */
29
+ nextStep: string | null;
30
+ }
31
+
32
+ const IMPORT_STATE: Record<string, string> = {
33
+ pending: 'not started',
34
+ analyzing: 'running',
35
+ generating_docs: 'running',
36
+ complete: 'complete',
37
+ failed: 'failed',
38
+ cancelled: 'failed',
39
+ };
40
+
41
+ /** A failure that is the API's (refused, missing, unreachable), not a bug in this code. */
42
+ function isApiSide(error: unknown): boolean {
43
+ return error instanceof TaigaApiError || isNetworkFailure(error);
44
+ }
45
+
46
+ /** The documents by the names the web uses. */
47
+ function named(types: string[]): string {
48
+ return types.map((type) => PRODUCT_DOCUMENT_NAMES[type as keyof typeof PRODUCT_DOCUMENT_NAMES] ?? type).join(', ');
49
+ }
50
+
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> {
53
+ if (product['status'] !== 'draft') return null;
54
+ const id = String(product['id']);
55
+ const imported = isImportedProduct(product as ProductSettingsView);
56
+ const [repos, blockers, full] = await Promise.all([
57
+ client.listProductRepositories(id).then(
58
+ (result) => result.data as Row[],
59
+ (error: unknown) => {
60
+ if (isApiSide(error)) return null;
61
+ throw error;
62
+ }
63
+ ),
64
+ // An API from before the read answers 404 to it: fall back to the step.
65
+ client.getProductSetupBlockers(id).then(
66
+ (result) => result.data,
67
+ (error: unknown) => {
68
+ if (isApiSide(error)) return null;
69
+ throw error;
70
+ }
71
+ ),
72
+ // A write's answer carries no discoveryStep; the read derives it.
73
+ 'discoveryStep' in product || imported
74
+ ? Promise.resolve(product)
75
+ : client.getProduct(id).then(
76
+ (result) => result.data as Row,
77
+ (error: unknown) => {
78
+ if (isApiSide(error)) return product;
79
+ throw error;
80
+ }
81
+ ),
82
+ ]);
83
+
84
+ const link = repos?.[0];
85
+ const repository = !repos
86
+ ? null
87
+ : link
88
+ ? { linked: true as const, name: link['repositoryName'], branch: link['effectiveBranch'] }
89
+ : { linked: false as const };
90
+ const settings = (product['settings'] ?? {}) as { importStatus?: unknown };
91
+ const importState = imported ? (IMPORT_STATE[String(settings.importStatus)] ?? 'not started') : undefined;
92
+ const missing = blockers ? blockers.missingDocuments : null;
93
+ const openChecks = blockers ? blockers.openChecks.map((check) => check.criterion) : null;
94
+ // What the API refuses on, nothing else: a running import is in nextStep, not a blocker.
95
+ const ready = blockers ? missing!.length === 0 && openChecks!.length === 0 : null;
96
+
97
+ return {
98
+ ready,
99
+ repository,
100
+ ...(importState ? { import: importState } : {}),
101
+ ...(imported ? {} : { discoveryStep: full['discoveryStep'] ?? null }),
102
+ missingDocuments: missing ? missing.map((type) => named([type])) : null,
103
+ openChecks,
104
+ nextStep: nextStepFor({
105
+ imported,
106
+ importState,
107
+ linked: repository?.linked ?? null,
108
+ missing,
109
+ openChecks,
110
+ product: full,
111
+ }),
112
+ };
113
+ }
114
+
115
+ function nextStepFor(state: {
116
+ imported: boolean;
117
+ importState: string | undefined;
118
+ /** Null when the links could not be read. */
119
+ linked: boolean | null;
120
+ missing: string[] | null;
121
+ openChecks: string[] | null;
122
+ product: Row;
123
+ }): string | null {
124
+ const finish =
125
+ 'taiga_finish_discovery opens the product and locks its documents (ask the person: it is their decision ' +
126
+ 'that the documents are right).';
127
+ if (state.imported) {
128
+ if (state.linked === false) {
129
+ return 'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.';
130
+ }
131
+ if (state.linked === null && state.importState === 'not started')
132
+ return setupNextStep(state.product as ProductSettingsView);
133
+ switch (state.importState) {
134
+ case 'running':
135
+ return 'The import is running and derives the documents from the code; taiga_wait_for_job follows it.';
136
+ case 'failed':
137
+ return 'The last import did not finish: taiga_start_import runs it again.';
138
+ case 'complete':
139
+ if (state.missing && state.missing.length > 0) {
140
+ return (
141
+ `The import finished but ${named(state.missing)} ${state.missing.length === 1 ? 'is' : 'are'} not ` +
142
+ 'published: taiga_list_product_documents shows them, taiga_publish_product_document publishes a draft.'
143
+ );
144
+ }
145
+ return `The import is done: review the reality documents (taiga_list_product_documents), then ${finish}`;
146
+ default:
147
+ return 'taiga_start_import derives the documents from the code and runs by itself (paid agent work: ask first).';
148
+ }
149
+ }
150
+ // Without the API's read, the Discovery step is all there is to go on, and
151
+ // without that, nothing: a guess would send a product at its documents back
152
+ // to the conversation.
153
+ if (!state.missing) return setupNextStep(state.product as ProductSettingsView);
154
+ if (state.missing.includes('specification')) {
155
+ return (
156
+ 'The specification comes from the Discovery conversation, held in the web: the person describes the ' +
157
+ 'product there. The other documents follow from it.' +
158
+ (state.linked === false
159
+ ? ' Linking a repository (taiga_link_repository) can wait until before the first build.'
160
+ : '')
161
+ );
162
+ }
163
+ if (state.missing.length > 0) {
164
+ return (
165
+ `Still to publish: ${named(state.missing)}. taiga_generate_product_documents generates the missing ones ` +
166
+ '(paid agent work: ask first); a draft is published with taiga_publish_product_document.'
167
+ );
168
+ }
169
+ if (state.openChecks && state.openChecks.length > 0)
170
+ return `The documents are published; the checks are stamped in the web, then ${finish}`;
171
+ return `Every required document is published: ${finish}`;
172
+ }
@@ -46,6 +46,8 @@ const GITHUB_REPOSITORY_FIELDS = [
46
46
  'defaultBranch',
47
47
  'description',
48
48
  'language',
49
+ // A product already links it (PRO-2800); absent from an API that predates the field.
50
+ 'linked',
49
51
  ] as const;
50
52
 
51
53
  const repositoryId = idInput(
@@ -114,7 +116,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
114
116
  "The repositories the organization's GitHub connection can reach, to link one to a product " +
115
117
  '(taiga_link_repository takes its id). Searches by name with query. Name the GitHub account (account: ' +
116
118
  'its login, the owner in owner/repo, or installationId) to read only its repositories; without one it ' +
117
- 'reads every connected account and takes rows from each in turn, so one account cannot fill the page. No ' +
119
+ 'reads every connected account and takes rows from each in turn, so one account cannot fill the page. ' +
120
+ 'linked: true means a product already links it (taiga_resolve_product says which). No ' +
118
121
  'connection means GitHub is not connected yet: that is done once, in the web, by someone who can ' +
119
122
  'install the app.',
120
123
  inputSchema: z.object({
@@ -194,20 +194,20 @@ export function registerResources(server: McpServer, client: TaigaClient) {
194
194
  if (client.hasCapability('backlog:read')) {
195
195
  server.registerResource(
196
196
  'backlog-item',
197
- new ResourceTemplate('taiga://backlog/{itemId}', { list: undefined }),
197
+ new ResourceTemplate('taiga://initiatives/{initiativeId}', { list: undefined }),
198
198
  {
199
- title: 'Backlog item',
199
+ title: 'Initiative',
200
200
  description:
201
- 'An initiative in full: description, acceptance criteria, technical context. taiga_list_backlog_items ' +
201
+ 'An initiative in full: description, acceptance criteria, technical context. taiga_list_initiatives ' +
202
202
  'gives the item ID.',
203
203
  mimeType: 'application/json',
204
204
  },
205
205
  async (uri, variables) => {
206
- const itemId = requireId('Backlog item ID', String(variables['itemId']));
206
+ const initiativeId = requireId('Initiative ID', String(variables['initiativeId']));
207
207
  const item = (
208
208
  await served(
209
- client.getBacklogItem(itemId),
210
- 'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
209
+ client.getBacklogItem(initiativeId),
210
+ 'No initiative with that ID the key can see; taiga_list_initiatives shows what exists'
211
211
  )
212
212
  ).data;
213
213
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(item, null, 2) }] };
@@ -168,7 +168,7 @@ export const sectionsInput = z
168
168
  .max(50)
169
169
  .optional()
170
170
  .describe(
171
- 'Only these sections, by id; [] for the outline (every section id and its size, no text); omit for the whole document'
171
+ 'Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
172
172
  );
173
173
 
174
174
  /**
@@ -184,6 +184,18 @@ export function sectionReadResult(doc: StoredDocument, ids: readonly string[], l
184
184
  if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
185
185
  const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
186
186
  const unknownSections = ids.filter((id) => !all.includes(id));
187
+ const known = ids.filter((id) => all.includes(id));
188
+ if (known.length === 0) {
189
+ // The outline, or a read naming only ids the document lacks: no text to
190
+ // carry, so no empty data.sections beside the ids, which read as a
191
+ // document with no sections (PRO-2800).
192
+ const { data: _data, ...meta } = cut.doc;
193
+ return toolResult({
194
+ ...meta,
195
+ outline: cut.omittedSections,
196
+ ...(unknownSections.length > 0 ? { unknownSections } : {}),
197
+ });
198
+ }
187
199
  return toolResult({
188
200
  ...cut.doc,
189
201
  omittedSections: cut.omittedSections,
@@ -4,8 +4,9 @@ import { z } from 'zod';
4
4
  import type { Product } from '@taiga/types';
5
5
  import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
6
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
7
- import { isImportedProduct, setupNextStep } from './catalogue.js';
8
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
7
+ import { isImportedProduct } from './catalogue.js';
8
+ import { runTool, toolError, toolResult } from './errors.js';
9
+ import { setupReadiness } from './readiness.js';
9
10
  import { withStop } from './hints.js';
10
11
  import { idInput, productId } from './inputs.js';
11
12
  import { pickFields } from './summarize.js';
@@ -64,36 +65,17 @@ const MISSING_DOCUMENTS_HOW =
64
65
 
65
66
  type ProductRow = WithoutTenant<Product>;
66
67
 
67
- /** A product row the model can act on, saying how it started as the other product tools say it. */
68
+ /** A product row the model can act on: how it started, and while in setup what it still needs. */
68
69
  async function productRow(client: TaigaClient, product: ProductRow) {
69
70
  const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
70
- const nextStep = setupNextStep(await withDiscoveryStep(client, product));
71
+ const readiness = await setupReadiness(client, product);
71
72
  return {
72
73
  ...rest,
73
74
  projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
74
- ...(nextStep ? { nextStep } : {}),
75
+ ...(readiness ? { readiness } : {}),
75
76
  };
76
77
  }
77
78
 
78
- /**
79
- * The product with its discoveryStep. The API derives it on reads only, not
80
- * on the answer to a write (products.ts), and a draft started from scratch
81
- * needs it to say what comes next; an imported one reads its import status
82
- * instead. Best effort: a failed read leaves the product without a nextStep
83
- * rather than failing the write that already happened.
84
- */
85
- async function withDiscoveryStep(client: TaigaClient, product: ProductRow): Promise<ProductRow> {
86
- if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product)) {
87
- return product;
88
- }
89
- try {
90
- return (await client.getProduct(String(product.id))).data;
91
- } catch (error) {
92
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
93
- throw error;
94
- }
95
- }
96
-
97
79
  /**
98
80
  * A half turned off with nothing said about how it is done today. Allowed (the
99
81
  * web asks for the sentence but does not insist), so a warning rather than a
@@ -191,8 +173,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
191
173
  'Move a product to another factory. Access follows the factory, so this changes who can reach the ' +
192
174
  "product (taiga_preview_product_move says who); the product's own knowledge files are re-filed onto " +
193
175
  'the new factory, and factory knowledge and instructions stay with the factory that owns them. Refused ' +
194
- '(CONFLICT) while a plan or build is in flight: stopLine: true stops the line first and then moves, which ' +
195
- 'interrupts that work.',
176
+ '(WORK_IN_FLIGHT) while a plan or build is in flight. Then ask the person: stopLine: true stops the ' +
177
+ 'line first and then moves, which interrupts that work.',
196
178
  inputSchema: z.object({
197
179
  productId,
198
180
  factoryId: factoryId.describe('The factory to move it to'),
@@ -205,10 +187,28 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
205
187
  },
206
188
  (params) =>
207
189
  runTool(async () => {
208
- const moved = await client.moveProduct(params.productId, {
209
- factoryId: params.factoryId,
210
- ...(params.stopLine ? { stopLine: true } : {}),
211
- });
190
+ let moved;
191
+ try {
192
+ moved = await client.moveProduct(params.productId, {
193
+ factoryId: params.factoryId,
194
+ ...(params.stopLine ? { stopLine: true } : {}),
195
+ });
196
+ } catch (error) {
197
+ // The API's own words say "Stop the line, then move the product",
198
+ // which reads as an instruction to the agent; whether to interrupt
199
+ // the work is the person's call (PRO-2800).
200
+ if (error instanceof TaigaApiError && error.code === 'WORK_IN_FLIGHT') {
201
+ return toolError(
202
+ 'WORK_IN_FLIGHT',
203
+ // The API's first sentence says what is in flight; its second ("Stop the line, then move the product")
204
+ // is the instruction this replaces.
205
+ `${withStop((error.apiMessage ?? 'Work is in flight on this product').split(/(?<=[.!?])\s/)[0]!)} Moving it now means stopping ` +
206
+ 'the line, which interrupts that work: ask the person, and pass stopLine: true only if they say so.',
207
+ error.details
208
+ );
209
+ }
210
+ throw error;
211
+ }
212
212
  return toolResult({
213
213
  product: await productRow(client, moved.data),
214
214
  changed: moved.changed,