@taiga-ai/mcp-server 0.25.0-dev.465 → 0.25.0-dev.467

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/dist/index.js CHANGED
@@ -670,6 +670,29 @@ const POLICY_TYPE_BY_CONTROL_PREFIX = {
670
670
  SCS: "policy_supply_chain",
671
671
  CLD: "policy_cloud_governance"
672
672
  };
673
+ /** The policy document a control id belongs to, or undefined for an unknown prefix. */
674
+ function policyTypeForControl(controlId) {
675
+ return POLICY_TYPE_BY_CONTROL_PREFIX[controlId.split("-")[0] ?? ""];
676
+ }
677
+ /**
678
+ * The policy a control id points a reader to (PRO-2809): its rightmost base
679
+ * prefix. An overlay family names its host policy last: TEL-TIC-1.1 is a
680
+ * telecom control in policy_tic, HC-FI-DATA-1.1 a Finnish healthcare control in
681
+ * policy_data, ARC-AWS-5 an AWS preset in policy_architecture. A regulatory
682
+ * family with no base prefix (GDPR, NIS2, AIACT) spans several policies and
683
+ * names none.
684
+ *
685
+ * For saying where to read a control, not for deciding who holds it:
686
+ * {@link policyTypeForControl} keeps an overlay control with the document it is
687
+ * written in, whatever its id says.
688
+ */
689
+ function policyNamedByControl(controlId) {
690
+ const prefixes = controlId.split("-").filter((part) => /^[A-Z]/.test(part));
691
+ for (let at = prefixes.length - 1; at >= 0; at -= 1) {
692
+ const owner = POLICY_TYPE_BY_CONTROL_PREFIX[prefixes[at]];
693
+ if (owner) return owner;
694
+ }
695
+ }
673
696
  /**
674
697
  * The shape of a control id in a policy's control table: `SEC-3.4`, and the
675
698
  * overlay families too (`TEL-TIC-1.1`, `HC-FI-DATA-1.1`, `GDPR-1.1`). One
@@ -2663,17 +2686,6 @@ const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().des
2663
2686
  /** The sections input of a company document, where a policy control id also names the sections that state it. */
2664
2687
  const policySectionsInput = sectionsInput.describe("Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document");
2665
2688
  /**
2666
- * The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
2667
- * TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
2668
- */
2669
- function policyOfControl(id) {
2670
- const prefixes = id.split("-").filter((part) => /^[A-Z]/.test(part));
2671
- for (const prefix of prefixes.reverse()) {
2672
- const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
2673
- if (policy) return policy;
2674
- }
2675
- }
2676
- /**
2677
2689
  * The sections that state each control id asked for (PRO-2800): policy
2678
2690
  * standing cites SEC-5.1, and the section holding it has an id of its own
2679
2691
  * (secretsManagement) an agent could only guess. A control id is found in
@@ -2699,7 +2711,7 @@ function withControls(outline, sections, policyType) {
2699
2711
  return outline.map((entry) => {
2700
2712
  const text = sectionContent(sections, entry.id) ?? "";
2701
2713
  const ids = [...new Set([...text.matchAll(pattern)].filter((match) => !/^_?Reserved_?/i.test(match[2] ?? "")).map((match) => match[1]).filter((id) => {
2702
- const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split("-")[0] ?? ""];
2714
+ const owner = policyTypeForControl(id);
2703
2715
  return owner === void 0 || owner === policyType;
2704
2716
  }))];
2705
2717
  return ids.length > 0 ? {
@@ -2727,7 +2739,7 @@ function sectionReadResult(doc, asked, label, options = {}) {
2727
2739
  const known = ids.filter((id) => all.includes(id));
2728
2740
  const found = Object.keys(controls).length > 0 ? { controls } : {};
2729
2741
  const asControl = options.controlIds ? void 0 : unknownSections.find((id) => isControlId(id));
2730
- const policy = asControl ? policyOfControl(asControl) : void 0;
2742
+ const policy = asControl ? policyNamedByControl(asControl) : void 0;
2731
2743
  const controlNote = asControl ? { note: policy ? `${asControl} is a control of ${policy}: read it with taiga_get_document (type: ${policy}, sections: ["${asControl}"]).` : `${asControl} looks like a policy control id, which only a policy resolves: taiga_get_document without type lists them.` } : {};
2732
2744
  if (known.length === 0) {
2733
2745
  const { data: _data, ...meta } = cut.doc;
@@ -3769,20 +3781,27 @@ const INITIATIVE_CHECKS = 10;
3769
3781
  * The status of the initiative behind each run still waiting on its pull
3770
3782
  * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
3771
3783
  * "waiting for a person to review and merge" while Action Required called the
3772
- * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
3773
- * cap, one whose read failed, and every one without backlog:read is
3774
- * 'unchecked', which withInitiativeState adds to the row's outcome.
3784
+ * same review stale.
3785
+ *
3786
+ * The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
3787
+ * archived, deleted, or null while live), for every key that can read it.
3788
+ * Against an API from before that, each initiative is read: a 404 is
3789
+ * 'deleted', and an initiative past the cap, one whose read failed, and every
3790
+ * one without backlog:read is 'unchecked', which withInitiativeState adds to
3791
+ * the row's outcome.
3775
3792
  */
