@taiga-ai/mcp-server 0.12.0-dev.421 → 0.13.0-dev.422

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/src/client.ts CHANGED
@@ -297,6 +297,34 @@ export class TaigaClient {
297
297
  );
298
298
  }
299
299
 
300
+ /**
301
+ * Stop or start the product's line. Stopping halts pulls and builds and
302
+ * stops the plan and build in flight; starting picks the interrupted work
303
+ * back up.
304
+ */
305
+ async setLineStopped(productId: string, stopped: boolean) {
306
+ return this.request<{ data: unknown }>('PUT', `/products/${seg(productId)}/line`, { stopped });
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Agent jobs
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /** Ask an agent for work: the job is queued and runs in the background. */
314
+ async createAgentJob(body: {
315
+ scope: 'product' | 'tenant';
316
+ type: string;
317
+ targetId: string;
318
+ documentType?: string | undefined;
319
+ metadata?: Record<string, unknown> | undefined;
320
+ }) {
321
+ return this.request<{ data: unknown }>('POST', '/agent-jobs', body);
322
+ }
323
+
324
+ async getAgentJob(id: string, signal?: AbortSignal) {
325
+ return this.request<{ data: unknown }>('GET', `/agent-jobs/${seg(id)}`, undefined, { signal });
326
+ }
327
+
300
328
  async getProductDocument(productId: string, type: string, origin?: string) {
301
329
  const qs = origin ? `?${new URLSearchParams({ origin })}` : '';
302
330
  return this.request<{ data: unknown }>('GET', `/products/${seg(productId)}/documents/${seg(type)}${qs}`);
@@ -340,26 +368,15 @@ export class TaigaClient {
340
368
  return this.request<{ data: unknown }>('GET', `/backlog/${seg(itemId)}`, undefined, { signal });
341
369
  }
342
370
 
343
- async createBacklogItem(
344
- productId: string,
345
- body: {
346
- title: string;
347
- description: string;
348
- category?: string | undefined;
349
- priority?: number | undefined;
350
- estimate?: number | undefined;
351
- }
352
- ) {
353
- // The route's body schema names the product too, as the web's
354
- // CreateBacklogItemInput does (the live run found it; the handler then
355
- // takes the one from the path). Dropping it is an API contract change.
356
- return this.request<{ data: unknown }>('POST', `/products/${seg(productId)}/backlog`, { ...body, productId });
357
- }
358
-
359
371
  /** A person's move on the line board: the column dropped into, and what to follow there. */
360
372
  async transitionInitiative(
361
373
  itemId: string,
362
- body: { state: string; afterId?: string | null | undefined; archived?: boolean | undefined }
374
+ body: {
375
+ state: string;
376
+ afterId?: string | null | undefined;
377
+ archived?: boolean | undefined;
378
+ force?: boolean | undefined;
379
+ }
363
380
  ) {
364
381
  return this.request<{ data: unknown }>('PUT', `/backlog/${seg(itemId)}/state`, body);
365
382
  }
@@ -433,10 +450,6 @@ export class TaigaClient {
433
450
  return this.request<{ data: unknown }>('POST', `/builds/${seg(id)}/stop`);
434
451
  }
435
452
 
436
- async resumeBuildRun(id: string) {
437
- return this.request<{ data: unknown }>('POST', `/builds/${seg(id)}/resume`);
438
- }
439
-
440
453
  async listBuildRunSteps(id: string) {
441
454
  return this.request<{ data: unknown[] }>('GET', `/builds/${seg(id)}/steps`);
442
455
  }
@@ -27,11 +27,11 @@ const ALL_CAPABILITIES = [
27
27
  */
28
28
  const ALL_TOOL_NAMES = [
29
29
  'taiga_approve_plan',
30
- 'taiga_create_backlog_item',
31
30
  'taiga_get_backlog_item',
32
31
  'taiga_get_build',
33
32
  'taiga_get_document',
34
33
  'taiga_get_factory_document',
34
+ 'taiga_get_job',
35
35
  'taiga_get_plan',
36
36
  'taiga_get_product',
37
37
  'taiga_get_product_context',
@@ -45,19 +45,22 @@ const ALL_TOOL_NAMES = [
45
45
  'taiga_list_product_documents',
46
46
  'taiga_list_products',
47
47
  'taiga_move_backlog_item',
48
+ 'taiga_pause_line',
49
+ 'taiga_plan_again',
48
50
  'taiga_publish_document',
49
51
  'taiga_publish_factory_document',
50
52
  'taiga_queue_backlog_item',
51
53
  'taiga_reject_plan',
54
+ 'taiga_request_initiatives',
52
55
  'taiga_resolve_product',
53
56
  'taiga_resume_backlog_item',
54
- 'taiga_resume_build',
55
57
  'taiga_set_auto_build',
56
58
  'taiga_stop_build',
57
59
  'taiga_update_document',
58
60
  'taiga_update_factory_document',
59
61
  'taiga_wait_for_backlog_item',
60
62
  'taiga_wait_for_build',
63
+ 'taiga_wait_for_job',
61
64
  ];
62
65
 
63
66
  const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
@@ -365,6 +368,8 @@ describe('MCP server integration', () => {
365
368
  'taiga_get_build',
366
369
  'taiga_get_queue',
367
370
  'taiga_wait_for_build',
371
+ 'taiga_get_job',
372
+ 'taiga_wait_for_job',
368
373
  ],
369
374
  'backlog:read': [
370
375
  'taiga_list_backlog_items',
@@ -378,11 +383,12 @@ describe('MCP server integration', () => {
378
383
  const PAIRED_CONTRACT: Record<string, string[]> = {
379
384
  'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
380
385
  'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
381
- 'backlog:write': ['taiga_create_backlog_item', 'taiga_move_backlog_item'],
386
+ 'backlog:write': ['taiga_move_backlog_item'],
382
387
  'backlog:write products:write': ['taiga_queue_backlog_item'],
383
- 'products:write': ['taiga_resume_backlog_item', 'taiga_set_auto_build'],
388
+ 'products:write': ['taiga_resume_backlog_item', 'taiga_set_auto_build', 'taiga_request_initiatives'],
384
389
  'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
385
- 'products:read products:write': ['taiga_stop_build', 'taiga_resume_build'],
390
+ 'backlog:read backlog:write products:write': ['taiga_plan_again'],
391
+ 'products:read products:write': ['taiga_stop_build', 'taiga_pause_line'],
386
392
  };
387
393
 
388
394
  for (const [capability, expectedTools] of [...Object.entries(CONTRACT), ...Object.entries(PAIRED_CONTRACT)]) {
@@ -2745,7 +2751,7 @@ describe('MCP server integration', () => {
2745
2751
  };
2746
2752
  const outcomeOf = (result: Record<string, unknown>) => parseToolResult(result);
2747
2753
 
2748
- it('should mark every tool that starts or stops agent work destructive, and the create not', async () => {
2754
+ it('should mark every tool that starts or stops agent work destructive, and the reads not', async () => {
2749
2755
  const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2750
2756
 
2751
2757
  const { tools } = await client.listTools();
@@ -2758,59 +2764,177 @@ describe('MCP server integration', () => {
2758
2764
  'taiga_approve_plan',
2759
2765
  'taiga_reject_plan',
2760
2766
  'taiga_stop_build',
2761
- 'taiga_resume_build',
2762
- 'taiga_set_auto_build',
2767
+ 'taiga_request_initiatives',
2768
+ 'taiga_plan_again',
2763
2769
  ]) {
2764
2770
  expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
2765
2771
  }
2766
- expect(byName.taiga_create_backlog_item).toMatchObject({ readOnlyHint: false, destructiveHint: false });
2767
- expect(byName.taiga_get_plan).toMatchObject({ readOnlyHint: true });
2772
+ // Settings: the same call twice leaves things as one call does.
2773
+ for (const name of ['taiga_set_auto_build', 'taiga_pause_line']) {
2774
+ expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true });
2775
+ }
2776
+ for (const name of ['taiga_get_plan', 'taiga_get_job', 'taiga_wait_for_job']) {
2777
+ expect(byName[name], name).toMatchObject({ readOnlyHint: true });
2778
+ }
2768
2779
  });
2769
2780
 
2770
- it('should create an initiative in the product and answer with where it stands, not the brief', async () => {
2781
+ it('should ask an agent for initiatives the way the web does, and answer with the job', async () => {
2782
+ const job = {
2783
+ id: 'job-1',
2784
+ type: 'generate_backlog',
2785
+ status: 'pending',
2786
+ targetId: PRODUCT_ID,
2787
+ result: null,
2788
+ metadata: {},
2789
+ };
2771
2790
  const { client, fetchSpy } = await bootMcpPair({
2772
2791
  capabilities: ALL_CAPABILITIES,
2773
- routes: { [`POST /v1/products/${PRODUCT_ID}/backlog`]: { data: item } },
2792
+ routes: { 'POST /v1/agent-jobs': { data: job } },
2774
2793
  });
2775
2794
 
2776
2795
  const result = await client.callTool({
2777
- name: 'taiga_create_backlog_item',
2778
- arguments: {
2779
- productId: PRODUCT_ID,
2780
- title: 'Add login',
2781
- description: 'Users log in.',
2782
- category: 'feature',
2783
- priority: 2,
2784
- },
2796
+ name: 'taiga_request_initiatives',
2797
+ arguments: { productId: PRODUCT_ID, title: ' Add login ', description: ' Users log in. ' },
2785
2798
  });
2786
2799
 
2787
2800
  expect(result.isError).toBeFalsy();
2788
- // The route wants the product in the body as well as the path.
2789
- expect(sentBody(fetchSpy, 'POST', `/products/${PRODUCT_ID}/backlog`)).toEqual({
2790
- productId: PRODUCT_ID,
2791
- title: 'Add login',
2792
- description: 'Users log in.',
2793
- category: 'feature',
2794
- priority: 2,
2801
+ // The body generateBacklogJob.ts builds: title and description trimmed, joined by a blank line.
2802
+ expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toEqual({
2803
+ scope: 'product',
2804
+ type: 'generate_backlog',
2805
+ targetId: PRODUCT_ID,
2806
+ metadata: { userPrompt: 'Add login\n\nUsers log in.' },
2795
2807
  });
2796
- const outcome = outcomeOf(result);
2797
- expect(outcome).toMatchObject({ id: ITEM_ID, state: 'backlog', lifecycle });
2798
- expect(outcome).not.toHaveProperty('description');
2799
- expect(outcome).not.toHaveProperty('acceptanceCriteria');
2808
+ expect(outcomeOf(result)).toEqual({
2809
+ id: 'job-1',
2810
+ type: 'generate_backlog',
2811
+ targetId: PRODUCT_ID,
2812
+ status: 'pending',
2813
+ });
2814
+ // Nothing writes an initiative directly: the agent does.
2815
+ expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).endsWith('/backlog'))).toBe(false);
2800
2816
  });
2801
2817
 
2802
- it('should refuse a create with no title or a brief past the limit before any request', async () => {
2803
- const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2804
- const before = fetchSpy.mock.calls.length;
2818
+ it('should ask for a suggestion when the request is empty, sending no prompt at all', async () => {
2819
+ const { client, fetchSpy } = await bootMcpPair({
2820
+ capabilities: ALL_CAPABILITIES,
2821
+ routes: { 'POST /v1/agent-jobs': { data: { id: 'job-2', type: 'generate_backlog', status: 'pending' } } },
2822
+ });
2805
2823
 
2806
- for (const args of [
2807
- { productId: PRODUCT_ID, title: '', description: 'x' },
2808
- { productId: PRODUCT_ID, title: 'T', description: 'x'.repeat(20_001) },
2809
- ]) {
2810
- const result = await client.callTool({ name: 'taiga_create_backlog_item', arguments: args });
2811
- expect(result.isError).toBe(true);
2812
- }
2813
- expect(fetchSpy.mock.calls.length).toBe(before);
2824
+ await client.callTool({ name: 'taiga_request_initiatives', arguments: { productId: PRODUCT_ID, title: ' ' } });
2825
+
2826
+ expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toEqual({
2827
+ scope: 'product',
2828
+ type: 'generate_backlog',
2829
+ targetId: PRODUCT_ID,
2830
+ });
2831
+ });
2832
+
2833
+ it('should say what an initiative request came to: the initiatives it added, or why it added none', async () => {
2834
+ const JOB_1 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a71';
2835
+ const JOB_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a72';
2836
+ const reason = {
2837
+ policyCode: 'ARC',
2838
+ quotedSpan: 'Services must not share a database.',
2839
+ conflict: 'The request shares the orders database.',
2840
+ alternative: 'An API between the services.',
2841
+ overridePath: 'Record an exception with the architecture board.',
2842
+ };
2843
+ const { client } = await bootMcpPair({
2844
+ capabilities: ['products:read'],
2845
+ routes: {
2846
+ [`/v1/agent-jobs/${JOB_1}`]: {
2847
+ data: {
2848
+ id: JOB_1,
2849
+ type: 'generate_backlog',
2850
+ status: 'completed',
2851
+ metadata: { userPrompt: 'the request' },
2852
+ result: { createdInitiatives: [{ id: ITEM_ID, title: 'Add login' }], createdInitiativeIds: [ITEM_ID] },
2853
+ },
2854
+ },
2855
+ [`/v1/agent-jobs/${JOB_2}`]: {
2856
+ data: {
2857
+ id: JOB_2,
2858
+ type: 'generate_backlog',
2859
+ status: 'completed',
2860
+ result: {
2861
+ outcome: 'needs_decision',
2862
+ message: 'Your architecture policy does not allow this.',
2863
+ coveredByPriority: null,
2864
+ coveredById: null,
2865
+ coveredInitiative: null,
2866
+ reason,
2867
+ },
2868
+ },
2869
+ },
2870
+ },
2871
+ });
2872
+
2873
+ const added = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB_1 } }));
2874
+ const refused = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB_2 } }));
2875
+
2876
+ expect(added).toEqual({
2877
+ id: JOB_1,
2878
+ type: 'generate_backlog',
2879
+ status: 'completed',
2880
+ outcome: { outcome: 'created', initiatives: [{ id: ITEM_ID, title: 'Add login' }] },
2881
+ });
2882
+ expect(added).not.toHaveProperty('metadata');
2883
+ expect(refused.outcome).toEqual({
2884
+ outcome: 'needs_decision',
2885
+ message: 'Your architecture policy does not allow this.',
2886
+ coveredBy: null,
2887
+ policy: reason,
2888
+ });
2889
+ });
2890
+
2891
+ it('should read an older request that recorded only the ids of what it added', async () => {
2892
+ const JOB = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a74';
2893
+ const { client } = await bootMcpPair({
2894
+ capabilities: ['products:read'],
2895
+ routes: {
2896
+ [`/v1/agent-jobs/${JOB}`]: {
2897
+ data: {
2898
+ id: JOB,
2899
+ type: 'generate_backlog',
2900
+ status: 'completed',
2901
+ result: { createdInitiativeIds: [ITEM_ID] },
2902
+ },
2903
+ },
2904
+ },
2905
+ });
2906
+
2907
+ const job = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB } }));
2908
+
2909
+ expect(job.outcome).toEqual({ outcome: 'created', initiatives: [{ id: ITEM_ID }] });
2910
+ });
2911
+
2912
+ it('should wait for a request to finish and answer with what it came to', async () => {
2913
+ const JOB = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a73';
2914
+ const running = { id: JOB, type: 'generate_backlog', status: 'processing', progress: 40, result: null };
2915
+ const finished = {
2916
+ ...running,
2917
+ status: 'completed',
2918
+ progress: 100,
2919
+ result: { createdInitiatives: [{ id: ITEM_ID, title: 'Add login' }] },
2920
+ };
2921
+ let call = 0;
2922
+ const { client } = await bootMcpPair({
2923
+ capabilities: ['products:read'],
2924
+ routes: {
2925
+ [`/v1/agent-jobs/${JOB}`]: () => {
2926
+ const body = { data: call++ < 2 ? running : finished };
2927
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
2928
+ },
2929
+ },
2930
+ });
2931
+
2932
+ const result = outcomeOf(await client.callTool({ name: 'taiga_wait_for_job', arguments: { jobId: JOB } }));
2933
+
2934
+ expect(result).toMatchObject({
2935
+ reached: true,
2936
+ job: { status: 'completed', outcome: { outcome: 'created', initiatives: [{ id: ITEM_ID }] } },
2937
+ });
2814
2938
  });
2815
2939
 
2816
2940
  it('should queue an initiative with the move the web makes and answer with the refreshed item', async () => {
@@ -2872,70 +2996,93 @@ describe('MCP server integration', () => {
2872
2996
  const backlogOnly = await bootMcpPair({ capabilities: ['backlog:write'] });
2873
2997
  const operatorOnly = await bootMcpPair({ capabilities: ['products:write'] });
2874
2998
 
2875
- expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
2876
- 'taiga_create_backlog_item',
2877
- 'taiga_move_backlog_item',
2878
- ]);
2879
- // The resume and the review gate take an item ID a create or a queue hands back, so they need no
2880
- // backlog read; the verdicts take a plan ID only the plan read gives.
2999
+ expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual(['taiga_move_backlog_item']);
3000
+ // A request, the resume and the review gate take a product or an item ID a request or a queue hands
3001
+ // back, so they need no backlog read; the verdicts and Plan again act on what only a read shows.
2881
3002
  expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual([
3003
+ 'taiga_request_initiatives',
2882
3004
  'taiga_resume_backlog_item',
2883
3005
  'taiga_set_auto_build',
2884
3006
  ]);
2885
3007
  });
2886
3008
 
2887
- it('should move an initiative into plan with a full key, and refuse queue or plan for a key without products:write before any request', async () => {
2888
- const full = await bootMcpPair({
3009
+ it('should refuse a move into the queue for a key without products:write before any request', async () => {
3010
+ const narrow = await bootMcpPair({ capabilities: ['backlog:read', 'backlog:write'] });
3011
+ const before = narrow.fetchSpy.mock.calls.length;
3012
+
3013
+ const result = await narrow.client.callTool({
3014
+ name: 'taiga_move_backlog_item',
3015
+ arguments: { itemId: ITEM_ID, state: 'queue' },
3016
+ });
3017
+
3018
+ expect(result.isError).toBe(true);
3019
+ expect(outcomeOf(result)).toEqual({
3020
+ error: 'FORBIDDEN',
3021
+ message: 'A move into queue starts agent work and needs products:write, which this key lacks.',
3022
+ });
3023
+ expect(narrow.fetchSpy.mock.calls.length).toBe(before);
3024
+ });
3025
+
3026
+ it('should plan again only at the Build station, and point elsewhere at the queue', async () => {
3027
+ const atStation = { ...item, state: 'plan', planningStatus: 'failed' };
3028
+ const station = await bootMcpPair({
2889
3029
  capabilities: ALL_CAPABILITIES,
2890
3030
  routes: {
2891
- [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...item, state: 'plan', planningStatus: 'planning' } },
3031
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: atStation },
3032
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...atStation, planningStatus: 'planning' } },
2892
3033
  },
2893
3034
  });
2894
- const planned = await full.client.callTool({
2895
- name: 'taiga_move_backlog_item',
2896
- arguments: { itemId: ITEM_ID, state: 'plan' },
3035
+ const replanned = await station.client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID } });
3036
+ expect(replanned.isError).toBeFalsy();
3037
+ expect(sentBody(station.fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan' });
3038
+ expect(outcomeOf(replanned)).toMatchObject({ state: 'plan', planningStatus: 'planning' });
3039
+
3040
+ const idle = await bootMcpPair({
3041
+ capabilities: ALL_CAPABILITIES,
3042
+ routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
2897
3043
  });
2898
- expect(planned.isError).toBeFalsy();
2899
- expect(sentBody(full.fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan' });
2900
- expect(outcomeOf(planned)).toMatchObject({ state: 'plan', planningStatus: 'planning' });
3044
+ const refused = await idle.client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID } });
3045
+ expect(refused.isError).toBe(true);
3046
+ expect(outcomeOf(refused).message).toMatch(/not at the Build station\. The queue is the only way to start work/);
3047
+ // Refused on the read: no move was sent.
3048
+ expect(idle.fetchSpy.mock.calls.some((c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT')).toBe(false);
3049
+ });
2901
3050
 
2902
- const narrow = await bootMcpPair({ capabilities: ['backlog:read', 'backlog:write'] });
2903
- const before = narrow.fetchSpy.mock.calls.length;
2904
- for (const state of ['queue', 'plan']) {
2905
- const result = await narrow.client.callTool({
2906
- name: 'taiga_move_backlog_item',
2907
- arguments: { itemId: ITEM_ID, state },
2908
- });
2909
- expect(result.isError, state).toBe(true);
2910
- expect(outcomeOf(result)).toEqual({
2911
- error: 'FORBIDDEN',
2912
- message: `A move into ${state} starts agent work and needs products:write, which this key lacks.`,
2913
- });
2914
- }
2915
- expect(narrow.fetchSpy.mock.calls.length).toBe(before);
3051
+ it('should plan again past an advisory gate only when force says so', async () => {
3052
+ const atStation = { ...item, state: 'build', planningStatus: 'planned' };
3053
+ const { client, fetchSpy } = await bootMcpPair({
3054
+ capabilities: ALL_CAPABILITIES,
3055
+ routes: {
3056
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: atStation },
3057
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...atStation, state: 'plan', planningStatus: 'planning' } },
3058
+ },
3059
+ });
3060
+
3061
+ await client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID, force: true } });
3062
+
3063
+ expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan', force: true });
2916
3064
  });
