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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.19.0-dev.451",
3
+ "version": "0.20.0-dev.454",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,8 +266,12 @@ async function connectServer(taigaClient: TaigaClient, wait?: WaitOptions) {
266
266
  }
267
267
 
268
268
  /** Extract sorted tool names from a listTools response. */
269
+ /** The tools a key was given, beside taiga_whoami, which comes with any of them (PRO-2800). */
269
270
  function toolNames(tools: { name: string }[]) {
270
- return tools.map((t) => t.name).sort();
271
+ return tools
272
+ .map((t) => t.name)
273
+ .filter((name) => name !== 'taiga_whoami')
274
+ .sort();
271
275
  }
272
276
 
273
277
  /** Extract parsed JSON from the first text content block of a tool call result. */
@@ -2030,9 +2034,9 @@ describe('MCP server integration', () => {
2030
2034
  expect(bundle.policies).toEqual([
2031
2035
  { type: 'policy_security', name: 'Security Policy', version: 3, sections: ['summary', 'scope'] },
2032
2036
  ]);
2033
- expect(bundle.backlog.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
2034
- expect(bundle.backlog.items[0]).not.toHaveProperty('description');
2035
- expect(bundle.backlog.truncated).toBe(false);
2037
+ expect(bundle.initiatives.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
2038
+ expect(bundle.initiatives.items[0]).not.toHaveProperty('description');
2039
+ expect(bundle.initiatives.truncated).toBe(false);
2036
2040
  expect(bundle.repositories).toEqual({
2037
2041
  items: [
2038
2042
  {
@@ -2046,7 +2050,7 @@ describe('MCP server integration', () => {
2046
2050
  ],
2047
2051
  truncated: false,
2048
2052
  });
2049
- expect(bundle.builds).toEqual([
2053
+ expect(bundle.runs).toEqual([
2050
2054
  {
2051
2055
  id: BUILD_ID,
2052
2056
  runNumber: 1,
@@ -2057,13 +2061,17 @@ describe('MCP server integration', () => {
2057
2061
  },
2058
2062
  ]);
2059
2063
  // PRO-2800: "how does it stand" in the same call, as counts.
2060
- expect(bundle.attention).toEqual({ open: 3, byKind: { review_pr: 2, setup_secret: 1 }, snoozed: 1 });
2064
+ expect(bundle.actionRequired).toEqual({ open: 3, byKind: { review_pr: 2, setup_secret: 1 }, snoozed: 1 });
2061
2065
  expect(bundle.maintenance).toEqual({
2062
2066
  openFindings: 1,
2063
2067
  bySeverity: { critical: 0, high: 1 },
2064
2068
  lastSweep: { status: 'completed', completedAt: '2026-10-01T00:00:00.000Z' },
2065
2069
  });
2066
- expect(bundle.standing).toEqual({ openViolations: 3, bySeverity: { critical: 1, high: 2 }, controlsViolated: 2 });
2070
+ expect(bundle.policyStanding).toEqual({
2071
+ openViolations: 3,
2072
+ bySeverity: { critical: 1, high: 2 },
2073
+ controlsViolated: 2,
2074
+ });
2067
2075
  expect(bundle.notGranted).toEqual([]);
2068
2076
  expect(bundle.unavailable).toEqual({});
2069
2077
  });
@@ -2078,7 +2086,7 @@ describe('MCP server integration', () => {
2078
2086
  expect(bundle.notGranted).toEqual([
2079
2087
  'factory (needs factories:read)',
2080
2088
  'policies (needs tenant:documents:read)',
2081
- 'backlog (needs backlog:read)',
2089
+ 'initiatives (needs backlog:read)',
2082
2090
  ]);
2083
2091
  expect(bundle).not.toHaveProperty('factory');
2084
2092
  expect(bundle).not.toHaveProperty('policies');
@@ -2098,9 +2106,9 @@ describe('MCP server integration', () => {
2098
2106
  await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2099
2107
  );
2100
2108
 
2101
- expect(bundle.unavailable).toEqual({ backlog: '429 RATE_LIMITED: Slow down' });
2102
- expect(bundle).not.toHaveProperty('backlog');
2103
- expect(bundle.builds).toHaveLength(1);
2109
+ expect(bundle.unavailable).toEqual({ initiatives: '429 RATE_LIMITED: Slow down' });
2110
+ expect(bundle).not.toHaveProperty('initiatives');
2111
+ expect(bundle.runs).toHaveLength(1);
2104
2112
  });
2105
2113
 
2106
2114
  it('should read every backlog page, since the open items are the newest and the list is oldest first', async () => {
@@ -2124,7 +2132,7 @@ describe('MCP server integration', () => {
2124
2132
  await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2125
2133
  );
2126
2134
 
2127
- expect(bundle.backlog.items.map((row: { title: string }) => row.title)).toEqual(['Add login', 'Newest']);
2135
+ expect(bundle.initiatives.items.map((row: { title: string }) => row.title)).toEqual(['Add login', 'Newest']);
2128
2136
  });
2129
2137
 
2130
2138
  it('should list an item once when it moved between the two status reads', async () => {
@@ -2141,8 +2149,8 @@ describe('MCP server integration', () => {
2141
2149
  await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2142
2150
  );
2143
2151
 
2144
- expect(bundle.backlog.items).toHaveLength(1);
2145
- expect(bundle.backlog.truncated).toBe(false);
2152
+ expect(bundle.initiatives.items).toHaveLength(1);
2153
+ expect(bundle.initiatives.truncated).toBe(false);
2146
2154
  const statuses = fetchSpy.mock.calls
2147
2155
  .map((c: unknown[]) => new URL(c[0] as string))
2148
2156
  .filter((u: URL) => u.pathname.endsWith('/backlog'))
@@ -2170,10 +2178,10 @@ describe('MCP server integration', () => {
2170
2178
  );
2171
2179
 
2172
2180
  // Ten pages per open status, two statuses.
2173
- expect(bundle.backlog.items).toHaveLength(20);
2174
- expect(bundle.backlog.truncated).toBe(true);
2181
+ expect(bundle.initiatives.items).toHaveLength(20);
2182
+ expect(bundle.initiatives.truncated).toBe(true);
2175
2183
  // The list pages oldest first: past the cap, the newest were never read, and the bundle says so.
2176
- expect(bundle.backlog.note).toContain('only the oldest of them were read');
2184
+ expect(bundle.initiatives.note).toContain('only the oldest of them were read');
2177
2185
  });
2178
2186
 
2179
2187
  it('should carry the newest fifty open items across both statuses and say the backlog is truncated', async () => {
@@ -2200,10 +2208,10 @@ describe('MCP server integration', () => {
2200
2208
  await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2201
2209
  );
2202
2210
 
2203
- expect(bundle.backlog.items).toHaveLength(50);
2204
- expect(bundle.backlog.items[0].title).toBe('Item 11');
2205
- expect(bundle.backlog.items.at(-1).title).toBe('Newest');
2206
- expect(bundle.backlog.truncated).toBe(true);
2211
+ expect(bundle.initiatives.items).toHaveLength(50);
2212
+ expect(bundle.initiatives.items[0].title).toBe('Item 11');
2213
+ expect(bundle.initiatives.items.at(-1).title).toBe('Newest');
2214
+ expect(bundle.initiatives.truncated).toBe(true);
2207
2215
  });
2208
2216
 
2209
2217
  it('should carry the two documents as their outline when asked for it', async () => {
@@ -3445,7 +3453,7 @@ describe('MCP server integration', () => {
3445
3453
  const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/resume'))!;
3446
3454
  expect((call[1] as RequestInit).method).toBe('POST');
3447
3455
  expect((call[1] as RequestInit).body).toBeNull();
3448
- expect(outcomeOf(result).lifecycle).toEqual(resumed.lifecycle);
3456
+ expect(outcomeOf(result).lifecycle).toEqual(asSeen(resumed.lifecycle));
3449
3457
  });
3450
3458
 
3451
3459
  it('should read the active plan with its steps and say when there is none', async () => {
@@ -4336,7 +4344,7 @@ describe('MCP server integration', () => {
4336
4344
  })
4337
4345
  );
4338
4346
 
4339
- expect(result).toMatchObject({ reached: true, item: { latestBuild: { prState: 'closed' } } });
4347
+ expect(result).toMatchObject({ reached: true, item: { latestRun: { prState: 'closed' } } });
4340
4348
  });
4341
4349
 
4342
4350
  it('should end the read in flight when the call is cancelled', async () => {
@@ -4473,7 +4481,7 @@ describe('MCP server integration', () => {
4473
4481
  );
4474
4482
 
4475
4483
  // Not stopped at the landed plan: the open gate builds it without anyone.
4476
- expect(result).toMatchObject({ reached: true, item: { latestBuild: { prState: 'open' } } });
4484
+ expect(result).toMatchObject({ reached: true, item: { latestRun: { prState: 'open' } } });
4477
4485
  });
4478
4486
 
4479
4487
  it("should take a landed plan for a person's turn when the product's closed gate holds it, reading the product once", async () => {
@@ -6430,7 +6438,7 @@ describe('what the agent review found (PRO-2759)', () => {
6430
6438
  expect(parseToolResult(item)).toEqual({
6431
6439
  error: 'NOT_FOUND',
6432
6440
  message:
6433
- "Backlog item not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
6441
+ "Initiative not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
6434
6442
  'plan ID is not one.',
6435
6443
  });
6436
6444
 
@@ -6488,7 +6496,7 @@ describe('what the agent review found (PRO-2759)', () => {
6488
6496
 
6489
6497
  expect(noPlan.message).toContain('taiga_plan_again asks for a new one');
6490
6498
  expect(noItem.message).toBe(
6491
- "Backlog item not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
6499
+ "Initiative not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
6492
6500
  'plan ID is not one.'
6493
6501
  );
6494
6502
  });
@@ -6616,8 +6624,8 @@ describe('what the agent review found (PRO-2759)', () => {
6616
6624
  expect(product.readiness).toEqual({
6617
6625
  ready: false,
6618
6626
  repository: { linked: false },
6619
- import: 'not started',
6620
- missingDocuments: ['Specification', 'Architecture'],
6627
+ import: 'pending',
6628
+ missingDocuments: ['specification', 'architecture'],
6621
6629
  openChecks: [],
6622
6630
  nextStep:
6623
6631
  'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.',
@@ -6813,10 +6821,36 @@ describe('what the agent review found (PRO-2759)', () => {
6813
6821
  expect(Object.keys(standing)[0]).toBe('summary');
6814
6822
  expect(standing.summary).toEqual({
6815
6823
  controls: 1,
6824
+ controlsInPolicies: 2,
6825
+ controlsWithNothingRecorded: 1,
6816
6826
  violations: 2,
6817
6827
  bySeverity: { high: 1, critical: 1 },
6818
6828
  resolvedViolations: 1,
6819
6829
  });
6830
+ expect(standing).not.toHaveProperty('note');
6831
+ });
6832
+
6833
+ it('says a never-assessed standing is no all-clear (PRO-2800)', async () => {
6834
+ const { client } = await bootMcpPair({
6835
+ capabilities: ['products:read'],
6836
+ routes: {
6837
+ [`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
6838
+ data: {
6839
+ productId: PRODUCT_ID,
6840
+ hasPolicies: true,
6841
+ assessment: { state: 'never' },
6842
+ controls: [],
6843
+ divergences: [],
6844
+ },
6845
+ },
6846
+ },
6847
+ });
6848
+
6849
+ const standing = parseToolResult(
6850
+ await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
6851
+ );
6852
+
6853
+ expect(standing.note).toMatch(/^Never assessed: .* nothing here is an all-clear/);
6820
6854
  });
6821
6855
  });
6822
6856
 
@@ -7039,12 +7073,11 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
7039
7073
  for (const tool of tools) {
7040
7074
  const notes = tool.annotations ?? {};
7041
7075
  const description = tool.description ?? '';
7042
- if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Acts on paid)/);
7043
- else if (notes.destructiveHint)
7044
- expect(description, tool.name).toMatch(/^Acts on paid or shared work: ask the person first\. /);
7076
+ if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Ask first:)/);
7077
+ else if (notes.destructiveHint) expect(description, tool.name).toMatch(/^Ask first: paid or shared work\. /);
7045
7078
  else expect(description, tool.name).toMatch(/^Writes\. /);
7046
7079
  }
7047
- expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^Acts on paid/);
7080
+ expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^Ask first:/);
7048
7081
  expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
7049
7082
  });
7050
7083
 
@@ -7089,6 +7122,219 @@ describe('what the third agent review found (PRO-2800)', () => {
7089
7122
  vi.restoreAllMocks();
7090
7123
  });
7091
7124
 
7125
+ it('says who the key acts as and which of its tools write', async () => {
7126
+ const { client } = await bootMcpPair({
7127
+ capabilities: ['products:read', 'backlog:read', 'backlog:write', 'products:write'],
7128
+ });
7129
+
7130
+ const me = parseToolResult(await client.callTool({ name: 'taiga_whoami', arguments: {} }));
7131
+
7132
+ expect(me.role).toBeTypeOf('string');
7133
+ expect(me.capabilities).toEqual(expect.arrayContaining(['products:read', 'backlog:write']));
7134
+ expect(me.tools.reads).toContain('taiga_get_product_context');
7135
+ expect(me.tools.askFirst).toContain('taiga_queue_initiative');
7136
+ expect(me.tools.reads).not.toContain('taiga_whoami');
7137
+ });
7138
+
7139
+ it('marks a pull request review that no longer stands, and counts those in the summary', async () => {
7140
+ const RUN_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e02';
7141
+ const ITEM_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e03';
7142
+ const review = (id: string, subjectId: string) => ({
7143
+ id,
7144
+ productId: PRODUCT_ID,
7145
+ productName: 'Web',
7146
+ kind: 'review_pr',
7147
+ subjectType: 'build_run',
7148
+ subjectId,
7149
+ status: 'open',
7150
+ createdAt: new Date().toISOString(),
7151
+ });
7152
+ const { client } = await bootMcpPair({
7153
+ capabilities: ['products:read', 'backlog:read'],
7154
+ routes: {
7155
+ 'GET /v1/inbox': { data: [review('i1', BUILD_ID), review('i2', RUN_2)], nextCursor: null },
7156
+ [`GET /v1/builds/${BUILD_ID}`]: {
7157
+ data: { id: BUILD_ID, prState: 'open', prMerged: false, backlogItemId: ITEM_ID },
7158
+ },
7159
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: { id: ITEM_ID, state: 'build', status: 'in_progress' } },
7160
+ [`GET /v1/builds/${RUN_2}`]: { data: { id: RUN_2, prState: 'open', prMerged: false, backlogItemId: ITEM_2 } },
7161
+ [`GET /v1/backlog/${ITEM_2}`]: { data: { id: ITEM_2, state: 'canceled', status: 'canceled' } },
7162
+ },
7163
+ });
7164
+
7165
+ const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
7166
+
7167
+ expect(list.data[0]).not.toHaveProperty('stale');
7168
+ expect(list.data[1].stale).toBe('its initiative was canceled; the pull request is still open on the forge');
7169
+ expect(list.summary).toEqual([
7170
+ { productId: PRODUCT_ID, product: 'Web', open: 2, byKind: { review_pr: 2 }, stale: 1 },
7171
+ ]);
7172
+ });
7173
+
7174
+ it("names a handback's own next step, counts rounds in words, and keeps a multi-line error to its first line", async () => {
7175
+ const base = {
7176
+ productId: PRODUCT_ID,
7177
+ completedSteps: 2,
7178
+ totalSteps: 3,
7179
+ prNumber: 5,
7180
+ prUrl: 'https://x/5',
7181
+ prDraft: false,
7182
+ };
7183
+ const { client } = await bootMcpPair({
7184
+ capabilities: ['products:read'],
7185
+ routes: {
7186
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7187
+ data: [
7188
+ { ...base, id: 'a', status: 'needs_review', reviewEscalation: { reason: 'ci_never_settled' } },
7189
+ { ...base, id: 'b', status: 'needs_review', reviewEscalation: { reason: 'ci_fix_cap', rounds: 1 } },
7190
+ {
7191
+ ...base,
7192
+ id: 'c',
7193
+ status: 'failed',
7194
+ prUrl: null,
7195
+ prNumber: null,
7196
+ error: 'TypeError: boom\n at x (y.js:1)',
7197
+ },
7198
+ ],
7199
+ nextCursor: null,
7200
+ },
7201
+ },
7202
+ });
7203
+
7204
+ const runs = parseToolResult(
7205
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7206
+ ).data as Record<string, unknown>[];
7207
+
7208
+ expect(runs[0]!['outcome']).toMatch(/look at what is still running or queued in CI/);
7209
+ expect(runs[1]!['outcome']).toMatch(/CI is still failing after 1 automatic round\./);
7210
+ expect(runs[2]!['outcome']).toBe('Failed: TypeError: boom (2 of 3 steps done).');
7211
+ // A row says the handback in its outcome; the full read keeps the record.
7212
+ expect(runs[0]).not.toHaveProperty('reviewEscalation');
7213
+ });
7214
+
7215
+ it('counts a review it could not check as unchecked, not live', async () => {
7216
+ const { client } = await bootMcpPair({
7217
+ // products:read alone: the run is readable, its initiative is not.
7218
+ capabilities: ['products:read'],
7219
+ routes: {
7220
+ 'GET /v1/inbox': {
7221
+ data: [
7222
+ {
7223
+ id: 'i1',
7224
+ productId: PRODUCT_ID,
7225
+ kind: 'review_pr',
7226
+ subjectType: 'build_run',
7227
+ subjectId: BUILD_ID,
7228
+ status: 'open',
7229
+ createdAt: new Date().toISOString(),
7230
+ },
7231
+ ],
7232
+ nextCursor: null,
7233
+ },
7234
+ [`GET /v1/builds/${BUILD_ID}`]: { data: { id: BUILD_ID, prState: 'open', backlogItemId: ITEM_ID } },
7235
+ },
7236
+ });
7237
+
7238
+ const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
7239
+
7240
+ expect(list.data[0]).not.toHaveProperty('stale');
7241
+ expect(list.staleNote).toMatch(/^1 pull request review was not checked for staleness/);
7242
+ });
7243
+
7244
+ it('names factories past the first page of them', async () => {
7245
+ const FACTORY_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e10';
7246
+ const { client } = await bootMcpPair({
7247
+ capabilities: ['products:read', 'factories:read'],
7248
+ routes: {
7249
+ 'GET /v1/products': {
7250
+ data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_2 }],
7251
+ nextCursor: null,
7252
+ },
7253
+ 'GET /v1/factories': (url: string) => {
7254
+ const second = new URL(url).searchParams.get('cursor') === 'p2';
7255
+ const body = second
7256
+ ? { data: [{ id: FACTORY_2, name: 'Second' }], nextCursor: null }
7257
+ : { data: [{ id: FACTORY_ID, name: 'First' }], nextCursor: 'p2' };
7258
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
7259
+ },
7260
+ },
7261
+ });
7262
+
7263
+ const products = parseToolResult(await client.callTool({ name: 'taiga_list_products', arguments: {} }));
7264
+
7265
+ expect(products.data[0].factoryName).toBe('Second');
7266
+ });
7267
+
7268
+ it('says why the agent handed a run back, where the initiative and the line say whose turn it is', async () => {
7269
+ const item = {
7270
+ id: ITEM_ID,
7271
+ title: 'HTTPS',
7272
+ lifecycle: {
7273
+ turn: 'you',
7274
+ stage: 'in_review',
7275
+ cta: 'review_pr',
7276
+ planFailureCause: null,
7277
+ latestBuild: {
7278
+ id: BUILD_ID,
7279
+ status: 'needs_review',
7280
+ prNumber: 29,
7281
+ reviewEscalation: { reason: 'ci_fix_stalled', failingChecks: [{ name: 'secret-scan', url: null }] },
7282
+ },
7283
+ },
7284
+ };
7285
+ const { client } = await bootMcpPair({
7286
+ capabilities: ['backlog:read'],
7287
+ routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
7288
+ });
7289
+
7290
+ const got = parseToolResult(
7291
+ await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
7292
+ );
7293
+
7294
+ expect(got.situation).toContain(
7295
+ 'handed back because CI is still failing on secret-scan, and the last fix round changed nothing'
7296
+ );
7297
+ expect(got.lifecycle.latestRun.id).toBe(BUILD_ID);
7298
+ });
7299
+
7300
+ it('reads the sections that state a policy control id', async () => {
7301
+ const { client } = await bootMcpPair({
7302
+ capabilities: ALL_CAPABILITIES,
7303
+ routes: {
7304
+ 'GET /v1/tenants/current/documents/policy_security': {
7305
+ data: {
7306
+ id: 'doc-1',
7307
+ type: 'policy_security',
7308
+ version: 1,
7309
+ isDraft: false,
7310
+ data: {
7311
+ version: '1.0.0',
7312
+ sections: {
7313
+ scope: { content: 'All systems.' },
7314
+ secretsManagement: { content: '| SEC-5.1 | Secrets in a vault |\n| SEC-5.10 | Rotate |' },
7315
+ logging: { content: '| SEC-5.10 | Log access |' },
7316
+ appendix: { content: 'See SEC-5.1A and SEC-5.1-appendix, and SEC-5.1.2.' },
7317
+ summary: { content: 'Secrets follow SEC-5.1.' },
7318
+ },
7319
+ },
7320
+ },
7321
+ },
7322
+ },
7323
+ });
7324
+
7325
+ const doc = parseToolResult(
7326
+ await client.callTool({
7327
+ name: 'taiga_get_document',
7328
+ arguments: { type: 'policy_security', sections: ['SEC-5.1', 'SEC-9.9'] },
7329
+ })
7330
+ );
7331
+
7332
+ // A whole id only, and a sentence's full stop after it is still the id.
7333
+ expect(Object.keys(doc.data.sections).sort()).toEqual(['secretsManagement', 'summary']);
7334
+ expect(doc.controls).toEqual({ 'SEC-5.1': ['secretsManagement', 'summary'] });
7335
+ expect(doc.unknownSections).toEqual(['SEC-9.9']);
7336
+ });
7337
+
7092
7338
  it('loads the three entry tools upfront in Claude Code, and only those', async () => {
7093
7339
  const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
7094
7340
 
@@ -7185,6 +7431,43 @@ describe('what the third agent review found (PRO-2800)', () => {
7185
7431
  expect(archive.data[0].waitingDays).toBe(2);
7186
7432
  });
7187
7433
 
7434
+ it("reads a draft product's links once for the context's repositories and its readiness", async () => {
7435
+ const draft = { id: PRODUCT_ID, name: 'Web', slug: 'web', status: 'draft', factoryId: FACTORY_ID, settings: {} };
7436
+ const { client, fetchSpy } = await bootMcpPair({
7437
+ capabilities: ['products:read'],
7438
+ routes: {
7439
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { ...draft, discoveryStep: 'documents' } },
7440
+ [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [], nextCursor: null },
7441
+ },
7442
+ });
7443
+
7444
+ const bundle = parseToolResult(
7445
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
7446
+ );
7447
+
7448
+ expect(bundle.product.readiness.repository).toEqual({ linked: false });
7449
+ expect(bundle.repositories.items).toEqual([]);
7450
+ const reads = fetchSpy.mock.calls.filter((call: unknown[]) =>
7451
+ new URL(call[0] as string).pathname.endsWith('/repositories')
7452
+ );
7453
+ expect(reads).toHaveLength(1);
7454
+ });
7455
+
7456
+ it("keeps a stopped run's draft state when it opened a pull request", async () => {
7457
+ const { client } = await bootMcpPair({
7458
+ capabilities: ['products:read', 'products:write'],
7459
+ routes: {
7460
+ [`POST /v1/builds/${BUILD_ID}/stop`]: {
7461
+ data: { id: BUILD_ID, status: 'stopped', prNumber: 4, prUrl: 'https://x/4', prDraft: true, actions: [] },
7462
+ },
7463
+ },
7464
+ });
7465
+
7466
+ const stopped = parseToolResult(await client.callTool({ name: 'taiga_stop_run', arguments: { runId: BUILD_ID } }));
7467
+
7468
+ expect(stopped.prDraft).toBe(true);
7469
+ });
7470
+
7188
7471
  it('lists a variable filed under two categories once, naming the other record', async () => {
7189
7472
  const task = (id: string, category: string, key: string | null) => ({
7190
7473
  id,
@@ -7211,6 +7494,8 @@ describe('what the third agent review found (PRO-2800)', () => {
7211
7494
 
7212
7495
  expect(tasks.data.map((row: { id: string }) => row.id)).toEqual(['t1', 't2']);
7213
7496
  expect(tasks.data[0].alsoFiledAs).toEqual([{ id: 't3', category: 'env_var', title: 'Set JWT_SECRET' }]);
7497
+ // Three records folded into two rows: the page says so beside the rows.
7498
+ expect(tasks.recordsOnPage).toBe(3);
7214
7499
  });
7215
7500
 
7216
7501
  it('leads a variable with its open record when an older one is done', async () => {
@@ -7286,6 +7571,24 @@ describe('what the third agent review found (PRO-2800)', () => {
7286
7571
  prUrl: 'https://x/8',
7287
7572
  baseBranch: 'main',
7288
7573
  },
7574
+ {
7575
+ ...base,
7576
+ id: 'r7',
7577
+ status: 'needs_review',
7578
+ prNumber: 29,
7579
+ prUrl: 'https://x/29',
7580
+ reviewEscalation: { reason: 'ci_fix_stalled', failingChecks: [{ name: 'secret-scan', url: null }] },
7581
+ },
7582
+ {
7583
+ ...base,
7584
+ id: 'r8',
7585
+ status: 'failed',
7586
+ failedReason: 'dispatch_rejected',
7587
+ error: 'The engineer environment is unavailable: the instance terminated while starting. Retry later.',
7588
+ completedSteps: 0,
7589
+ totalSteps: 3,
7590
+ prUrl: null,
7591
+ },
7289
7592
  ],
7290
7593
  nextCursor: null,
7291
7594
  },
@@ -7300,9 +7603,18 @@ describe('what the third agent review found (PRO-2800)', () => {
7300
7603
  ['rejected', 'PR #28 was closed without merging, so the work did not land.'],
7301
7604
  ['succeeded', 'Merged: PR #7 is in main.'],
7302
7605
  ['failed', 'Failed: agent_error (2 of 6 steps done).'],
7303
- ['gated', 'Waiting for a person to review and merge PR #9.'],
7606
+ ['gated', 'Waiting for a person to review and merge PR #9, still a draft.'],
7304
7607
  ['in_flight', 'Running: hardening: running the security scanners and fixing what they find (6 of 6 steps done).'],
7305
7608
  ['succeeded', 'Merged: PR #8 is in main.'],
7609
+ [
7610
+ 'gated',
7611
+ 'Handed back to a person: CI is still failing on secret-scan, and the last fix round changed nothing. ' +
7612
+ 'PR #29, still a draft, waits for them to fix it by hand or review it back to the agent.',
7613
+ ],
7614
+ [
7615
+ 'failed',
7616
+ 'Failed: The engineer environment is unavailable: the instance terminated while starting (0 of 3 steps done).',
7617
+ ],
7306
7618
  ]);
7307
7619
  expect(runs[0]).toHaveProperty('prDraft', false);
7308
7620
  expect(runs[2]).not.toHaveProperty('prDraft');
@@ -64,7 +64,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
64
64
  {
65
65
  title: "Set whether an initiative's pull request merges on its own",
66
66
  description:
67
- "An initiative's own switch for merging on its own: true lets its pull request merge itself once the " +
67
+ "Set whether one initiative's pull request merges on its own: true lets its pull request merge itself once the " +
68
68
  "repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false " +
69
69
  "makes it wait for a person; null follows the product's default. The final word within what the " +
70
70
  'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
@@ -20,7 +20,7 @@ import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
20
20
  import { jobReport } from './jobs.js';
21
21
  import { requestPrompt } from './request-prompt.js';
22
22
  import { queueView } from './queue.js';
23
- import { landedDisposition } from './builds.js';
23
+ import { handbackNextStep, handbackSaid, landedDisposition } from './builds.js';
24
24
  import {
25
25
  SEARCH_NOTE,
26
26
  SEARCH_PAGE_SIZE,
@@ -139,6 +139,9 @@ export function situationOf(item: Record<string, unknown>): string | null {
139
139
  ? RUN_STANDS[landedDisposition(build.status as BuildRunStatus, build.prMerged === true)]
140
140
  : build.status;
141
141
  stage += `; its latest run ${said}${pr}`;
142
+ // Handed back: what the agent could not get past, which "in review" alone hides.
143
+ const handback = handbackSaid((build as { reviewEscalation?: unknown }).reviewEscalation);
144
+ if (handback) stage += `, handed back because ${handback}`;
142
145
  }
143
146
  parts.push(`${stage}.`);
144
147
  if (typeof item['needsHumanCause'] === 'string') {
@@ -152,7 +155,13 @@ export function situationOf(item: Record<string, unknown>): string | null {
152
155
  if (why) parts.push(`The line stopped on it: ${why}.`);
153
156
  }
154
157
  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;
158
+ const handedBack = handbackSaid((build as { reviewEscalation?: unknown } | null | undefined)?.reviewEscalation);
159
+ const move =
160
+ life.cta === 'review_pr' && handedBack
161
+ ? `a person should ${handbackNextStep((build as { reviewEscalation?: unknown }).reviewEscalation)}`
162
+ : typeof life.cta === 'string'
163
+ ? CTA_MOVE[life.cta]
164
+ : undefined;
156
165
  if (life.cta === 'view_pr') parts.push(`Nothing to do: ${move}.`);
157
166
  else if (turn && move) parts.push(`${turn}: ${move}.`);
158
167
  else if (turn) parts.push(`${turn}.`);
@@ -295,7 +304,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
295
304
  {
296
305
  title: 'List initiatives',
297
306
  description:
298
- "List a product's initiatives, its backlog: titles, category, status, where each one is on the " +
307
+ "List a product's initiatives (its backlog): titles, category, status, where each one is on the " +
299
308
  'line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on, in a ' +
300
309
  'sentence) and criteria, how many of its acceptance criteria are met ' +
301
310
  '(null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked ' +
@@ -457,7 +466,7 @@ function registerMove(server: McpServer, client: TaigaClient) {
457
466
  {
458
467
  title: 'Move initiative on the board',
459
468
  description:
460
- 'Move an initiative on the board, or order it within a column: backlog and todo are yours and the line ' +
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 ' +
461
470
  'never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); ' +
462
471
  'done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this ' +
463
472
  'now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan ' +
@@ -678,7 +687,7 @@ function registerResume(server: McpServer, client: TaigaClient) {
678
687
  {
679
688
  title: 'Resume initiative',
680
689
  description:
681
- 'Resume an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
690
+ 'Carry on an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
682
691
  'failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished ' +
683
692
  'part (the steps already committed are kept), a refused build start is cleared and retried. This is the ' +
684
693
  'way to carry work on: a run is the record of an attempt, and decisions about the work are made on the ' +