@taiga-ai/mcp-server 0.21.0-dev.455 → 0.22.0-dev.457
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 +225 -53
- package/package.json +1 -1
- package/src/server.integration.test.ts +328 -3
- package/src/tools/backlogs.ts +35 -47
- package/src/tools/builds.ts +52 -1
- package/src/tools/context.ts +2 -2
- package/src/tools/errors.ts +15 -6
- package/src/tools/inbox.ts +60 -6
- package/src/tools/index.ts +51 -7
- package/src/tools/inputs.ts +1 -1
- package/src/tools/instructions.ts +4 -3
- package/src/tools/maintenance.ts +56 -6
- package/src/tools/products.ts +3 -1
- package/src/tools/repositories.ts +26 -3
- package/src/tools/setup.ts +23 -11
- package/taiga-ai-mcp-server-0.22.0-dev.457.tgz +0 -0
- package/taiga-ai-mcp-server-0.21.0-dev.455.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -584,7 +584,7 @@ const OPERATE_SETTING = {
|
|
|
584
584
|
* "Invalid UUID" alone sends a model that passed a name to guess again.
|
|
585
585
|
*/
|
|
586
586
|
function idInput(what, from) {
|
|
587
|
-
return z.string().uuid({ message:
|
|
587
|
+
return z.string().uuid({ message: `${/^[aeiou]/i.test(what) ? "An" : "A"} ${what} ID is a UUID, not a name: ${from}` }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${from}`);
|
|
588
588
|
}
|
|
589
589
|
const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it");
|
|
590
590
|
/** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
|
|
@@ -2379,7 +2379,7 @@ function inProductWords(value) {
|
|
|
2379
2379
|
}
|
|
2380
2380
|
/** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
|
|
2381
2381
|
function inProductWording(said) {
|
|
2382
|
-
return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found");
|
|
2382
|
+
return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found").replace(/^This product already has a repository\. Change it instead of linking another\./i, "This product already has a repository. Pass replacesRepositoryId to swap it in one step, or taiga_unlink_repository first (taiga_list_product_repositories gives its ID).");
|
|
2383
2383
|
}
|
|
2384
2384
|
/** A successful tool result: one JSON text block the model reads. */
|
|
2385
2385
|
function toolResult(value) {
|
|
@@ -2460,8 +2460,8 @@ async function runTool(handler) {
|
|
|
2460
2460
|
} catch (error) {
|
|
2461
2461
|
if (error instanceof TaigaApiError) {
|
|
2462
2462
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? "API_ERROR";
|
|
2463
|
-
const
|
|
2464
|
-
return toolError(code, error.status === 404 ? withNotFoundHint(
|
|
2463
|
+
const worded = inProductWording(error.apiMessage ?? `Taiga API answered ${error.status}.`);
|
|
2464
|
+
return toolError(code, error.status === 404 ? withNotFoundHint(worded) : worded, error.details);
|
|
2465
2465
|
}
|
|
2466
2466
|
if (isNetworkFailure(error)) return toolError("NETWORK_ERROR", `Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`);
|
|
2467
2467
|
throw error;
|
|
@@ -3088,7 +3088,7 @@ async function resolveProductRef(client, ref, accepted, usage) {
|
|
|
3088
3088
|
return { product: (await client.getProduct(ref.productId)).data };
|
|
3089
3089
|
} catch (error) {
|
|
3090
3090
|
if (!isMissOrRefusal(error)) throw error;
|
|
3091
|
-
return refusal(apiFailureCode(error), `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`);
|
|
3091
|
+
return refusal(apiFailureCode(error), error instanceof TaigaApiError && error.status === 404 ? "No product with that ID the key can see; taiga_list_products shows the ones it can." : `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`);
|
|
3092
3092
|
}
|
|
3093
3093
|
if (ref.slug) {
|
|
3094
3094
|
const [product] = (await client.listProducts({
|
|
@@ -3758,6 +3758,33 @@ function withInitiativeState(row, states) {
|
|
|
3758
3758
|
outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`
|
|
3759
3759
|
};
|
|
3760
3760
|
}
|
|
3761
|
+
/** A step that changed only prose: no code of its own could have broken a test. */
|
|
3762
|
+
const PROSE_ONLY = /(^|\/)(README|CHANGELOG|LICENSE|NOTICE|AUTHORS)$|\.(md|mdx|txt|rst|adoc)$/i;
|
|
3763
|
+
/**
|
|
3764
|
+
* What a check that failed after every step means (PRO-2800). Two reviews read
|
|
3765
|
+
* seven steps of "tests: failed" as seven breakages, while the headlines said
|
|
3766
|
+
* each step's own tests passed: a check that fails from the first step on,
|
|
3767
|
+
* docs-only steps included, is one the repository or the check environment
|
|
3768
|
+
* carries throughout. The check records no reason, so this says what the
|
|
3769
|
+
* pattern shows and no more.
|
|
3770
|
+
*/
|
|
3771
|
+
function verificationNote(steps) {
|
|
3772
|
+
const checked = steps.filter((step) => Array.isArray(step["verification"]) && step["verification"].length > 0);
|
|
3773
|
+
if (checked.length < 2) return null;
|
|
3774
|
+
const failedOn = (step) => step["verification"].filter((check) => check.status === "failed").map((check) => String(check.check));
|
|
3775
|
+
const always = failedOn(checked[0]).filter((check) => checked.every((step) => failedOn(step).includes(check)));
|
|
3776
|
+
if (always.length === 0) return null;
|
|
3777
|
+
const prose = checked.find((step) => {
|
|
3778
|
+
const files = step["filesChanged"];
|
|
3779
|
+
return Array.isArray(files) && files.length > 0 && files.every((file) => PROSE_ONLY.test(String(file)));
|
|
3780
|
+
});
|
|
3781
|
+
if (!prose && checked.length < 3) return null;
|
|
3782
|
+
const reasons = [...new Set(checked.flatMap((step) => step["verification"].filter((check) => check.status === "failed" && always.includes(String(check.check))).map((check) => check.reason).filter((reason) => typeof reason === "string" && reason.length > 0)))];
|
|
3783
|
+
const first = checked[0]["stepOrder"];
|
|
3784
|
+
const last = checked[checked.length - 1]["stepOrder"];
|
|
3785
|
+
const proseSaid = prose ? `, including step ${String(prose["stepOrder"])}, which changed only documentation` : "";
|
|
3786
|
+
return `Taiga's ${always.join(" and ")} check failed after every checked step (${String(first)} to ${String(last)})${proseSaid}. A check failing from the first step on is usually one the repository or the check environment fails throughout, not one each step broke. ` + (reasons.length > 0 ? `Its reason${reasons.length === 1 ? "" : "s"}: ${reasons.slice(0, 3).join("; ")}.` : "It records no reason; each step's headline says what its own run found.");
|
|
3787
|
+
}
|
|
3761
3788
|
/** readableRun for a list row. */
|
|
3762
3789
|
const readableRow = (run) => readableRun(run, { row: true });
|
|
3763
3790
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
@@ -3860,14 +3887,18 @@ function registerBuildTools(server, client) {
|
|
|
3860
3887
|
const stop = where ? { stoppedIn: where } : {};
|
|
3861
3888
|
const states = await initiativeStates(client, [runRow]);
|
|
3862
3889
|
const readable = withInitiativeState(readableRun(runRow), states);
|
|
3890
|
+
const note = verificationNote(rows);
|
|
3891
|
+
const verification = note ? { verificationNote: note } : {};
|
|
3863
3892
|
if (params.full) return toolResult({
|
|
3864
3893
|
...readable,
|
|
3865
3894
|
...stop,
|
|
3895
|
+
...verification,
|
|
3866
3896
|
steps: steps.data
|
|
3867
3897
|
});
|
|
3868
3898
|
return toolResult({
|
|
3869
3899
|
...readable,
|
|
3870
3900
|
...stop,
|
|
3901
|
+
...verification,
|
|
3871
3902
|
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
3872
3903
|
...step,
|
|
3873
3904
|
headline: summaryHeadline(rows[index].summary)
|
|
@@ -4272,16 +4303,18 @@ function registerBacklogTools(server, client) {
|
|
|
4272
4303
|
function registerBacklogReads(server, client) {
|
|
4273
4304
|
server.registerTool("taiga_list_initiatives", {
|
|
4274
4305
|
title: "List initiatives",
|
|
4275
|
-
description: "List a product's initiatives: title, category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked off by a person; null before planning, so a done initiative can show fewer met than it has). query finds one by title; taiga_get_initiative reads it in full. Archived ones are left out unless asked for.",
|
|
4306
|
+
description: "List a product's open initiatives (not done or canceled; status: 'all' for every one): title, category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked off by a person; null before planning, so a done initiative can show fewer met than it has). query finds one by title, state one board column; taiga_get_initiative reads it in full. Archived ones are left out unless asked for.",
|
|
4276
4307
|
inputSchema: z.object({
|
|
4277
4308
|
productId,
|
|
4278
4309
|
query: z.string().trim().min(1).max(200).optional().describe("Part of the title, any case: only the items that contain it"),
|
|
4279
4310
|
status: z.enum([
|
|
4311
|
+
"open",
|
|
4312
|
+
"all",
|
|
4280
4313
|
"backlog",
|
|
4281
4314
|
"in_progress",
|
|
4282
4315
|
"done",
|
|
4283
4316
|
"canceled"
|
|
4284
|
-
]).optional().describe("
|
|
4317
|
+
]).optional().describe("open (default without state): not done or canceled; all: every one; in_progress covers everything between backlog and done"),
|
|
4285
4318
|
planningStatus: z.enum([
|
|
4286
4319
|
"unplanned",
|
|
4287
4320
|
"planning",
|
|
@@ -4300,32 +4333,25 @@ function registerBacklogReads(server, client) {
|
|
|
4300
4333
|
}),
|
|
4301
4334
|
annotations: READ_ONLY
|
|
4302
4335
|
}, (params) => runTool(async () => {
|
|
4303
|
-
const { query, state, ...filters } = params;
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
return toolResult({
|
|
4312
|
-
...page(situatedRows(found.rows), null),
|
|
4313
|
-
...found.more ? { more: "More items may be in that column: narrow it with status or query, or raise the limit." } : {}
|
|
4314
|
-
});
|
|
4315
|
-
}
|
|
4316
|
-
if (query === void 0) {
|
|
4317
|
-
const result = await client.listBacklogItems(params.productId, filters);
|
|
4336
|
+
const { query, state, status = state === void 0 ? "open" : "all", ...filters } = params;
|
|
4337
|
+
const apiFilters = {
|
|
4338
|
+
...filters,
|
|
4339
|
+
status: status === "open" || status === "all" ? void 0 : status
|
|
4340
|
+
};
|
|
4341
|
+
const open = status === "open";
|
|
4342
|
+
if (query === void 0 && state === void 0 && !open) {
|
|
4343
|
+
const result = await client.listBacklogItems(params.productId, apiFilters);
|
|
4318
4344
|
return toolResult(page(situatedRows(result.data), result.nextCursor));
|
|
4319
4345
|
}
|
|
4320
|
-
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR",
|
|
4346
|
+
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "Open items, a board column or a title search answer one page; narrow them, or pass status: 'all' (or one status) to page through every item.");
|
|
4321
4347
|
const found = await searchPages((cursor) => client.listBacklogItems(params.productId, {
|
|
4322
|
-
...
|
|
4348
|
+
...apiFilters,
|
|
4323
4349
|
limit: 100,
|
|
4324
4350
|
cursor
|
|
4325
|
-
}), query, (row) => [row.title], params.limit ?? 25);
|
|
4351
|
+
}), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => (state === void 0 || row.state === state) && (!open || !["done", "canceled"].includes(row.status)));
|
|
4326
4352
|
return toolResult({
|
|
4327
4353
|
...page(situatedRows(found.rows), null),
|
|
4328
|
-
...found.more ? { more:
|
|
4354
|
+
...found.more ? { more: "There may be more than the limit carries: narrow it with state or query, or list one status (backlog, in_progress, done or canceled) without them, which pages." } : {}
|
|
4329
4355
|
});
|
|
4330
4356
|
}));
|
|
4331
4357
|
server.registerTool("taiga_get_initiative", {
|
|
@@ -4564,6 +4590,44 @@ const FINDING_ROW_FIELDS = [
|
|
|
4564
4590
|
"ignoreReason",
|
|
4565
4591
|
"ignoreExpiresAt"
|
|
4566
4592
|
];
|
|
4593
|
+
/**
|
|
4594
|
+
* Whether taiga_fix_finding would take a finding, said on the row (PRO-2800): three reviews found a dependency
|
|
4595
|
+
* marked remediableBy coding_agent with no fixed version, which the API refuses to fix. The API's own refusals,
|
|
4596
|
+
* read off the row; remediableBy stays as the sweep recorded it.
|
|
4597
|
+
*/
|
|
4598
|
+
function withFixability(row) {
|
|
4599
|
+
if ((row["kind"] === "vulnerable_dependency" || row["kind"] === "outdated_dependency") && !row["recommendedVersion"] && row["remediableBy"] === "coding_agent") return {
|
|
4600
|
+
...row,
|
|
4601
|
+
fixable: false,
|
|
4602
|
+
fixNote: "No fixed version is known yet: taiga_fix_finding refuses it until one is published."
|
|
4603
|
+
};
|
|
4604
|
+
return row;
|
|
4605
|
+
}
|
|
4606
|
+
/** The parts of an advisory record an agent reads; the rest (every affected range, every reference) is full: true. */
|
|
4607
|
+
const ADVISORY_KEPT = [
|
|
4608
|
+
"id",
|
|
4609
|
+
"aliases",
|
|
4610
|
+
"summary",
|
|
4611
|
+
"details",
|
|
4612
|
+
"severity",
|
|
4613
|
+
"published"
|
|
4614
|
+
];
|
|
4615
|
+
const ADVISORY_TEXT_LIMIT = 1500;
|
|
4616
|
+
function leanAdvisory(data) {
|
|
4617
|
+
if (typeof data !== "object" || data === null || !("affected" in data || "references" in data)) return data;
|
|
4618
|
+
const record = data;
|
|
4619
|
+
const kept = Object.fromEntries(ADVISORY_KEPT.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
4620
|
+
const details = kept["details"];
|
|
4621
|
+
if (typeof details === "string" && details.length > ADVISORY_TEXT_LIMIT) kept["details"] = `${details.slice(0, ADVISORY_TEXT_LIMIT)}... [cut at ${ADVISORY_TEXT_LIMIT} of ${details.length} characters; full: true reads it whole]`;
|
|
4622
|
+
const references = Array.isArray(record["references"]) ? record["references"] : [];
|
|
4623
|
+
return {
|
|
4624
|
+
...kept,
|
|
4625
|
+
...references.length > 0 ? {
|
|
4626
|
+
references: references.slice(0, 3),
|
|
4627
|
+
...references.length > 3 ? { referencesNote: `3 of ${references.length}; full: true has every one.` } : {}
|
|
4628
|
+
} : {}
|
|
4629
|
+
};
|
|
4630
|
+
}
|
|
4567
4631
|
/** A sweep as the record of when the product was last checked, and whether that check held. */
|
|
4568
4632
|
const SWEEP_FIELDS = [
|
|
4569
4633
|
"id",
|
|
@@ -4662,17 +4726,24 @@ function registerMaintenanceTools(server, client) {
|
|
|
4662
4726
|
sweep: null,
|
|
4663
4727
|
sweepNote: "The sweep overview could not be read: an empty list here is not an all-clear."
|
|
4664
4728
|
} : sweep ? { sweep } : {},
|
|
4665
|
-
...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
|
|
4729
|
+
...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
|
|
4666
4730
|
...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {}
|
|
4667
4731
|
});
|
|
4668
4732
|
}));
|
|
4669
4733
|
server.registerTool("taiga_get_finding", {
|
|
4670
4734
|
title: "Get a maintenance finding",
|
|
4671
|
-
description: "Read one maintenance finding
|
|
4672
|
-
inputSchema: z.object({
|
|
4735
|
+
description: "Read one maintenance finding: exploit likelihood (EPSS, known-exploited list), the advisory summarized (full: true for its whole record: every affected range and reference) and fixed version, code location, breached policy controls, why it ranks where it does, history (reintroduced, previously resolved) and the remediation under way. fixable: false with fixNote says taiga_fix_finding would refuse it. A secret's value is never stored, only the rule and location.",
|
|
4736
|
+
inputSchema: z.object({
|
|
4737
|
+
findingId,
|
|
4738
|
+
full: z.boolean().optional().describe("Carry the advisory's whole record: every affected range and reference (default false)")
|
|
4739
|
+
}),
|
|
4673
4740
|
annotations: READ_ONLY
|
|
4674
4741
|
}, (params) => runTool(async () => {
|
|
4675
|
-
const { runId, ...
|
|
4742
|
+
const { runId, ...raw } = (await client.getFinding(params.findingId)).data;
|
|
4743
|
+
const finding = withFixability(params.full ? raw : {
|
|
4744
|
+
...raw,
|
|
4745
|
+
data: leanAdvisory(raw["data"])
|
|
4746
|
+
});
|
|
4676
4747
|
return toolResult(runId === void 0 ? finding : {
|
|
4677
4748
|
sweepId: runId,
|
|
4678
4749
|
...finding
|
|
@@ -5011,7 +5082,7 @@ async function documentsPart(client, product, depth) {
|
|
|
5011
5082
|
notes
|
|
5012
5083
|
};
|
|
5013
5084
|
}
|
|
5014
|
-
/** Every published policy
|
|
5085
|
+
/** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
|
|
5015
5086
|
async function policiesPart(client) {
|
|
5016
5087
|
return (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => POLICY_TYPES.includes(String(row["type"])) && row["isDraft"] === false).map((row) => ({
|
|
5017
5088
|
type: row["type"],
|
|
@@ -5183,7 +5254,7 @@ function registerContextTools(server, client) {
|
|
|
5183
5254
|
if (!client.hasCapability("products:read")) return;
|
|
5184
5255
|
server.registerTool("taiga_get_product_context", {
|
|
5185
5256
|
title: "Get product context",
|
|
5186
|
-
description: `Read where a product stands in one call; the first read for any product question. Name it by ID, slug or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, specification and architecture outlines (section ids, sizes), policies with section counts, open initiatives (taiga_list_initiatives has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1e3}k characters each, full returns all.`,
|
|
5257
|
+
description: `Read where a product stands in one call; the first read for any product question. Name it by ID, slug or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, specification and architecture outlines (section ids, sizes), policies with section counts, open initiatives (taiga_list_initiatives status: 'all' has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1e3}k characters each, full returns all.`,
|
|
5187
5258
|
inputSchema: z.object({
|
|
5188
5259
|
productId: productId.optional(),
|
|
5189
5260
|
slug: slug.optional(),
|
|
@@ -5289,8 +5360,12 @@ function itemRow(row, now, full) {
|
|
|
5289
5360
|
const { snoozedUntil, resolvedAt, ...rest } = pickFields([row], ITEM_FIELDS)[0];
|
|
5290
5361
|
const metadata = row["metadata"] ?? {};
|
|
5291
5362
|
const created = typeof row["createdAt"] === "string" ? Date.parse(row["createdAt"]) : NaN;
|
|
5363
|
+
const { summary, ...lean } = rest;
|
|
5292
5364
|
return {
|
|
5293
|
-
...
|
|
5365
|
+
...row["kind"] === "review_pr" && !full ? lean : {
|
|
5366
|
+
...lean,
|
|
5367
|
+
summary
|
|
5368
|
+
},
|
|
5294
5369
|
...snoozedUntil ? { snoozedUntil } : {},
|
|
5295
5370
|
...resolvedAt ? { resolvedAt } : {},
|
|
5296
5371
|
...typeof metadata["prUrl"] === "string" ? { prUrl: metadata["prUrl"] } : {},
|
|
@@ -5298,7 +5373,10 @@ function itemRow(row, now, full) {
|
|
|
5298
5373
|
...full ? { metadata: row["metadata"] } : {}
|
|
5299
5374
|
};
|
|
5300
5375
|
}
|
|
5301
|
-
/**
|
|
5376
|
+
/**
|
|
5377
|
+
* What is open, counted by product and kind: the answer to "what needs attention" without reading every row. An item
|
|
5378
|
+
* that no longer stands is counted under stale, not open or byKind, so nobody subtracts by hand (PRO-2800).
|
|
5379
|
+
*/
|
|
5302
5380
|
function summaryOf(rows, stale) {
|
|
5303
5381
|
const byProduct = /* @__PURE__ */ new Map();
|
|
5304
5382
|
for (const row of rows) {
|
|
@@ -5313,9 +5391,11 @@ function summaryOf(rows, stale) {
|
|
|
5313
5391
|
open: 0,
|
|
5314
5392
|
byKind: {}
|
|
5315
5393
|
});
|
|
5316
|
-
entry.open += 1;
|
|
5317
|
-
entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
|
|
5318
5394
|
if (stale.has(String(row["id"]))) entry.stale = (entry.stale ?? 0) + 1;
|
|
5395
|
+
else {
|
|
5396
|
+
entry.open += 1;
|
|
5397
|
+
entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
|
|
5398
|
+
}
|
|
5319
5399
|
byProduct.set(key, entry);
|
|
5320
5400
|
}
|
|
5321
5401
|
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
@@ -5350,11 +5430,42 @@ async function staleReviews(client, rows) {
|
|
|
5350
5430
|
unchecked += 1;
|
|
5351
5431
|
}
|
|
5352
5432
|
}));
|
|
5433
|
+
unchecked += await staleBacklogReviews(client, rows, out);
|
|
5353
5434
|
return {
|
|
5354
5435
|
stale: out,
|
|
5355
5436
|
unchecked
|
|
5356
5437
|
};
|
|
5357
5438
|
}
|
|
5439
|
+
/**
|
|
5440
|
+
* "Review Initiatives" items whose product has no open initiative left (PRO-2800): two reviews found one
|
|
5441
|
+
* waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
|
|
5442
|
+
* a handful of products; returns how many could not be checked.
|
|
5443
|
+
*/
|
|
5444
|
+
async function staleBacklogReviews(client, rows, out) {
|
|
5445
|
+
const asks = [...new Map(rows.filter((row) => row["kind"] === "review_backlog" && typeof row["productId"] === "string").map((row) => [String(row["id"]), row])).values()];
|
|
5446
|
+
if (asks.length === 0) return 0;
|
|
5447
|
+
if (!client.hasCapability("backlog:read")) return asks.length;
|
|
5448
|
+
const checked = [...new Set(asks.map((row) => String(row["productId"])))].slice(0, STALE_CHECKS);
|
|
5449
|
+
let unchecked = asks.filter((row) => !checked.includes(String(row["productId"]))).length;
|
|
5450
|
+
const empty = /* @__PURE__ */ new Set();
|
|
5451
|
+
await Promise.all(checked.map(async (productId) => {
|
|
5452
|
+
try {
|
|
5453
|
+
const [waiting, started] = await Promise.all([client.listBacklogItems(productId, {
|
|
5454
|
+
status: "backlog",
|
|
5455
|
+
limit: 1
|
|
5456
|
+
}), client.listBacklogItems(productId, {
|
|
5457
|
+
status: "in_progress",
|
|
5458
|
+
limit: 1
|
|
5459
|
+
})]);
|
|
5460
|
+
if (waiting.data.length === 0 && started.data.length === 0) empty.add(productId);
|
|
5461
|
+
} catch (error) {
|
|
5462
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5463
|
+
unchecked += asks.filter((row) => row["productId"] === productId).length;
|
|
5464
|
+
}
|
|
5465
|
+
}));
|
|
5466
|
+
for (const row of asks) if (empty.has(String(row["productId"]))) out.set(String(row["id"]), "no initiative is open now: the ones it asked about are all done or canceled");
|
|
5467
|
+
return unchecked;
|
|
5468
|
+
}
|
|
5358
5469
|
/** Pages read for the summary; past this it says it counted only those. */
|
|
5359
5470
|
const SUMMARY_PAGE = 100;
|
|
5360
5471
|
const SETUP_TASK_FIELDS = [
|
|
@@ -5480,7 +5591,7 @@ function registerInboxTools(server, client) {
|
|
|
5480
5591
|
}), result.nextCursor),
|
|
5481
5592
|
...totalOf(result),
|
|
5482
5593
|
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
5483
|
-
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked}
|
|
5594
|
+
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
|
|
5484
5595
|
});
|
|
5485
5596
|
}));
|
|
5486
5597
|
server.registerTool("taiga_list_setup_tasks", {
|
|
@@ -20767,6 +20878,20 @@ function interleave(lists) {
|
|
|
20767
20878
|
for (let index = 0; index < longest; index++) for (const list of lists) if (index < list.length) out.push(list[index]);
|
|
20768
20879
|
return out;
|
|
20769
20880
|
}
|
|
20881
|
+
/**
|
|
20882
|
+
* A product in setup says what it still needs after a repository write, as create_product and get_product do
|
|
20883
|
+
* (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
|
|
20884
|
+
* is done, so a failed read leaves readiness out rather than failing it.
|
|
20885
|
+
*/
|
|
20886
|
+
async function readinessAfter(client, productId) {
|
|
20887
|
+
try {
|
|
20888
|
+
const readiness = await setupReadiness(client, (await client.getProduct(productId)).data);
|
|
20889
|
+
return readiness ? { readiness } : {};
|
|
20890
|
+
} catch (error) {
|
|
20891
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return {};
|
|
20892
|
+
throw error;
|
|
20893
|
+
}
|
|
20894
|
+
}
|
|
20770
20895
|
function registerRepositoryTools(server, client) {
|
|
20771
20896
|
if (client.hasCapability("products:read")) {
|
|
20772
20897
|
server.registerTool("taiga_list_product_repositories", {
|
|
@@ -20854,10 +20979,13 @@ function registerRepositoryTools(server, client) {
|
|
|
20854
20979
|
}),
|
|
20855
20980
|
annotations: OPERATE
|
|
20856
20981
|
}, (params) => runTool(async () => {
|
|
20857
|
-
return toolResult(
|
|
20858
|
-
|
|
20859
|
-
|
|
20860
|
-
|
|
20982
|
+
return toolResult({
|
|
20983
|
+
...pickFields([(await client.linkRepository(params.productId, {
|
|
20984
|
+
githubRepositoryId: params.githubRepositoryId,
|
|
20985
|
+
...params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}
|
|
20986
|
+
})).data], LINK_FIELDS)[0],
|
|
20987
|
+
...await readinessAfter(client, params.productId)
|
|
20988
|
+
});
|
|
20861
20989
|
}));
|
|
20862
20990
|
server.registerTool("taiga_set_repository_branch", {
|
|
20863
20991
|
title: "Choose the branch Taiga works on",
|
|
@@ -20877,7 +21005,8 @@ function registerRepositoryTools(server, client) {
|
|
|
20877
21005
|
})).data;
|
|
20878
21006
|
return toolResult({
|
|
20879
21007
|
...pickFields([updated], LINK_FIELDS)[0],
|
|
20880
|
-
baseBranchProtection: updated.baseBranchProtection ?? null
|
|
21008
|
+
baseBranchProtection: updated.baseBranchProtection ?? null,
|
|
21009
|
+
...await readinessAfter(client, params.productId)
|
|
20881
21010
|
});
|
|
20882
21011
|
}));
|
|
20883
21012
|
server.registerTool("taiga_unlink_repository", {
|
|
@@ -21082,8 +21211,13 @@ async function firstNamed(name, read) {
|
|
|
21082
21211
|
}
|
|
21083
21212
|
return null;
|
|
21084
21213
|
}
|
|
21085
|
-
|
|
21086
|
-
|
|
21214
|
+
/**
|
|
21215
|
+
* The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
|
|
21216
|
+
* "use it" would hand the agent someone else's work (PRO-2800, review 6).
|
|
21217
|
+
*/
|
|
21218
|
+
function duplicateName(kind, existing, where) {
|
|
21219
|
+
const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
|
|
21220
|
+
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).`, {
|
|
21087
21221
|
existingId: existing.id,
|
|
21088
21222
|
...existing.slug ? { existingSlug: existing.slug } : {}
|
|
21089
21223
|
});
|
|
@@ -21131,7 +21265,7 @@ function registerSetupTools(server, client) {
|
|
|
21131
21265
|
limit: 100,
|
|
21132
21266
|
cursor
|
|
21133
21267
|
}));
|
|
21134
|
-
if (same) return duplicateName("factory", same);
|
|
21268
|
+
if (same) return duplicateName("factory", same, "in this organization");
|
|
21135
21269
|
}
|
|
21136
21270
|
return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
|
|
21137
21271
|
}));
|
|
@@ -21185,7 +21319,7 @@ function registerSetupTools(server, client) {
|
|
|
21185
21319
|
if (!client.hasCapability("products:write")) return;
|
|
21186
21320
|
server.registerTool("taiga_create_product", {
|
|
21187
21321
|
title: "Create a product",
|
|
21188
|
-
description: "Create a product in a factory
|
|
21322
|
+
description: "Create a product in a factory and return it with readiness (what it needs next). start cannot change later. import (brownfield): Taiga drafts the documents from existing code; link it with taiga_link_repository (owners and admins only), then taiga_start_import. scratch (greenfield): Discovery is a conversation in the web that writes the specification. Infrastructure code and CI/CD pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.",
|
|
21189
21323
|
inputSchema: z.object({
|
|
21190
21324
|
factoryId,
|
|
21191
21325
|
name: z.string().trim().min(1).max(255).describe("The product name"),
|
|
@@ -21200,10 +21334,11 @@ function registerSetupTools(server, client) {
|
|
|
21200
21334
|
if (contradiction) return contradiction;
|
|
21201
21335
|
if (!params.allowDuplicateName) {
|
|
21202
21336
|
const same = await firstNamed(params.name, async (cursor) => client.listProducts({
|
|
21337
|
+
factoryId: params.factoryId,
|
|
21203
21338
|
limit: 100,
|
|
21204
21339
|
cursor
|
|
21205
21340
|
}));
|
|
21206
|
-
if (same) return duplicateName("product", same);
|
|
21341
|
+
if (same) return duplicateName("product", same, "in this factory");
|
|
21207
21342
|
}
|
|
21208
21343
|
const product = (await client.createProduct({
|
|
21209
21344
|
name: params.name,
|
|
@@ -21632,14 +21767,14 @@ function registerWaitTools(server, client, options) {
|
|
|
21632
21767
|
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
21633
21768
|
* registered without it.
|
|
21634
21769
|
*/
|
|
21635
|
-
function withStrictInputs(server, registered) {
|
|
21770
|
+
function withStrictInputs(server, client, registered) {
|
|
21636
21771
|
const register = server.registerTool.bind(server);
|
|
21637
21772
|
server.registerTool = ((name, config, callback) => {
|
|
21638
21773
|
registered.set(name, config.annotations);
|
|
21639
21774
|
const schema = config.inputSchema;
|
|
21640
21775
|
if (schema !== void 0 && !(schema instanceof z.ZodObject)) throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
|
|
21641
21776
|
const handler = callback;
|
|
21642
|
-
const withHint = (args, ctx) => notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx));
|
|
21777
|
+
const withHint = async (args, ctx) => withIdKind(client, args, await notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx)));
|
|
21643
21778
|
const described = {
|
|
21644
21779
|
...config,
|
|
21645
21780
|
description: `${writeNotice(name, config.annotations)}${config.description ?? ""}`,
|
|
@@ -21653,6 +21788,42 @@ function withStrictInputs(server, registered) {
|
|
|
21653
21788
|
return server;
|
|
21654
21789
|
}
|
|
21655
21790
|
/**
|
|
21791
|
+
* A product the API does not know may be a factory's ID passed as one (PRO-2800): the hint can only say a product
|
|
21792
|
+
* ID is wanted, so on that miss alone one read names what the ID is.
|
|
21793
|
+
*/
|
|
21794
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21795
|
+
async function withIdKind(client, args, result) {
|
|
21796
|
+
const productId = typeof args === "object" && args !== null ? args["productId"] : null;
|
|
21797
|
+
const answer = result;
|
|
21798
|
+
const first = answer?.isError ? answer.content?.[0] : void 0;
|
|
21799
|
+
if (typeof productId !== "string" || first?.type !== "text") return result;
|
|
21800
|
+
let body;
|
|
21801
|
+
try {
|
|
21802
|
+
body = JSON.parse(first.text);
|
|
21803
|
+
} catch {
|
|
21804
|
+
return result;
|
|
21805
|
+
}
|
|
21806
|
+
if (body.error !== "NOT_FOUND" || !/\b(product|project) not found|No product with that ID/i.test(String(body.message))) return result;
|
|
21807
|
+
if (!UUID.test(productId) || !client.hasCapability("factories:read")) return result;
|
|
21808
|
+
try {
|
|
21809
|
+
const factory = (await client.getFactory(productId)).data;
|
|
21810
|
+
const message = `${String(body.message)} That ID is the factory "${factory.name}", not a product: taiga_list_products with factoryId lists its products.`;
|
|
21811
|
+
return {
|
|
21812
|
+
...answer,
|
|
21813
|
+
content: [{
|
|
21814
|
+
type: "text",
|
|
21815
|
+
text: JSON.stringify({
|
|
21816
|
+
...body,
|
|
21817
|
+
message
|
|
21818
|
+
})
|
|
21819
|
+
}, ...answer.content.slice(1)]
|
|
21820
|
+
};
|
|
21821
|
+
} catch (error) {
|
|
21822
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return result;
|
|
21823
|
+
throw error;
|
|
21824
|
+
}
|
|
21825
|
+
}
|
|
21826
|
+
/**
|
|
21656
21827
|
* The entry points, loaded with their descriptions at session start
|
|
21657
21828
|
* (PRO-2800). Claude Code defers every other MCP tool behind tool search and
|
|
21658
21829
|
* shows the model only its name until it searches, so an agent meeting Taiga
|
|
@@ -21712,7 +21883,7 @@ const PAID_WORK = /* @__PURE__ */ new Set([
|
|
|
21712
21883
|
*/
|
|
21713
21884
|
function registerAllTools(server, client, options = {}) {
|
|
21714
21885
|
const registered = /* @__PURE__ */ new Map();
|
|
21715
|
-
withStrictInputs(server, registered);
|
|
21886
|
+
withStrictInputs(server, client, registered);
|
|
21716
21887
|
registerProductTools(server, client);
|
|
21717
21888
|
registerDocumentTools(server, client);
|
|
21718
21889
|
registerBacklogTools(server, client);
|
|
@@ -21783,7 +21954,7 @@ const SERVER_INSTRUCTIONS = [
|
|
|
21783
21954
|
"**Which read.** Where a product stands: taiga_get_product_context (by git remote URL, slug or ID). What waits on",
|
|
21784
21955
|
"people: taiga_list_action_required. A policy: taiga_get_document (sections: [] for the outline, or a control id",
|
|
21785
21956
|
"such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative, taiga_get_run.",
|
|
21786
|
-
"This key's role and tools: taiga_whoami.",
|
|
21957
|
+
"This key's role and which tools write: taiga_whoami.",
|
|
21787
21958
|
"",
|
|
21788
21959
|
"**Words.** An initiative is a unit of work; its board columns are backlog, todo, queue, plan, build, done, canceled. The",
|
|
21789
21960
|
"line is the product's queue, worked one initiative at a time through plan and build. A run is one attempt by the",
|
|
@@ -21795,8 +21966,9 @@ const SERVER_INSTRUCTIONS = [
|
|
|
21795
21966
|
"(derived from the code). outcome, situation and nextStep are one-sentence summaries of their record.",
|
|
21796
21967
|
"",
|
|
21797
21968
|
"**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others see",
|
|
21798
|
-
"it).\"; before an ask-first tool, tell the person what you will do and wait for their yes.
|
|
21799
|
-
"product name is refused unless allowDuplicateName
|
|
21969
|
+
"it).\"; before an ask-first tool, tell the person what you will do and wait for their yes. Others share this",
|
|
21970
|
+
"organization: a taken factory name (or product name in its factory) is refused unless allowDuplicateName, and",
|
|
21971
|
+
"the one that exists may not be this person's.",
|
|
21800
21972
|
"",
|
|
21801
21973
|
"**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from",
|
|
21802
21974
|
"scratch, stamping checks, starting a run over, and deleting factories and products."
|
package/package.json
CHANGED