2917
3065
 
2918
- it("should pass on the API's refusal of a create, a resume, a rejection and a run resume alike", async () => {
3066
+ it("should pass on the API's refusal of a request, a resume and a rejection alike", async () => {
2919
3067
  const { client } = await bootMcpPair({
2920
3068
  capabilities: ALL_CAPABILITIES,
2921
3069
  routes: {
2922
- [`POST /v1/products/${PRODUCT_ID}/backlog`]: refusal(400, 'VALIDATION_ERROR', 'title too long'),
3070
+ 'POST /v1/agent-jobs': refusal(409, 'CONFLICT', 'Initiatives are already being generated for this product'),
2923
3071
  [`POST /v1/backlog/${ITEM_ID}/resume`]: refusal(
2924
3072
  409,
2925
3073
  'CONFLICT',
2926
3074
  'This initiative is not stopped, so there is nothing to resume.'
2927
3075
  ),
2928
3076
  [`POST /v1/plans/${PLAN_ID}/reject`]: refusal(409, 'CONFLICT', 'This plan has been replaced'),
2929
- [`POST /v1/builds/${BUILD_ID}/resume`]: refusal(409, 'CONFLICT', 'Build run is not resumable'),
2930
3077
  },
2931
3078
  });
2932
3079
 
2933
3080
  const calls = [
2934
3081
  [
2935
- 'taiga_create_backlog_item',
2936
- { productId: PRODUCT_ID, title: 'T', description: 'D' },
2937
- 'VALIDATION_ERROR',
2938
- 'title too long',
3082
+ 'taiga_request_initiatives',
3083
+ { productId: PRODUCT_ID, title: 'T' },
3084
+ 'CONFLICT',
3085
+ 'Initiatives are already being generated for this product',
2939
3086
  ],
2940
3087
  [
2941
3088
  'taiga_resume_backlog_item',
@@ -2944,7 +3091,6 @@ describe('MCP server integration', () => {
2944
3091
  'This initiative is not stopped, so there is nothing to resume.',
2945
3092
  ],
2946
3093
  ['taiga_reject_plan', { planId: PLAN_ID, comment: 'Again' }, 'CONFLICT', 'This plan has been replaced'],
2947
- ['taiga_resume_build', { buildId: BUILD_ID }, 'CONFLICT', 'Build run is not resumable'],
2948
3094
  ] as const;
2949
3095
  for (const [name, args, error, message] of calls) {
2950
3096
  const result = await client.callTool({ name, arguments: { ...args } });
@@ -3021,20 +3167,23 @@ describe('MCP server integration', () => {
3021
3167
  });
3022
3168
  });
3023
3169
 
3024
- it('should offer exactly the columns a person may drop into, and refuse build', async () => {
3170
+ it('should offer exactly the columns a person may drop into, and refuse build and plan', async () => {
3025
3171
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
3026
3172
 
3027
3173
  const { tools } = await client.listTools();
3028
3174
  const move = tools.find((t) => t.name === 'taiga_move_backlog_item')!;
3029
3175
  const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
3030
- expect(props.state!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled', 'plan']);
3176
+ // The queue is the only way to start work: no "plan this now", nothing dropped into Build.
3177
+ expect(props.state!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
3031
3178
 
3032
3179
  const before = fetchSpy.mock.calls.length;
3033
- const result = await client.callTool({
3034
- name: 'taiga_move_backlog_item',
3035
- arguments: { itemId: ITEM_ID, state: 'build' },
3036
- });
3037
- expect(result.isError).toBe(true);
3180
+ for (const state of ['build', 'plan']) {
3181
+ const result = await client.callTool({
3182
+ name: 'taiga_move_backlog_item',
3183
+ arguments: { itemId: ITEM_ID, state },
3184
+ });
3185
+ expect(result.isError, state).toBe(true);
3186
+ }
3038
3187
  expect(fetchSpy.mock.calls.length).toBe(before);
3039
3188
  });
3040
3189
 
@@ -3161,13 +3310,12 @@ describe('MCP server integration', () => {
3161
3310
  expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'This plan has been replaced' });
3162
3311
  });
3163
3312
 
3164
- it('should stop and resume a run and answer with its state and next actions, not its noise', async () => {
3313
+ it('should stop a run and answer with its state and next actions, not its noise', async () => {
3165
3314
  const stopped = { ...run, status: 'stopped', failedReason: 'user_stopped', actions: ['resume', 'start_over'] };
3166
3315
  const { client, fetchSpy } = await bootMcpPair({
3167
3316
  capabilities: ['products:read', 'products:write'],
3168
3317
  routes: {
3169
3318
  [`POST /v1/builds/${BUILD_ID}/stop`]: { data: stopped },
3170
- [`POST /v1/builds/${BUILD_ID}/resume`]: { data: { ...run, status: 'pending', actions: ['stop'] } },
3171
3319
  },
3172
3320
  });
3173
3321
 
@@ -3186,10 +3334,6 @@ describe('MCP server integration', () => {
3186
3334
  });
3187
3335
  const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/stop'))!;
3188
3336
  expect((call[1] as RequestInit).body).toBeNull();
3189
-
3190
- const resume = await client.callTool({ name: 'taiga_resume_build', arguments: { buildId: BUILD_ID } });
3191
- expect(resume.isError).toBeFalsy();
3192
- expect(outcomeOf(resume)).toMatchObject({ status: 'pending', actions: ['stop'] });
3193
3337
  });
3194
3338
 
3195
3339
  it("should pass on the API's refusal to stop a run that is not running", async () => {
@@ -3647,6 +3791,45 @@ describe('MCP server integration', () => {
3647
3791
  );
3648
3792
  });
3649
3793
 
3794
+ it('should pause and resume the line, and say in the queue read that a paused line waits for nothing else', async () => {
3795
+ const paused = { id: PRODUCT_ID, lineStoppedAt: '2026-10-08T20:00:00.000Z' };
3796
+ const { client, fetchSpy } = await bootMcpPair({
3797
+ capabilities: ['products:read', 'products:write', 'backlog:read'],
3798
+ routes: {
3799
+ [`PUT /v1/products/${PRODUCT_ID}/line`]: { data: paused },
3800
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: paused },
3801
+ [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] },
3802
+ [`/v1/backlog/${HEAD_ITEM}`]: { data: head },
3803
+ },
3804
+ });
3805
+
3806
+ const pause = parseToolResult(
3807
+ await client.callTool({ name: 'taiga_pause_line', arguments: { productId: PRODUCT_ID, paused: true } })
3808
+ );
3809
+ expect(sentBody(fetchSpy, 'PUT', '/line')).toEqual({ stopped: true });
3810
+ expect(pause).toEqual({ productId: PRODUCT_ID, linePaused: true, lineStoppedAt: '2026-10-08T20:00:00.000Z' });
3811
+
3812
+ const queue = parseToolResult(
3813
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3814
+ );
3815
+ expect(queue.linePaused).toBe(true);
3816
+
3817
+ // Resuming sends the other answer and reports the line running.
3818
+ fetchSpy.mockClear();
3819
+ fetchSpy.mockImplementation(
3820
+ makeFetchRouter({
3821
+ '/v1/auth/introspect': introspectionResponse(['products:read', 'products:write', 'backlog:read']),
3822
+ [`PUT /v1/products/${PRODUCT_ID}/line`]: { data: { id: PRODUCT_ID, lineStoppedAt: null } },
3823
+ }).getMockImplementation()!
3824
+ );
3825
+ const resumed = parseToolResult(
3826
+ await client.callTool({ name: 'taiga_pause_line', arguments: { productId: PRODUCT_ID, paused: false } })
3827
+ );
3828
+ expect(sentBody(fetchSpy, 'PUT', '/line')).toEqual({ stopped: false });
3829
+ expect(resumed).toEqual({ productId: PRODUCT_ID, linePaused: false, lineStoppedAt: null });
3830
+ expect(queue.waitingOn).toMatch(/^The line is paused \(since 2026-10-08T20:00:00.000Z\)/);
3831
+ });
3832
+
3650
3833
  it('should keep the queue when an entry cannot be named, and say why entries carry ids only', async () => {
3651
3834
  const queueRoute = { [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] } };
3652
3835
  // One pair at a time: each boot stubs the global fetch the previous client reads through.
@@ -4110,6 +4293,26 @@ describe('MCP server integration', () => {
4110
4293
  expect(productCalls).toBe(2);
4111
4294
  });
4112
4295
 
4296
+ it("should wake a wait for any change when a landed plan's review gate is set, though the lifecycle reads the same", async () => {
4297
+ const landed = {
4298
+ ...item,
4299
+ state: 'plan',
4300
+ planningStatus: 'planned',
4301
+ autoBuild: null,
4302
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
4303
+ };
4304
+ const { client } = await bootMcpPair({
4305
+ capabilities: ['backlog:read'],
4306
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(landed, landed, { ...landed, autoBuild: false }) },
4307
+ });
4308
+
4309
+ const result = parseToolResult(
4310
+ await client.callTool({ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'changed' } })
4311
+ );
4312
+
4313
+ expect(result).toMatchObject({ reached: true, item: { autoBuild: false } });
4314
+ });
4315
+
4113
4316
  it('should answer reached: false at the timeout', async () => {
4114
4317
  const { client } = await bootMcpPair({
4115
4318
  capabilities: ['backlog:read'],
@@ -30,3 +30,9 @@ export const OPERATE = {
30
30
  idempotentHint: false,
31
31
  openWorldHint: false,
32
32
  } as const;
33
+
34
+ /**
35
+ * An OPERATE write that sets a value rather than adding one: the same call
36
+ * twice leaves things as one call does (a review gate set, a line paused).
37
+ */
38
+ export const OPERATE_SETTING = { ...OPERATE, idempotentHint: true } as const;