3776
3793
  async function initiativeStates(client, rows) {
3777
3794
  const states = /* @__PURE__ */ new Map();
3778
3795
  const waiting = rows.filter((row) => ["needs_review", "completed"].includes(String(row["status"])) && row["prMerged"] !== true);
3779
- const all = [...new Set(waiting.map((row) => row["backlogItemId"]).filter((id) => typeof id === "string"))];
3796
+ const told = waiting.filter((row) => "initiativeClosed" in row);
3797
+ for (const row of told) if (typeof row["backlogItemId"] === "string") states.set(row["backlogItemId"], typeof row["initiativeClosed"] === "string" ? row["initiativeClosed"] : "live");
3798
+ const all = [...new Set(waiting.filter((row) => !("initiativeClosed" in row)).map((row) => row["backlogItemId"]).filter((id) => typeof id === "string" && !states.has(id)))];
3780
3799
  const ids = client.hasCapability("backlog:read") ? all.slice(0, INITIATIVE_CHECKS) : [];
3781
3800
  for (const id of all) if (!ids.includes(id)) states.set(id, "unchecked");
3782
3801
  await Promise.all(ids.map(async (id) => {
3783
3802
  try {
3784
3803
  const item = (await client.getBacklogItem(id)).data;
3785
- states.set(id, String(item["status"] ?? item["state"]));
3804
+ states.set(id, item["archivedAt"] ? "archived" : String(item["status"] ?? item["state"]));
3786
3805
  } catch (error) {
3787
3806
  if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, "deleted");
3788
3807
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
@@ -3791,7 +3810,18 @@ async function initiativeStates(client, rows) {
3791
3810
  }));
3792
3811
  return states;
3793
3812
  }
3794
- /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
3813
+ /**
3814
+ * How each way an initiative ends reads on a run it left waiting: the same set
3815
+ * Action Required closes a run's review for (PRO-2806). A done one's waiting
3816
+ * run is not the one that landed it: that run is merged and never waiting.
3817
+ */
3818
+ const INITIATIVE_ENDED = {
3819
+ done: "was marked done",
3820
+ canceled: "was canceled",
3821
+ archived: "was archived",
3822
+ deleted: "was deleted"
3823
+ };
3824
+ /** A readable run row that also says when its initiative ended while its pull request waited. */
3795
3825
  function withInitiativeState(row, states) {
3796
3826
  const state = states.get(String(row["backlogItemId"]));
3797
3827
  if (!["gated", "unmerged"].includes(String(row["disposition"]))) return row;
@@ -3799,14 +3829,15 @@ function withInitiativeState(row, states) {
3799
3829
  ...row,
3800
3830
  outcome: `${String(row["outcome"])} Whether its initiative still wants it was not checked.`
3801
3831
  };
3802
- if (state !== "canceled" && state !== "deleted") return row;
3832
+ const ended = state ? INITIATIVE_ENDED[state] : void 0;
3833
+ if (!ended) return row;
3803
3834
  const pr = typeof row["prNumber"] === "number" ? `PR #${row["prNumber"]}` : "its pull request";
3804
3835
  const open = row["prState"] === "open" ? ` ${pr} is still open on the forge.` : "";
3805
3836
  return {
3806
3837
  ...row,
3807
3838
  disposition: "abandoned",
3808
3839
  actions: [],
3809
- outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`
3840
+ outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`
3810
3841
  };
3811
3842
  }
3812
3843
  /** A step that changed only prose: no code of its own could have broken a test. */
@@ -3913,7 +3944,7 @@ function registerBuildTools(server, client) {
3913
3944
  if (!client.hasCapability("products:read")) return;
3914
3945
  server.registerTool("taiga_list_runs", {
3915
3946
  title: "List runs",
3916
- description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3947
+ description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3917
3948
  inputSchema: z.object({
3918
3949
  productId: productId.optional(),
3919
3950
  initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it").optional().describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
@@ -21408,41 +21439,29 @@ const MISSING_DOCUMENTS_HOW = "An imported product gets them from its import (ta
21408
21439
  /**
21409
21440
  * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
21410
21441
  * creates a second one, so a retried call or two agents at once left
21411
- * duplicates nobody asked for. allowDuplicateName says the person means it.
21442
+ * duplicates nobody asked for. The API checks it in the create's transaction
21443
+ * (PRO-2808), so names past any page and two creates at once are caught too.
21444
+ * allowDuplicateName says the person means it.
21412
21445
  */
21413
21446
  const allowDuplicateName = z.boolean().optional().describe("Create it even when one with the same name exists (default false: refused, naming the existing one)");
21414
- /** The same name, as a person would read it: case and surrounding space aside. */
21415
- const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
21416
- /** Pages read looking for a taken name; past this the create goes ahead. */
21417
- const NAME_PAGES = 5;
21418
21447
  /**
21419
- * The first row with this name, across pages. Best effort: a list the API
21420
- * refuses or fails leaves the check out rather than the create.
21421
- */
21422
- async function firstNamed(name, read) {
21423
- let cursor;
21424
- try {
21425
- for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
21426
- const result = await read(cursor);
21427
- const same = result.data.find((row) => sameName(row.name, name));
21428
- if (same) return same;
21429
- if (!result.nextCursor) return null;
21430
- cursor = result.nextCursor;
21431
- }
21432
- } catch (error) {
21433
- if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
21434
- }
21435
- return null;
21436
- }
21437
- /**
21438
- * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
21439
- * "use it" would hand the agent someone else's work (PRO-2800, review 6).
21440
- */
21441
- function duplicateName(kind, existing, where) {
21442
- const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
21443
- return toolError("DUPLICATE_NAME", `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).`, {
21444
- existingId: existing.id,
21445
- ...existing.slug ? { existingSlug: existing.slug } : {}
21448
+ * The API's refusal of a taken name, as the tool's: it names the one that
21449
+ * exists when the caller may read it, and leaves the choice to the person.
21450
+ * Another session may have made it, so "use it" would hand the agent someone
21451
+ * else's work (PRO-2800, review 6). Anything else is not this refusal.
21452
+ */
21453
+ function duplicateName(kind, error, where) {
21454
+ if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.["reason"] !== "duplicate_name") return null;
21455
+ const said = (key) => typeof error.details?.[key] === "string" ? error.details[key] : void 0;
21456
+ const [existingId, existingSlug, name, createdAt] = [
21457
+ said("existingId"),
21458
+ said("existingSlug"),
21459
+ said("existingName"),
21460
+ said("existingCreatedAt")
21461
+ ];
21462
+ return toolError("DUPLICATE_NAME", `${name ? `A ${kind} named "${name}"` : `A ${kind} with this name`} already exists ${where}${existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ""})` : ""}. ${existingId ? "It may not be this person's: ask whether they meant that one, want another name, or want a second" : "The caller cannot read it: ask whether they want another name or a second"} (allowDuplicateName: true).`, {
21463
+ ...existingId ? { existingId } : {},
21464
+ ...existingSlug ? { existingSlug } : {}
21446
21465
  });
21447
21466
  }
21448
21467
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
@@ -21483,14 +21502,16 @@ function registerSetupTools(server, client) {
21483
21502
  }),
21484
21503
  annotations: WRITE
21485
21504
  }, (params) => runTool(async () => {
21486
- if (!params.allowDuplicateName) {
21487
- const same = await firstNamed(params.name, async (cursor) => client.listFactories({
21488
- limit: 100,
21489
- cursor
21490
- }));
21491
- if (same) return duplicateName("factory", same, "in this organization");
21505
+ try {
21506
+ return toolResult(pickFields([(await client.createFactory({
21507
+ name: params.name,
21508
+ refuseDuplicateName: !params.allowDuplicateName
21509
+ })).data], FACTORY_FIELDS)[0]);
21510
+ } catch (error) {
21511
+ const refused = duplicateName("factory", error, "in this organization");
21512
+ if (refused) return refused;
21513
+ throw error;
21492
21514
  }
21493
- return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
21494
21515
  }));
21495
21516
  server.registerTool("taiga_rename_factory", {
21496
21517
  title: "Rename a factory",
@@ -21555,22 +21576,22 @@ function registerSetupTools(server, client) {
21555
21576
  }, (params) => runTool(async () => {
21556
21577
  const contradiction = notesWithNothingToDescribe(params);
21557
21578
  if (contradiction) return contradiction;
21558
- if (!params.allowDuplicateName) {
21559
- const same = await firstNamed(params.name, async (cursor) => client.listProducts({
21579
+ let product;
21580
+ try {
21581
+ product = (await client.createProduct({
21582
+ name: params.name,
21560
21583
  factoryId: params.factoryId,
21561
- limit: 100,
21562
- cursor
21563
- }));
21564
- if (same) return duplicateName("product", same, "in this factory");
21584
+ ...params.start === "import" ? { projectType: "brownfield" } : {},
21585
+ ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21586
+ ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21587
+ ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {},
21588
+ refuseDuplicateName: !params.allowDuplicateName
21589
+ })).data;
21590
+ } catch (error) {
21591
+ const refused = duplicateName("product", error, "in this factory");
21592
+ if (refused) return refused;
21593
+ throw error;
21565
21594
  }
21566
- const product = (await client.createProduct({
21567
- name: params.name,
21568
- factoryId: params.factoryId,
21569
- ...params.start === "import" ? { projectType: "brownfield" } : {},
21570
- ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21571
- ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21572
- ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {}
21573
- })).data;
21574
21595
  return toolResult(await productRow(client, product));
21575
21596
  }));
21576
21597
  server.registerTool("taiga_update_product", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.25.0-dev.465",
3
+ "version": "0.25.0-dev.467",
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",
package/src/client.ts CHANGED
@@ -44,6 +44,8 @@ import type {
44
44
  SetupTaskListEntry,
45
45
  TenantDocument,
46
46
  UptimeReport,
47
+ CreateFactoryInput,
48
+ CreateProductInput,
47
49
  } from '@taiga/types';
48
50
  import { SERVER_VERSION } from './version.js';
49
51
 
@@ -844,7 +846,7 @@ export class TaigaClient {
844
846
  return this.request<Page<Factory>>('GET', `/factories${qs ? `?${qs}` : ''}`);
845
847
  }
846
848
 
847
- async createFactory(body: { name: string }) {
849
+ async createFactory(body: CreateFactoryInput) {
848
850
  return this.request<One<Factory>>('POST', '/factories', body);
849
851
  }
850
852
 
@@ -856,14 +858,18 @@ export class TaigaClient {
856
858
  // Setting a product up
857
859
  // ---------------------------------------------------------------------------
858
860
 
859
- async createProduct(body: {
860
- name: string;
861
- factoryId: string;
862
- projectType?: 'brownfield' | undefined;
863
- ownsInfrastructure?: boolean | null | undefined;
864
- ownsDeployment?: boolean | null | undefined;
865
- deploymentNotes?: string | undefined;
866
- }) {
861
+ async createProduct(
862
+ body: Pick<
863
+ CreateProductInput,
864
+ | 'name'
865
+ | 'factoryId'
866
+ | 'projectType'
867
+ | 'ownsInfrastructure'
868
+ | 'ownsDeployment'
869
+ | 'deploymentNotes'
870
+ | 'refuseDuplicateName'
871
+ >
872
+ ) {
867
873
  return this.request<One<Product>>('POST', '/products', body);
868
874
  }
869
875
 
@@ -5790,13 +5790,14 @@ describe('MCP server integration', () => {
5790
5790
  );
5791
5791
 
5792
5792
  // Following the organization is saying nothing, as the web's dialog does.
5793
- expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID });
5793
+ expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID, refuseDuplicateName: true });
5794
5794
  expect(sentBody(fetchSpy, 'POST', '/v1/products')).toEqual({
5795
5795
  name: 'Billing',
5796
5796
  factoryId: FACTORY_ID,
5797
5797
  projectType: 'brownfield',
5798
5798
  ownsDeployment: false,
5799
5799
  deploymentNotes: 'Platform team owns the pipelines',
5800
+ refuseDuplicateName: true,
5800
5801
  });
5801
5802
  expect(scratch).toMatchObject({ id: PRODUCT_ID, projectType: 'greenfield' });
5802
5803
  expect(imported).toMatchObject({ projectType: 'brownfield', ownsDeployment: false });
@@ -5889,7 +5890,7 @@ describe('MCP server integration', () => {
5889
5890
  arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID, stopLine: true },
5890
5891
  });
5891
5892
 
5892
- expect(factoryBody).toEqual({ name: 'Payments' });
5893
+ expect(factoryBody).toEqual({ name: 'Payments', refuseDuplicateName: true });
5893
5894
  expect(created).toEqual({ id: OTHER_FACTORY_ID, name: 'Payments', slug: 'payments', isDefault: false });
5894
5895
  expect(renamed.name).toBe('Payments EU');
5895
5896
  expect(seen).toEqual(preview);
@@ -7810,58 +7811,142 @@ describe('what the fourth agent review found (PRO-2800)', () => {
7810
7811
  expect(runs[0]!['actions']).toEqual([]);
7811
7812
  });
7812
7813
 
7813
- it('refuses a factory or product name already taken, unless the person wants a second', async () => {
7814
+ it("reads how a run's initiative ended from the run itself, with no backlog read (PRO-2808)", async () => {
7815
+ const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
7814
7816
  const { client, fetchSpy } = await bootMcpPair({
7815
- capabilities: ALL_CAPABILITIES,
7817
+ // No backlog:read: before PRO-2808 these rows said only that nobody checked.
7818
+ capabilities: ['products:read'],
7816
7819
  routes: {
7817
- 'GET /v1/factories': { data: [{ id: FACTORY_ID, name: 'Platform', slug: 'platform' }], nextCursor: null },
7818
- 'POST /v1/factories': { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } },
7820
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7821
+ data: [
7822
+ { ...base, id: 'r1', backlogItemId: 'i1', prNumber: 10, initiativeClosed: 'archived' },
7823
+ { ...base, id: 'r2', backlogItemId: 'i2', prNumber: 11, initiativeClosed: 'done' },
7824
+ { ...base, id: 'r3', backlogItemId: 'i3', prNumber: 12, initiativeClosed: null },
7825
+ ],
7826
+ nextCursor: null,
7827
+ },
7819
7828
  },
7820
7829
  });
7821
7830
 
7822
- const refused = await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } });
7823
- const made = await client.callTool({
7824
- name: 'taiga_create_factory',
7825
- arguments: { name: 'platform', allowDuplicateName: true },
7826
- });
7831
+ const runs = parseToolResult(
7832
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7833
+ ).data as Record<string, unknown>[];
7827
7834
 
7828
- expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
7829
- expect(made.isError).toBeFalsy();
7830
- expect(
7831
- fetchSpy.mock.calls.filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
7832
- ).toHaveLength(1);
7835
+ expect(runs.map((run) => [run['disposition'], run['outcome']])).toEqual([
7836
+ [
7837
+ 'abandoned',
7838
+ 'Its initiative was archived, so nobody is waiting on this run. PR #10 is still open on the forge.',
7839
+ ],
7840
+ [
7841
+ 'abandoned',
7842
+ 'Its initiative was marked done, so nobody is waiting on this run. PR #11 is still open on the forge.',
7843
+ ],
7844
+ [runs[2]!['disposition'], expect.not.stringMatching(/not checked|abandoned|nobody/)],
7845
+ ]);
7846
+ expect(runs[2]!['disposition']).not.toBe('abandoned');
7847
+ expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/v1/backlog/'))).toBe(false);
7833
7848
  });
7834
7849
 
7835
- it('refuses a product name already taken, unless the person wants a second', async () => {
7836
- const { client, fetchSpy } = await bootMcpPair({
7837
- capabilities: ALL_CAPABILITIES,
7850
+ it('reads an archived initiative as archived from an API that does not say it on the run (PRO-2808)', async () => {
7851
+ const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
7852
+ const { client } = await bootMcpPair({
7853
+ capabilities: ['products:read', 'backlog:read'],
7838
7854
  routes: {
7839
- 'GET /v1/products': {
7840
- data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID }],
7855
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7856
+ data: [{ ...base, id: 'r1', backlogItemId: ITEM_ID, prNumber: 10 }],
7841
7857
  nextCursor: null,
7842
7858
  },
7843
- 'POST /v1/products': {
7844
- data: { id: PRODUCT_ID_2, name: 'web', slug: 'web-2', status: 'active', factoryId: FACTORY_ID, settings: {} },
7859
+ // Archiving keeps the status the initiative had.
7860
+ [`GET /v1/backlog/${ITEM_ID}`]: {
7861
+ data: { id: ITEM_ID, status: 'in_progress', state: 'build', archivedAt: '2026-10-09T10:00:00.000Z' },
7845
7862
  },
7846
7863
  },
7847
7864
  });
7848
7865
 
7849
- const refused = await client.callTool({
7850
- name: 'taiga_create_product',
7851
- arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
7866
+ const runs = parseToolResult(
7867
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7868
+ ).data as Record<string, unknown>[];
7869
+
7870
+ expect(runs[0]).toMatchObject({
7871
+ disposition: 'abandoned',
7872
+ outcome: expect.stringMatching(/^Its initiative was archived/),
7852
7873
  });
7874
+ });
7875
+
7876
+ it('asks the API to refuse a taken name, and says so naming the one that exists (PRO-2808)', async () => {
7877
+ const reply = (ok: boolean, status: number, body: unknown) => ({
7878
+ ok,
7879
+ status,
7880
+ json: () => Promise.resolve(body),
7881
+ text: () => Promise.resolve(JSON.stringify(body)),
7882
+ });
7883
+ const taken = reply(false, 409, {
7884
+ error: 'CONFLICT',
7885
+ code: 'CONFLICT',
7886
+ message: 'A factory named "Platform" already exists in this organization.',
7887
+ details: {
7888
+ reason: 'duplicate_name',
7889
+ existingId: FACTORY_ID,
7890
+ existingSlug: 'platform',
7891
+ existingName: 'Platform',
7892
+ existingCreatedAt: '2026-10-10T10:00:00.000Z',
7893
+ },
7894
+ });
7895
+ const { client, fetchSpy } = await bootMcpPair({
7896
+ capabilities: ALL_CAPABILITIES,
7897
+ routes: {
7898
+ 'POST /v1/factories': (_url: string, init: RequestInit) =>
7899
+ JSON.parse(init.body as string).refuseDuplicateName
7900
+ ? taken
7901
+ : reply(true, 201, { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } }),
7902
+ },
7903
+ });
7904
+
7905
+ const refused = parseToolResult(
7906
+ await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } })
7907
+ );
7853
7908
  const made = await client.callTool({
7854
- name: 'taiga_create_product',
7855
- arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch', allowDuplicateName: true },
7909
+ name: 'taiga_create_factory',
7910
+ arguments: { name: 'platform', allowDuplicateName: true },
7856
7911
  });
7857
7912
 
7858
- expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: PRODUCT_ID } });
7913
+ expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
7914
+ expect(refused.message).toBe(
7915
+ `A factory named "Platform" already exists in this organization (${FACTORY_ID}, created ` +
7916
+ "2026-10-10T10:00:00.000Z). It may not be this person's: ask whether they meant that one, want another " +
7917
+ 'name, or want a second (allowDuplicateName: true).'
7918
+ );
7859
7919
  expect(made.isError).toBeFalsy();
7860
- const posts = fetchSpy.mock.calls.filter(
7861
- (call: unknown[]) =>
7862
- (call[1] as RequestInit | undefined)?.method === 'POST' && String(call[0]).endsWith('/v1/products')
7920
+ const bodies = fetchSpy.mock.calls
7921
+ .filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
7922
+ .map((call: unknown[]) => JSON.parse((call[1] as RequestInit).body as string));
7923
+ expect(bodies.map((body: Record<string, unknown>) => body['refuseDuplicateName'])).toEqual([true, false]);
7924
+ // No list read first: the API's check is the one that holds.
7925
+ expect(fetchSpy.mock.calls.some((call: unknown[]) => String(call[0]).includes('/v1/factories?'))).toBe(false);
7926
+ });
7927
+
7928
+ it('says a product name is taken without naming the one a caller cannot read', async () => {
7929
+ const { client } = await bootMcpPair({
7930
+ capabilities: ALL_CAPABILITIES,
7931
+ routes: {
7932
+ 'POST /v1/products': refusal(409, 'CONFLICT', 'A product with this name already exists in this factory.', {
7933
+ reason: 'duplicate_name',
7934
+ }),
7935
+ },
7936
+ });
7937
+
7938
+ const refused = parseToolResult(
7939
+ await client.callTool({
7940
+ name: 'taiga_create_product',
7941
+ arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
7942
+ })
7943
+ );
7944
+
7945
+ expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: {} });
7946
+ expect(refused.message).toBe(
7947
+ 'A product with this name already exists in this factory. The caller cannot read it: ask whether they want ' +
7948
+ 'another name or a second (allowDuplicateName: true).'
7863
7949
  );
7864
- expect(posts).toHaveLength(1);
7865
7950
  });
7866
7951
 
7867
7952
  it('bounds the full policy standing and reads one violation whole', async () => {
@@ -8172,36 +8257,6 @@ describe('what the fourth agent review found (PRO-2800)', () => {
8172
8257
  expect(list.staleNote).toMatch(/^1 review was not checked for staleness/);
8173
8258
  });
8174
8259
 
8175
- it('looks for a taken product name in its own factory only, and leaves the choice to the person', async () => {
8176
- const { client, fetchSpy } = await bootMcpPair({
8177
- capabilities: ALL_CAPABILITIES,
8178
- routes: {
8179
- 'GET /v1/products': {
8180
- data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', createdAt: '2026-10-10T10:00:00Z' }],
8181
- nextCursor: null,
8182
- },
8183
- },
8184
- });
8185
-
8186
- const refused = parseToolResult(
8187
- await client.callTool({
8188
- name: 'taiga_create_product',
8189
- arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch' },
8190
- })
8191
- );
8192
-
8193
- const listed = fetchSpy.mock.calls
8194
- .map((call: unknown[]) => String(call[0]))
8195
- .find((url: string) => url.includes('/v1/products?'));
8196
- expect(new URL(listed!).searchParams.get('factoryId')).toBe(FACTORY_ID);
8197
- expect(refused.message).toBe(
8198
- `A product named "Web" already exists in this factory (${PRODUCT_ID}, created 2026-10-10T10:00:00Z). It may ` +
8199
- "not be this person's: ask whether they meant that one, want another name, or want a second " +
8200
- '(allowDuplicateName: true).'
8201
- );
8202
- expect(refused.message).not.toMatch(/Use it/);
8203
- });
8204
-
8205
8260
  it('names a factory ID passed where a product ID belongs, and drops the transport text from a miss', async () => {
8206
8261
  const { client } = await bootMcpPair({
8207
8262
  capabilities: ['products:read', 'backlog:read', 'factories:read'],
@@ -273,9 +273,14 @@ const INITIATIVE_CHECKS = 10;
273
273
  * The status of the initiative behind each run still waiting on its pull
274
274
  * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
275
275
  * "waiting for a person to review and merge" while Action Required called the
276
- * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
277
- * cap, one whose read failed, and every one without backlog:read is
278
- * 'unchecked', which withInitiativeState adds to the row's outcome.
276
+ * same review stale.
277
+ *
278
+ * The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
279
+ * archived, deleted, or null while live), for every key that can read it.
280
+ * Against an API from before that, each initiative is read: a 404 is
281
+ * 'deleted', and an initiative past the cap, one whose read failed, and every
282
+ * one without backlog:read is 'unchecked', which withInitiativeState adds to
283
+ * the row's outcome.
279
284
  */
280
285
  export async function initiativeStates(
281
286
  client: TaigaClient,
@@ -286,8 +291,19 @@ export async function initiativeStates(
286
291
  const waiting = rows.filter(
287
292
  (row) => ['needs_review', 'completed'].includes(String(row['status'])) && row['prMerged'] !== true
288
293
  );
294
+ const told = waiting.filter((row) => 'initiativeClosed' in row);
295
+ for (const row of told) {
296
+ if (typeof row['backlogItemId'] === 'string') {
297
+ states.set(row['backlogItemId'], typeof row['initiativeClosed'] === 'string' ? row['initiativeClosed'] : 'live');
298
+ }
299
+ }
289
300
  const all = [
290
- ...new Set(waiting.map((row) => row['backlogItemId']).filter((id): id is string => typeof id === 'string')),
301
+ ...new Set(
302
+ waiting
303
+ .filter((row) => !('initiativeClosed' in row))
304
+ .map((row) => row['backlogItemId'])
305
+ .filter((id): id is string => typeof id === 'string' && !states.has(id))
306
+ ),
291
307
  ];
292
308
  // Without backlog:read, or past the cap, a waiting run's initiative is not read: it is marked unchecked so
293
309
  // the row does not claim a person is waiting when nobody may be.
@@ -297,7 +313,8 @@ export async function initiativeStates(
297
313
  ids.map(async (id) => {
298
314
  try {
299
315
  const item = (await client.getBacklogItem(id)).data as unknown as Record<string, unknown>;
300
- states.set(id, String(item['status'] ?? item['state']));
316
+ // Archiving keeps the status it had, so it is read apart, as the API reads it (initiativeClosed).
317
+ states.set(id, item['archivedAt'] ? 'archived' : String(item['status'] ?? item['state']));
301
318
  } catch (error) {
302
319
  if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, 'deleted');
303
320
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
@@ -308,7 +325,19 @@ export async function initiativeStates(
308
325
  return states;
309
326
  }
310
327
 
311
- /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
328
+ /**
329
+ * How each way an initiative ends reads on a run it left waiting: the same set
330
+ * Action Required closes a run's review for (PRO-2806). A done one's waiting
331
+ * run is not the one that landed it: that run is merged and never waiting.
332
+ */
333
+ const INITIATIVE_ENDED: Record<string, string> = {
334
+ done: 'was marked done',
335
+ canceled: 'was canceled',
336
+ archived: 'was archived',
337
+ deleted: 'was deleted',
338
+ };
339
+
340
+ /** A readable run row that also says when its initiative ended while its pull request waited. */
312
341
  export function withInitiativeState(
313
342
  row: Record<string, unknown>,
314
343
  states: Map<string, string>
@@ -320,7 +349,8 @@ export function withInitiativeState(
320
349
  if (state === 'unchecked') {
321
350
  return { ...row, outcome: `${String(row['outcome'])} Whether its initiative still wants it was not checked.` };
322
351
  }
323
- if (state !== 'canceled' && state !== 'deleted') return row;
352
+ const ended = state ? INITIATIVE_ENDED[state] : undefined;
353
+ if (!ended) return row;
324
354
  const pr = typeof row['prNumber'] === 'number' ? `PR #${row['prNumber']}` : 'its pull request';
325
355
  const open = row['prState'] === 'open' ? ` ${pr} is still open on the forge.` : '';
326
356
  return {
@@ -328,7 +358,7 @@ export function withInitiativeState(
328
358
  disposition: 'abandoned',
329
359
  // Retry or start over cannot succeed once the initiative is gone.
330
360
  actions: [],
331
- outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`,
361
+ outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`,
332
362
  };
333
363
  }
334
364
 
@@ -485,8 +515,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
485
515
  description:
486
516
  'List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), ' +
487
517
  'actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), ' +
488
- 'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled ' +
489
- "or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run " +
518
+ 'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: ' +
519
+ "done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps " +
520
+ 'running ones. taiga_get_run ' +
490
521
  'reads one; carry work on from the initiative (taiga_resume_initiative).',
491
522
  inputSchema: z.object({
492
523
  productId: productId.optional(),
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { CallToolResult } from '@modelcontextprotocol/server';
15
15
  import { z } from 'zod';
16
- import { CONTROL_ID_SOURCE, isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
16
+ import { CONTROL_ID_SOURCE, isControlId, policyNamedByControl, policyTypeForControl } from '@taiga/types';
17
17
  import { toolError, toolResult } from './errors.js';
18
18
 
19
19
  /** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
@@ -177,19 +177,6 @@ export const policySectionsInput = sectionsInput.describe(
177
177
  'Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
178
178
  );
179
179
 
180
- /**
181
- * The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
182
- * TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
183
- */
184
- function policyOfControl(id: string): string | undefined {
185
- const prefixes = id.split('-').filter((part) => /^[A-Z]/.test(part));
186
- for (const prefix of prefixes.reverse()) {
187
- const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
188
- if (policy) return policy;
189
- }
190
- return undefined;
191
- }
192
-
193
180
  /**
194
181
  * The sections that state each control id asked for (PRO-2800): policy
195
182
  * standing cites SEC-5.1, and the section holding it has an id of its own
@@ -231,7 +218,7 @@ function withControls(outline: { id: string; characters: number }[], sections: S
231
218
  // A base control belongs to the policy its prefix names: an SEC row in the data policy is a stale mention,
232
219
  // as heldControlMap reads it. An overlay prefix stays where it is written.
233
220
  .filter((id) => {
234
- const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split('-')[0] ?? ''];
221
+ const owner = policyTypeForControl(id);
235
222
  return owner === undefined || owner === policyType;
236
223
  })
237
224
  ),
@@ -267,7 +254,7 @@ export function sectionReadResult(
267
254
  const found = Object.keys(controls).length > 0 ? { controls } : {};
268
255
  // A control id asked of a document that does not state controls: name the policy that does.
269
256
  const asControl = options.controlIds ? undefined : unknownSections.find((id) => isControlId(id));
270
- const policy = asControl ? policyOfControl(asControl) : undefined;
257
+ const policy = asControl ? policyNamedByControl(asControl) : undefined;
271
258
  const controlNote = asControl
272
259
  ? {
273
260
  note: policy
@@ -5,7 +5,7 @@ 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
7
  import { isImportedProduct } from './catalogue.js';
8
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
8
+ import { runTool, toolError, toolResult } from './errors.js';
9
9
  import { setupReadiness } from './readiness.js';
10
10
  import { withStop } from './hints.js';
11
11
  import { idInput, productId } from './inputs.js';
@@ -68,58 +68,42 @@ type ProductRow = WithoutTenant<Product>;
68
68
  /**
69
69
  * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
70
70
  * creates a second one, so a retried call or two agents at once left
71
- * duplicates nobody asked for. allowDuplicateName says the person means it.
71
+ * duplicates nobody asked for. The API checks it in the create's transaction
72
+ * (PRO-2808), so names past any page and two creates at once are caught too.
73
+ * allowDuplicateName says the person means it.
72
74
  */
73
75
  const allowDuplicateName = z
74
76
  .boolean()
75
77
  .optional()
76
78
  .describe('Create it even when one with the same name exists (default false: refused, naming the existing one)');
77
79
 
78
- /** The same name, as a person would read it: case and surrounding space aside. */
79
- const sameName = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
80
-
81
- /** Pages read looking for a taken name; past this the create goes ahead. */
82
- const NAME_PAGES = 5;
83
-
84
80
  /**
85
- * The first row with this name, across pages. Best effort: a list the API
86
- * refuses or fails leaves the check out rather than the create.
81
+ * The API's refusal of a taken name, as the tool's: it names the one that
82
+ * exists when the caller may read it, and leaves the choice to the person.
83
+ * Another session may have made it, so "use it" would hand the agent someone
84
+ * else's work (PRO-2800, review 6). Anything else is not this refusal.
87
85
  */
88
- async function firstNamed<T extends { id: string; name: string; slug?: string; createdAt?: unknown }>(
89
- name: string,
90
- read: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>
91
- ): Promise<T | null> {
92
- let cursor: string | undefined;
93
- try {
94
- for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
95
- const result = await read(cursor);
96
- const same = result.data.find((row) => sameName(row.name, name));
97
- if (same) return same;
98
- if (!result.nextCursor) return null;
99
- cursor = result.nextCursor;
100
- }
101
- } catch (error) {
102
- if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
86
+ function duplicateName(kind: 'factory' | 'product', error: unknown, where: string) {
87
+ if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.['reason'] !== 'duplicate_name') {
88
+ return null;
103
89
  }
104
- return null;
105
- }
106
-
107
- /**
108
- * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
109
- * "use it" would hand the agent someone else's work (PRO-2800, review 6).
110
- */
111
- function duplicateName(
112
- kind: 'factory' | 'product',
113
- existing: { id: string; name: string; slug?: string; createdAt?: unknown },
114
- where: string
115
- ) {
116
- const made = typeof existing.createdAt === 'string' ? `, created ${existing.createdAt}` : '';
117
- return toolError(
118
- 'DUPLICATE_NAME',
119
- `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this ` +
120
- "person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).",
121
- { existingId: existing.id, ...(existing.slug ? { existingSlug: existing.slug } : {}) }
122
- );
90
+ const said = (key: string) => (typeof error.details?.[key] === 'string' ? (error.details[key] as string) : undefined);
91
+ const [existingId, existingSlug, name, createdAt] = [
92
+ said('existingId'),
93
+ said('existingSlug'),
94
+ said('existingName'),
95
+ said('existingCreatedAt'),
96
+ ];
97
+ const which = name ? `A ${kind} named "${name}"` : `A ${kind} with this name`;
98
+ const ids = existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ''})` : '';
99
+ // Without the holder's id the caller cannot read it, so "that one" is not a choice they have.
100
+ const choices = existingId
101
+ ? "It may not be this person's: ask whether they meant that one, want another name, or want a second"
102
+ : 'The caller cannot read it: ask whether they want another name or a second';
103
+ return toolError('DUPLICATE_NAME', `${which} already exists ${where}${ids}. ${choices} (allowDuplicateName: true).`, {
104
+ ...(existingId ? { existingId } : {}),
105
+ ...(existingSlug ? { existingSlug } : {}),
106
+ });
123
107
  }
124
108
 
125
109
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
@@ -186,11 +170,17 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
186
170
  },
187
171
  (params) =>
188
172
  runTool(async () => {
189
- if (!params.allowDuplicateName) {
190
- const same = await firstNamed(params.name, async (cursor) => client.listFactories({ limit: 100, cursor }));
191
- if (same) return duplicateName('factory', same, 'in this organization');
173
+ try {
174
+ const made = await client.createFactory({
175
+ name: params.name,
176
+ refuseDuplicateName: !params.allowDuplicateName,
177
+ });
178
+ return toolResult(pickFields([made.data], FACTORY_FIELDS)[0]);
179
+ } catch (error) {
180
+ const refused = duplicateName('factory', error, 'in this organization');
181
+ if (refused) return refused;
182
+ throw error;
192
183
  }
193
- return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
194
184
  })
195
185
  );
196
186
 
@@ -308,23 +298,25 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
308
298
  runTool(async () => {
309
299
  const contradiction = notesWithNothingToDescribe(params);
310
300
  if (contradiction) return contradiction;
311
- if (!params.allowDuplicateName) {
312
- // Within the factory it goes into: the same name in another factory is a different product.
313
- const same = await firstNamed(params.name, async (cursor) =>
314
- client.listProducts({ factoryId: params.factoryId, limit: 100, cursor })
315
- );
316
- if (same) return duplicateName('product', same, 'in this factory');
301
+ let product: ProductRow;
302
+ try {
303
+ product = (
304
+ await client.createProduct({
305
+ name: params.name,
306
+ factoryId: params.factoryId,
307
+ ...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
308
+ ...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
309
+ ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
310
+ ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
311
+ // Within the factory it goes into: the same name in another factory is a different product.
312
+ refuseDuplicateName: !params.allowDuplicateName,
313
+ })
314
+ ).data;
315
+ } catch (error) {
316
+ const refused = duplicateName('product', error, 'in this factory');
317
+ if (refused) return refused;
318
+ throw error;
317
319
  }
318
- const product = (
319
- await client.createProduct({
320
- name: params.name,
321
- factoryId: params.factoryId,
322
- ...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
323
- ...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
324
- ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
325
- ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
326
- })
327
- ).data;
328
320
  return toolResult(await productRow(client, product));
329
321
  })
330
322
  );