@taiga-ai/mcp-server 0.19.0-rc.452 → 0.20.0-dev.454
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +479 -240
- package/package.json +1 -1
- package/src/server.integration.test.ts +345 -33
- package/src/tools/autonomy.ts +1 -1
- package/src/tools/backlogs.ts +14 -5
- package/src/tools/builds.ts +98 -11
- package/src/tools/catalogue.test.ts +1 -1
- package/src/tools/context.ts +45 -28
- package/src/tools/errors.ts +14 -2
- package/src/tools/inbox.ts +92 -11
- package/src/tools/index.ts +54 -4
- package/src/tools/instructions.ts +14 -9
- package/src/tools/knowledge.ts +1 -1
- package/src/tools/maintenance.ts +34 -9
- package/src/tools/observe.ts +3 -3
- package/src/tools/product-documents.ts +1 -1
- package/src/tools/products.ts +47 -5
- package/src/tools/queue.ts +7 -1
- package/src/tools/readiness.ts +11 -6
- package/src/tools/repositories.ts +2 -2
- package/src/tools/sections.ts +32 -2
- package/src/tools/setup.ts +2 -2
- package/src/tools/wait.ts +6 -4
- package/taiga-ai-mcp-server-0.20.0-dev.454.tgz +0 -0
- package/taiga-ai-mcp-server-0.19.0-rc.452.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -2284,7 +2284,8 @@ function withStop(text) {
|
|
|
2284
2284
|
*/
|
|
2285
2285
|
const PRODUCT_WORDS = {
|
|
2286
2286
|
backlogItemId: "initiativeId",
|
|
2287
|
-
buildRunId: "runId"
|
|
2287
|
+
buildRunId: "runId",
|
|
2288
|
+
latestBuild: "latestRun"
|
|
2288
2289
|
};
|
|
2289
2290
|
/**
|
|
2290
2291
|
* Maps whose keys are content, not API fields: a document's sections are keyed
|
|
@@ -2310,6 +2311,10 @@ function inProductWords(value) {
|
|
|
2310
2311
|
});
|
|
2311
2312
|
return out;
|
|
2312
2313
|
}
|
|
2314
|
+
/** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
|
|
2315
|
+
function inProductWording(said) {
|
|
2316
|
+
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");
|
|
2317
|
+
}
|
|
2313
2318
|
/** A successful tool result: one JSON text block the model reads. */
|
|
2314
2319
|
function toolResult(value) {
|
|
2315
2320
|
return { content: [{
|
|
@@ -2390,7 +2395,7 @@ async function runTool(handler) {
|
|
|
2390
2395
|
if (error instanceof TaigaApiError) {
|
|
2391
2396
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? "API_ERROR";
|
|
2392
2397
|
const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
|
|
2393
|
-
return toolError(code, error.status === 404 ? withNotFoundHint(said) : said, error.details);
|
|
2398
|
+
return toolError(code, error.status === 404 ? withNotFoundHint(inProductWording(said)) : said, error.details);
|
|
2394
2399
|
}
|
|
2395
2400
|
if (isNetworkFailure(error)) return toolError("NETWORK_ERROR", `Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`);
|
|
2396
2401
|
throw error;
|
|
@@ -2581,7 +2586,26 @@ function budgetSections(doc, budget) {
|
|
|
2581
2586
|
* The `sections` input of a document read: some sections by id, none for the
|
|
2582
2587
|
* outline (every id with its size and no text), or omitted for the whole.
|
|
2583
2588
|
*/
|
|
2584
|
-
const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().describe("Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document");
|
|
2589
|
+
const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().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");
|
|
2590
|
+
/** A policy control id as the standing cites it: SEC-5.1, SDLC-3.1, GDPR-2.3. */
|
|
2591
|
+
const CONTROL_ID = /^[A-Z][A-Z0-9]*-\d+(\.\d+)*$/;
|
|
2592
|
+
/**
|
|
2593
|
+
* The sections that state each control id asked for (PRO-2800): policy
|
|
2594
|
+
* standing cites SEC-5.1, and the section holding it has an id of its own
|
|
2595
|
+
* (secretsManagement) an agent could only guess. A control id is found in
|
|
2596
|
+
* the sections' text, matched as a whole id so SEC-5.1 is not SEC-5.10.
|
|
2597
|
+
*/
|
|
2598
|
+
function controlSections(sections, ids) {
|
|
2599
|
+
const found = {};
|
|
2600
|
+
for (const id of ids) {
|
|
2601
|
+
if (!CONTROL_ID.test(id) || Object.hasOwn(sections, id)) continue;
|
|
2602
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2603
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9.-])${escaped}(?![A-Za-z0-9-]|\\.[A-Za-z0-9])`);
|
|
2604
|
+
const holders = Object.keys(sections).filter((sectionId) => pattern.test(sectionContent(sections, sectionId) ?? ""));
|
|
2605
|
+
if (holders.length > 0) found[id] = holders;
|
|
2606
|
+
}
|
|
2607
|
+
return found;
|
|
2608
|
+
}
|
|
2585
2609
|
/**
|
|
2586
2610
|
* The answer to a read that names sections: the ones the document has, the
|
|
2587
2611
|
* rest listed with their sizes, and the asked-for ids it lacks under
|
|
@@ -2590,12 +2614,17 @@ const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().des
|
|
|
2590
2614
|
* real id with its size, so the retry needs nothing else. No ids is the
|
|
2591
2615
|
* outline.
|
|
2592
2616
|
*/
|
|
2593
|
-
function sectionReadResult(doc,
|
|
2617
|
+
function sectionReadResult(doc, asked, label) {
|
|
2618
|
+
const before = sectionsOf(doc.data);
|
|
2619
|
+
const controls = before ? controlSections(before, asked) : {};
|
|
2620
|
+
const ids = [.../* @__PURE__ */ new Set([...asked.filter((id) => !Object.hasOwn(controls, id)), ...Object.values(controls).flat()])];
|
|
2594
2621
|
const cut = pickSections(doc, new Set(ids));
|
|
2595
2622
|
if (!cut) return toolError("VALIDATION_ERROR", `The ${label} is not made of sections; read it without sections.`);
|
|
2596
2623
|
const all = Object.keys(sectionsOf(doc.data) ?? {});
|
|
2597
2624
|
const unknownSections = ids.filter((id) => !all.includes(id));
|
|
2598
|
-
|
|
2625
|
+
const known = ids.filter((id) => all.includes(id));
|
|
2626
|
+
const found = Object.keys(controls).length > 0 ? { controls } : {};
|
|
2627
|
+
if (known.length === 0) {
|
|
2599
2628
|
const { data: _data, ...meta } = cut.doc;
|
|
2600
2629
|
return toolResult({
|
|
2601
2630
|
...meta,
|
|
@@ -2605,6 +2634,7 @@ function sectionReadResult(doc, ids, label) {
|
|
|
2605
2634
|
}
|
|
2606
2635
|
return toolResult({
|
|
2607
2636
|
...cut.doc,
|
|
2637
|
+
...found,
|
|
2608
2638
|
omittedSections: cut.omittedSections,
|
|
2609
2639
|
...unknownSections.length > 0 ? { unknownSections } : {}
|
|
2610
2640
|
});
|
|
@@ -2628,12 +2658,12 @@ function named(types) {
|
|
|
2628
2658
|
return types.map((type) => PRODUCT_DOCUMENT_NAMES[type] ?? type).join(", ");
|
|
2629
2659
|
}
|
|
2630
2660
|
/** The product's setup readiness, or null once it has finished setup. */
|
|
2631
|
-
async function setupReadiness(client, product) {
|
|
2661
|
+
async function setupReadiness(client, product, links) {
|
|
2632
2662
|
if (product["status"] !== "draft") return null;
|
|
2633
2663
|
const id = String(product["id"]);
|
|
2634
2664
|
const imported = isImportedProduct(product);
|
|
2635
2665
|
const [repos, blockers, full] = await Promise.all([
|
|
2636
|
-
client.listProductRepositories(id).then((result) => result.data, (error) => {
|
|
2666
|
+
(links ? links() : client.listProductRepositories(id)).then((result) => result.data, (error) => {
|
|
2637
2667
|
if (isApiSide(error)) return null;
|
|
2638
2668
|
throw error;
|
|
2639
2669
|
}),
|
|
@@ -2659,9 +2689,9 @@ async function setupReadiness(client, product) {
|
|
|
2659
2689
|
return {
|
|
2660
2690
|
ready: blockers ? missing.length === 0 && openChecks.length === 0 : null,
|
|
2661
2691
|
repository,
|
|
2662
|
-
...
|
|
2692
|
+
...imported ? { import: typeof settings.importStatus === "string" ? settings.importStatus : "pending" } : {},
|
|
2663
2693
|
...imported ? {} : { discoveryStep: full["discoveryStep"] ?? null },
|
|
2664
|
-
missingDocuments: missing
|
|
2694
|
+
missingDocuments: missing,
|
|
2665
2695
|
openChecks,
|
|
2666
2696
|
nextStep: nextStepFor({
|
|
2667
2697
|
imported,
|
|
@@ -2787,6 +2817,41 @@ const PRODUCT_SUMMARY_FIELDS = [
|
|
|
2787
2817
|
"factoryId",
|
|
2788
2818
|
"discoveryStep"
|
|
2789
2819
|
];
|
|
2820
|
+
/**
|
|
2821
|
+
* Product rows with their factory's name beside its id (PRO-2800): "what
|
|
2822
|
+
* products does the organization have" needed a second list to name the
|
|
2823
|
+
* factories. One factories read; without factories:read, or when it fails,
|
|
2824
|
+
* the rows keep the id alone.
|
|
2825
|
+
*/
|
|
2826
|
+
async function withFactoryNames(client, rows) {
|
|
2827
|
+
if (!client.hasCapability("factories:read") || rows.length === 0) return rows;
|
|
2828
|
+
try {
|
|
2829
|
+
const wanted = new Set(rows.map((row) => String(row.factoryId)));
|
|
2830
|
+
const names = /* @__PURE__ */ new Map();
|
|
2831
|
+
let cursor;
|
|
2832
|
+
for (let pageIndex = 0; pageIndex < FACTORY_PAGES; pageIndex += 1) {
|
|
2833
|
+
const result = await client.listFactories({
|
|
2834
|
+
limit: 100,
|
|
2835
|
+
cursor
|
|
2836
|
+
});
|
|
2837
|
+
for (const factory of result.data) names.set(factory.id, factory.name);
|
|
2838
|
+
if (!result.nextCursor || [...wanted].every((id) => names.has(id))) break;
|
|
2839
|
+
cursor = result.nextCursor;
|
|
2840
|
+
}
|
|
2841
|
+
return rows.map((row) => {
|
|
2842
|
+
const name = names.get(String(row.factoryId));
|
|
2843
|
+
return name === void 0 ? row : {
|
|
2844
|
+
...row,
|
|
2845
|
+
factoryName: name
|
|
2846
|
+
};
|
|
2847
|
+
});
|
|
2848
|
+
} catch (error) {
|
|
2849
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return rows;
|
|
2850
|
+
throw error;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
/** Factory pages read to name products' factories. */
|
|
2854
|
+
const FACTORY_PAGES = 10;
|
|
2790
2855
|
/** A list's name search: part of a name, any case. */
|
|
2791
2856
|
const query = (what) => z.string().trim().min(1).max(200).optional().describe(`Part of the ${what}, any case: only the rows that contain it`);
|
|
2792
2857
|
/**
|
|
@@ -2987,7 +3052,7 @@ function registerProductTools(server, client) {
|
|
|
2987
3052
|
if (client.hasCapability("products:read")) {
|
|
2988
3053
|
server.registerTool("taiga_list_products", {
|
|
2989
3054
|
title: "List products",
|
|
2990
|
-
description: `List products in the organization, as summaries (id, name, slug, status,
|
|
3055
|
+
description: `List products in the organization, as summaries (id, name, slug, status, factoryId and factoryName, discoveryStep). Use this to find a product by name (query); inside a git checkout, taiga_resolve_product finds it from the remote URL instead. taiga_get_product reads one in full. ${DISCOVERY_STEP_MEANING}`,
|
|
2991
3056
|
inputSchema: z.object({
|
|
2992
3057
|
query: query("product name or slug"),
|
|
2993
3058
|
factoryId: idInput("factory", "taiga_list_factories (query finds one by name) gives it").optional().describe("Only the products of this factory"),
|
|
@@ -2999,7 +3064,7 @@ function registerProductTools(server, client) {
|
|
|
2999
3064
|
const { query: text, ...filters } = params;
|
|
3000
3065
|
if (text === void 0) {
|
|
3001
3066
|
const result = await client.listProducts(filters);
|
|
3002
|
-
return toolResult(page(pickFields(result.data, PRODUCT_SUMMARY_FIELDS), result.nextCursor));
|
|
3067
|
+
return toolResult(page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor));
|
|
3003
3068
|
}
|
|
3004
3069
|
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", SEARCH_WITH_CURSOR);
|
|
3005
3070
|
const found = await searchPages((cursor) => client.listProducts({
|
|
@@ -3009,13 +3074,13 @@ function registerProductTools(server, client) {
|
|
|
3009
3074
|
cursor
|
|
3010
3075
|
}), text, (row) => [row.name, row.slug], params.limit ?? 25);
|
|
3011
3076
|
return toolResult({
|
|
3012
|
-
...page(pickFields(found.rows, PRODUCT_SUMMARY_FIELDS), null),
|
|
3077
|
+
...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
|
|
3013
3078
|
...found.more ? { more: SEARCH_NOTE } : {}
|
|
3014
3079
|
});
|
|
3015
3080
|
}));
|
|
3016
3081
|
server.registerTool("taiga_get_product", {
|
|
3017
3082
|
title: "Get product",
|
|
3018
|
-
description: "
|
|
3083
|
+
description: "Read one product record and its settings, by ID or by slug: name, slug, factory, what Taiga owns for it (application, infrastructure, deployment) and its settings; the list and resolve tools carry summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use taiga_list_product_documents for its specification and design, taiga_list_initiatives for its work and taiga_list_runs for what the coding agent did. A product still in setup carries readiness: whether it can finish setup now, its repository and import, the required documents not yet published, the checks not yet stamped, and nextStep, the one move that gets it closer.",
|
|
3019
3084
|
inputSchema: z.object({
|
|
3020
3085
|
productId: productId.optional(),
|
|
3021
3086
|
slug: slug.optional()
|
|
@@ -3409,163 +3474,6 @@ function requestPrompt(title, description) {
|
|
|
3409
3474
|
return [(title ?? "").trim(), body].filter(Boolean).join("\n\n") || void 0;
|
|
3410
3475
|
}
|
|
3411
3476
|
//#endregion
|
|
3412
|
-
//#region src/tools/queue.ts
|
|
3413
|
-
/** How many entries the read names; past this an entry keeps its initiativeId only. */
|
|
3414
|
-
const NAMED_ENTRIES = 25;
|
|
3415
|
-
const MOVE_ASIDE = "or move it down (taiga_queue_initiative with afterId) or off the line (taiga_move_initiative) to let the rest pass.";
|
|
3416
|
-
/**
|
|
3417
|
-
* What lifts a halt, in the tools that do it. Read from the halt registry in
|
|
3418
|
-
* @taiga/types (how each cause clears, and whether a person may lift it), so
|
|
3419
|
-
* the advice cannot drift from what the API accepts; plan review is the one
|
|
3420
|
-
* cause a tool answers directly.
|
|
3421
|
-
*/
|
|
3422
|
-
function haltNextStep(cause) {
|
|
3423
|
-
if (cause === "awaiting_plan_review") return "Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).";
|
|
3424
|
-
const spec = cause ? HALT_CAUSES[cause] : void 0;
|
|
3425
|
-
if (!cause || !spec) return `Read the initiative with taiga_get_initiative to see why, ${MOVE_ASIDE}`;
|
|
3426
|
-
const known = cause;
|
|
3427
|
-
if (canResumeHalt(known)) return `taiga_resume_initiative lifts it, ${MOVE_ASIDE}`;
|
|
3428
|
-
if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
|
|
3429
|
-
return `Only taking it off the line clears it (taiga_move_initiative): ${spec.clearedWhen}.`;
|
|
3430
|
-
}
|
|
3431
|
-
/**
|
|
3432
|
-
* The entry the line waits on, by the queue's two kinds of blocker
|
|
3433
|
-
* (@taiga/types): an active entry holds every other one wherever it sits, so
|
|
3434
|
-
* it answers first; without one, the first halted entry holds the entries
|
|
3435
|
-
* behind it. -1 when neither is there.
|
|
3436
|
-
*/
|
|
3437
|
-
function blockingIndex(entries, stateOf) {
|
|
3438
|
-
const active = entries.findIndex((entry) => stateOf(entry) === "active");
|
|
3439
|
-
return active >= 0 ? active : entries.findIndex((entry) => stateOf(entry) === "halted");
|
|
3440
|
-
}
|
|
3441
|
-
/** The one sentence a person needs: what the line is doing, or what it is waiting on. */
|
|
3442
|
-
function waitingOn(rows) {
|
|
3443
|
-
const at = blockingIndex(rows, (row) => row.entryState);
|
|
3444
|
-
if (at < 0) return rows.length > 0 ? "Nothing in the queue is running; the first queued initiative starts when the line is free." : null;
|
|
3445
|
-
const head = rows[at];
|
|
3446
|
-
const name = typeof head.title === "string" ? `"${head.title}"` : `Initiative ${String(head.backlogItemId)}`;
|
|
3447
|
-
if (head.entryState === "halted") {
|
|
3448
|
-
const halt = head.halt;
|
|
3449
|
-
const ahead = at > 0 ? " Entries ahead of it are not held by it." : "";
|
|
3450
|
-
return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
|
|
3451
|
-
}
|
|
3452
|
-
const lifecycle = head.lifecycle;
|
|
3453
|
-
const held = "every other queued initiative waits until its pull request merges.";
|
|
3454
|
-
if (!lifecycle) return `${name} is the active entry; ${held}`;
|
|
3455
|
-
const pr = typeof head.prUrl === "string" ? ` (${head.prUrl})` : "";
|
|
3456
|
-
if (lifecycle.cta === "start_build") return `${name} has its plan and waits for the Build slot; ${held}`;
|
|
3457
|
-
if (lifecycle.turn === "you") return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
|
|
3458
|
-
return `${name} is ${String(lifecycle.stage)}${pr}; ${held}`;
|
|
3459
|
-
}
|
|
3460
|
-
/**
|
|
3461
|
-
* The product's queue as a person reads it: each live entry in order with its
|
|
3462
|
-
* initiative's title and where that initiative is, a halted entry with why and
|
|
3463
|
-
* what lifts it, and one sentence saying what the line is waiting on. The
|
|
3464
|
-
* route carries item ids only; each is named by one item read (when the key
|
|
3465
|
-
* may read the backlog), and a read the API fails leaves that entry unnamed
|
|
3466
|
-
* rather than failing the queue.
|
|
3467
|
-
*/
|
|
3468
|
-
async function queueView(client, product, options = { name: "all" }) {
|
|
3469
|
-
const [queue, paused] = await Promise.all([client.listQueue(product), linePausedSince(client, product)]);
|
|
3470
|
-
const entries = queue.data.filter((entry) => entry.state !== "settled");
|
|
3471
|
-
const canName = client.hasCapability("backlog:read");
|
|
3472
|
-
const blocking = blockingIndex(entries, (entry) => entry.state);
|
|
3473
|
-
const toName = !canName ? [] : options.name === "all" ? entries.slice(0, NAMED_ENTRIES).map((_, index) => index) : blocking >= 0 ? [blocking] : [];
|
|
3474
|
-
const settled = await Promise.allSettled(toName.map((index) => client.getBacklogItem(entries[index].backlogItemId)));
|
|
3475
|
-
const reads = new Map(toName.map((index, at) => [index, settled[at]]));
|
|
3476
|
-
let unnamed = options.name === "all" ? entries.length - toName.length : 0;
|
|
3477
|
-
const rows = entries.map((entry, index) => {
|
|
3478
|
-
const read = reads.get(index);
|
|
3479
|
-
if (read?.status === "rejected") {
|
|
3480
|
-
if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
|
|
3481
|
-
unnamed += 1;
|
|
3482
|
-
}
|
|
3483
|
-
const item = read?.status === "fulfilled" ? read.value.data : null;
|
|
3484
|
-
const lifecycle = item?.lifecycle;
|
|
3485
|
-
const latestBuild = lifecycle?.latestBuild;
|
|
3486
|
-
const row = {
|
|
3487
|
-
position: index + 1,
|
|
3488
|
-
backlogItemId: entry.backlogItemId,
|
|
3489
|
-
entryState: entry.state
|
|
3490
|
-
};
|
|
3491
|
-
if (item) Object.assign(row, {
|
|
3492
|
-
title: item.title,
|
|
3493
|
-
state: item.state,
|
|
3494
|
-
planningStatus: item.planningStatus,
|
|
3495
|
-
needsHumanCause: item.needsHumanCause,
|
|
3496
|
-
criteria: criteriaCounts(item),
|
|
3497
|
-
lifecycle: lifecycle ? {
|
|
3498
|
-
turn: lifecycle.turn,
|
|
3499
|
-
stage: lifecycle.stage,
|
|
3500
|
-
cta: lifecycle.cta
|
|
3501
|
-
} : null,
|
|
3502
|
-
...latestBuild ? {
|
|
3503
|
-
runId: latestBuild.id,
|
|
3504
|
-
prUrl: latestBuild.prUrl ?? null
|
|
3505
|
-
} : {}
|
|
3506
|
-
});
|
|
3507
|
-
if (entry.state === "halted") {
|
|
3508
|
-
const cause = typeof entry.haltCause === "string" ? entry.haltCause : null;
|
|
3509
|
-
row.halt = {
|
|
3510
|
-
cause,
|
|
3511
|
-
reason: (cause ? renderHalt({
|
|
3512
|
-
cause,
|
|
3513
|
-
params: entry.haltParams ?? {}
|
|
3514
|
-
}) : "") || (typeof entry.haltReason === "string" ? entry.haltReason : "The queue stopped here."),
|
|
3515
|
-
nextStep: haltNextStep(cause)
|
|
3516
|
-
};
|
|
3517
|
-
}
|
|
3518
|
-
return row;
|
|
3519
|
-
});
|
|
3520
|
-
const note = unnamed === 0 ? null : !canName ? "Entries carry initiativeId only: naming them needs backlog:read, which this key lacks." : entries.length > NAMED_ENTRIES ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry initiativeId only.` : "An entry whose initiative could not be read carries initiativeId only; taiga_get_initiative reads it.";
|
|
3521
|
-
const line = typeof paused === "string" ? `The line is paused (since ${paused}): nothing is planned or built until a person resumes it.` : null;
|
|
3522
|
-
return {
|
|
3523
|
-
linePaused: paused === null ? null : typeof paused === "string",
|
|
3524
|
-
entries: rows,
|
|
3525
|
-
waitingOn: line ?? waitingOn(rows),
|
|
3526
|
-
...note ? { note } : {}
|
|
3527
|
-
};
|
|
3528
|
-
}
|
|
3529
|
-
/**
|
|
3530
|
-
* When the product's line was paused, false when it runs, null when the
|
|
3531
|
-
* product cannot be read (the queue is still worth reading without it).
|
|
3532
|
-
*/
|
|
3533
|
-
async function linePausedSince(client, product) {
|
|
3534
|
-
try {
|
|
3535
|
-
const row = (await client.getProduct(product)).data;
|
|
3536
|
-
return typeof row.lineStoppedAt === "string" ? row.lineStoppedAt : false;
|
|
3537
|
-
} catch (error) {
|
|
3538
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
3539
|
-
throw error;
|
|
3540
|
-
}
|
|
3541
|
-
}
|
|
3542
|
-
function registerQueueTools(server, client) {
|
|
3543
|
-
if (!client.hasCapability("products:read")) return;
|
|
3544
|
-
server.registerTool("taiga_get_line", {
|
|
3545
|
-
title: "Get the product queue",
|
|
3546
|
-
description: "Read a product's queue, the line the coding agent works one initiative at a time: each entry in order with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why it stopped and what lifts it, linePaused (null when the product could not be read), and waitingOn, one sentence saying what the line is waiting for. The line advances only when the active entry's pull request merges, so an entry waiting on a review holds everything behind it. Use this when a queued initiative is not moving.",
|
|
3547
|
-
inputSchema: z.object({ productId }),
|
|
3548
|
-
annotations: READ_ONLY
|
|
3549
|
-
}, (params) => runTool(async () => toolResult(await queueView(client, params.productId))));
|
|
3550
|
-
if (!client.hasCapability("products:write")) return;
|
|
3551
|
-
server.registerTool("taiga_pause_line", {
|
|
3552
|
-
title: "Pause or resume a product's line",
|
|
3553
|
-
description: "Pause the product's whole line, or resume it. Pausing cancels the plan being written and stops the build in flight where it stands (both are flagged), and nothing more is planned or built; initiatives keep their places. Resuming plans the interrupted initiative again from scratch, carries the interrupted build on from its first incomplete step, and the line picks up where it was; a build you had stopped yourself before pausing stays stopped. Idempotent. Pausing interrupts work and resuming starts it: read taiga_get_line first to see what it would interrupt, and ask the person.",
|
|
3554
|
-
inputSchema: z.object({
|
|
3555
|
-
productId,
|
|
3556
|
-
paused: z.boolean().describe("true pauses the line, false resumes it")
|
|
3557
|
-
}),
|
|
3558
|
-
annotations: OPERATE_SETTING
|
|
3559
|
-
}, (params) => runTool(async () => {
|
|
3560
|
-
const row = (await client.setLineStopped(params.productId, params.paused)).data;
|
|
3561
|
-
return toolResult({
|
|
3562
|
-
productId: row.id,
|
|
3563
|
-
linePaused: typeof row.lineStoppedAt === "string",
|
|
3564
|
-
lineStoppedAt: row.lineStoppedAt ?? null
|
|
3565
|
-
});
|
|
3566
|
-
}));
|
|
3567
|
-
}
|
|
3568
|
-
//#endregion
|
|
3569
3477
|
//#region src/tools/builds.ts
|
|
3570
3478
|
/** What a list row needs to say for the model to pick a run to read in full. */
|
|
3571
3479
|
const BUILD_SUMMARY_FIELDS = [
|
|
@@ -3588,6 +3496,8 @@ const BUILD_SUMMARY_FIELDS = [
|
|
|
3588
3496
|
"prState",
|
|
3589
3497
|
"prDraft",
|
|
3590
3498
|
"prMerged",
|
|
3499
|
+
"reviewEscalation",
|
|
3500
|
+
"error",
|
|
3591
3501
|
"actions",
|
|
3592
3502
|
"createdAt",
|
|
3593
3503
|
"startedAt",
|
|
@@ -3604,7 +3514,10 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
3604
3514
|
"totalSteps",
|
|
3605
3515
|
"prNumber",
|
|
3606
3516
|
"prUrl",
|
|
3517
|
+
"prDraft",
|
|
3607
3518
|
"prMerged",
|
|
3519
|
+
"reviewEscalation",
|
|
3520
|
+
"error",
|
|
3608
3521
|
"actions"
|
|
3609
3522
|
];
|
|
3610
3523
|
/**
|
|
@@ -3642,6 +3555,46 @@ const RUNNING = {
|
|
|
3642
3555
|
ci_fixing: "fixing what CI found",
|
|
3643
3556
|
reviewing: "answering review comments on the pull request"
|
|
3644
3557
|
};
|
|
3558
|
+
/** Why the agent handed a run back, in the web's words (apps/web handback), with the checks it names. */
|
|
3559
|
+
function handbackSaid(escalation) {
|
|
3560
|
+
if (!escalation || typeof escalation !== "object") return null;
|
|
3561
|
+
const e = escalation;
|
|
3562
|
+
const checks = Array.isArray(e.failingChecks) ? e.failingChecks.map((check) => String(check.name)).join(", ") : "";
|
|
3563
|
+
const on = checks ? ` on ${checks}` : "";
|
|
3564
|
+
const rounds = typeof e.rounds === "number" && e.rounds > 0 ? ` after ${e.rounds} automatic round${e.rounds === 1 ? "" : "s"}` : "";
|
|
3565
|
+
const blockers = Array.isArray(e.blockers) ? e.blockers.map((b) => READY_BLOCKER_SAID[String(b)] ?? String(b)) : [];
|
|
3566
|
+
switch (e.reason) {
|
|
3567
|
+
case "ci_fix_cap": return `CI is still failing${on}${rounds}`;
|
|
3568
|
+
case "ci_fix_stalled": return `CI is still failing${on}, and the last fix round changed nothing`;
|
|
3569
|
+
case "conflict_cap": return `the branch still conflicts with its base${rounds}`;
|
|
3570
|
+
case "review_bot_cap": return `review bots still have open comments${rounds}`;
|
|
3571
|
+
case "ci_never_settled": return "CI did not finish in time";
|
|
3572
|
+
case "ready_blocked": return `CI is green, but Taiga kept the pull request a draft${blockers.length ? `: ${blockers.join("; ")}` : ""}`;
|
|
3573
|
+
case "check_weakened": return `CI went green only after a check was loosened${typeof e.file === "string" ? ` in ${e.file}` : ""}`;
|
|
3574
|
+
default: return typeof e.reason === "string" ? e.reason : null;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
/** What a person does about a handback, by its reason, as the web's handback detail says it. */
|
|
3578
|
+
function handbackNextStep(escalation) {
|
|
3579
|
+
const reason = escalation && typeof escalation === "object" ? escalation.reason : null;
|
|
3580
|
+
if (reason === "check_weakened") return "review the loosened check before merging, or review it back to the agent";
|
|
3581
|
+
if (reason === "ci_never_settled") return "look at what is still running or queued in CI, then review it back to the agent";
|
|
3582
|
+
return "fix it by hand or review it back to the agent";
|
|
3583
|
+
}
|
|
3584
|
+
/** The web's words for why Taiga keeps a pull request a draft. */
|
|
3585
|
+
const READY_BLOCKER_SAID = {
|
|
3586
|
+
plan_incomplete: "not every planned step shipped",
|
|
3587
|
+
tests_failed: "the tests Taiga ran after the build failed",
|
|
3588
|
+
bar_lowering: "a fix lowered a check instead of fixing the code",
|
|
3589
|
+
bar_lowering_unchecked: "Taiga could not check whether a fix lowered a check",
|
|
3590
|
+
criticals_unresolved: "Taiga's own review left a critical issue unresolved"
|
|
3591
|
+
};
|
|
3592
|
+
/** The first sentence of a run's error, short enough for a row. */
|
|
3593
|
+
function errorSaid(error) {
|
|
3594
|
+
if (typeof error !== "string" || !error.trim()) return null;
|
|
3595
|
+
const first = error.trim().split(/\n|(?<=[.!?])\s/)[0].replace(/[.!?]$/, "");
|
|
3596
|
+
return first.length > 240 ? `${first.slice(0, 240).trimEnd()}...` : first;
|
|
3597
|
+
}
|
|
3645
3598
|
/**
|
|
3646
3599
|
* How a run stands, in one sentence, with its disposition: the meaning the
|
|
3647
3600
|
* status and the pull request facts carry between them
|
|
@@ -3655,34 +3608,41 @@ function runOutcome(run) {
|
|
|
3655
3608
|
const disposition = landedDisposition(status, run["prMerged"] === true);
|
|
3656
3609
|
const pr = typeof run["prNumber"] === "number" ? `PR #${run["prNumber"]}` : "its pull request";
|
|
3657
3610
|
const progress = typeof run["completedSteps"] === "number" && typeof run["totalSteps"] === "number" ? ` (${run["completedSteps"]} of ${run["totalSteps"]} steps done)` : "";
|
|
3658
|
-
const
|
|
3611
|
+
const why = errorSaid(run["error"]) ?? (typeof run["failedReason"] === "string" ? run["failedReason"] : null);
|
|
3612
|
+
const handback = handbackSaid(run["reviewEscalation"]);
|
|
3613
|
+
const draft = run["prDraft"] === true ? `, still a draft` : "";
|
|
3659
3614
|
return {
|
|
3660
3615
|
disposition,
|
|
3661
3616
|
outcome: {
|
|
3662
3617
|
in_flight: status === "pending" ? "Queued: waiting to start." : `Running: ${RUNNING[status] ?? status}${progress}.`,
|
|
3663
|
-
gated: `Waiting for a person to review and merge ${pr}.`,
|
|
3618
|
+
gated: handback ? `Handed back to a person: ${handback}. ${pr}${draft ? `${draft},` : ""} waits for them to ${handbackNextStep(run["reviewEscalation"])}.` : `Waiting for a person to review and merge ${pr}${draft}.`,
|
|
3664
3619
|
succeeded: `Merged: ${pr} is in ${typeof run["baseBranch"] === "string" ? run["baseBranch"] : "the base branch"}.`,
|
|
3665
3620
|
unmerged: typeof run["prNumber"] === "number" || run["prUrl"] ? `Settled without merging: ${pr} was left unmerged.` : "Settled without a merged pull request.",
|
|
3666
|
-
failed: `Failed${
|
|
3621
|
+
failed: `Failed${why ? `: ${why}` : ""}${progress}.`,
|
|
3667
3622
|
stopped: `Stopped before it finished${progress}.`,
|
|
3668
|
-
rejected: `${pr} was closed without merging, so the work did not land.`
|
|
3623
|
+
rejected: `${pr} was closed without merging, so the work did not land.${handback ? ` It had been handed back: ${handback}.` : ""}`
|
|
3669
3624
|
}[disposition] ?? status
|
|
3670
3625
|
};
|
|
3671
3626
|
}
|
|
3672
3627
|
/**
|
|
3673
3628
|
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
3674
3629
|
* there is a pull request. The column defaults to true, so a run that never
|
|
3675
|
-
* opened one said it had a draft (PRO-2800).
|
|
3630
|
+
* opened one said it had a draft (PRO-2800). A list row leaves the full error
|
|
3631
|
+
* text out: its first sentence is in the outcome, and taiga_get_run has it all.
|
|
3676
3632
|
*/
|
|
3677
|
-
function readableRun(run) {
|
|
3678
|
-
const { prDraft, ...rest } = run;
|
|
3633
|
+
function readableRun(run, opts = {}) {
|
|
3634
|
+
const { prDraft, error, reviewEscalation, ...rest } = run;
|
|
3679
3635
|
const said = runOutcome(run);
|
|
3680
3636
|
return {
|
|
3681
3637
|
...rest,
|
|
3638
|
+
...opts.row || error === void 0 ? {} : { error },
|
|
3639
|
+
...opts.row || reviewEscalation === void 0 ? {} : { reviewEscalation },
|
|
3682
3640
|
...typeof rest["prNumber"] === "number" || rest["prUrl"] ? { prDraft } : {},
|
|
3683
3641
|
...said ?? {}
|
|
3684
3642
|
};
|
|
3685
3643
|
}
|
|
3644
|
+
/** readableRun for a list row. */
|
|
3645
|
+
const readableRow = (run) => readableRun(run, { row: true });
|
|
3686
3646
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
3687
3647
|
const NAMED_PRODUCTS = 100;
|
|
3688
3648
|
const runId = idInput("run", "taiga_list_runs gives it");
|
|
@@ -3726,7 +3686,7 @@ async function activityRows(client, opts) {
|
|
|
3726
3686
|
name: product.name,
|
|
3727
3687
|
slug: product.slug
|
|
3728
3688
|
});
|
|
3729
|
-
const rows = pickFields(runs.data, BUILD_SUMMARY_FIELDS).map(
|
|
3689
|
+
const rows = pickFields(runs.data, BUILD_SUMMARY_FIELDS).map(readableRow).map((row) => {
|
|
3730
3690
|
const product = nameById.get(String(row["productId"]));
|
|
3731
3691
|
return product ? {
|
|
3732
3692
|
...row,
|
|
@@ -3760,13 +3720,13 @@ function registerBuildTools(server, client) {
|
|
|
3760
3720
|
limit: params.limit,
|
|
3761
3721
|
cursor: params.cursor
|
|
3762
3722
|
});
|
|
3763
|
-
return toolResult(page(pickFields(result.data, BUILD_SUMMARY_FIELDS).map(
|
|
3723
|
+
return toolResult(page(pickFields(result.data, BUILD_SUMMARY_FIELDS).map(readableRow), result.nextCursor));
|
|
3764
3724
|
}
|
|
3765
3725
|
return toolResult(await activityRows(client, params));
|
|
3766
3726
|
}));
|
|
3767
3727
|
server.registerTool("taiga_get_run", {
|
|
3768
3728
|
title: "Get run",
|
|
3769
|
-
description: "
|
|
3729
|
+
description: "Read one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and each step's title, status, commit, files changed, verification and headline, the first paragraph of what the agent reported. A step's verification is Taiga's own check after the step (tests, lint, build), independent of what the agent says it ran: a docs-only step can show tests failed because the repository's tests fail. A run that failed or was stopped says where (stoppedIn) and when it last made progress (lastProgressAt). Pass full: true for each step's complete instructions and report, several thousand tokens on a long run. Its actions name what the initiative accepts: resume and retry_from_failed_step are taiga_resume_initiative; start_over is in the web only; to stop it, move the initiative out of Build. A maintenance run has no initiative and is retried from its finding.",
|
|
3770
3730
|
inputSchema: z.object({
|
|
3771
3731
|
runId,
|
|
3772
3732
|
full: z.boolean().optional().describe("Carry each step's complete instructions and report (default false: a summary)")
|
|
@@ -3799,7 +3759,170 @@ function registerBuildTools(server, client) {
|
|
|
3799
3759
|
inputSchema: z.object({ runId }),
|
|
3800
3760
|
annotations: OPERATE
|
|
3801
3761
|
}, (params) => runTool(async () => {
|
|
3802
|
-
|
|
3762
|
+
const result = await client.stopBuildRun(params.runId);
|
|
3763
|
+
return toolResult(readableRow(pickFields([result.data], RUN_OUTCOME_FIELDS)[0]));
|
|
3764
|
+
}));
|
|
3765
|
+
}
|
|
3766
|
+
//#endregion
|
|
3767
|
+
//#region src/tools/queue.ts
|
|
3768
|
+
/** How many entries the read names; past this an entry keeps its initiativeId only. */
|
|
3769
|
+
const NAMED_ENTRIES = 25;
|
|
3770
|
+
const MOVE_ASIDE = "or move it down (taiga_queue_initiative with afterId) or off the line (taiga_move_initiative) to let the rest pass.";
|
|
3771
|
+
/**
|
|
3772
|
+
* What lifts a halt, in the tools that do it. Read from the halt registry in
|
|
3773
|
+
* @taiga/types (how each cause clears, and whether a person may lift it), so
|
|
3774
|
+
* the advice cannot drift from what the API accepts; plan review is the one
|
|
3775
|
+
* cause a tool answers directly.
|
|
3776
|
+
*/
|
|
3777
|
+
function haltNextStep(cause) {
|
|
3778
|
+
if (cause === "awaiting_plan_review") return "Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).";
|
|
3779
|
+
const spec = cause ? HALT_CAUSES[cause] : void 0;
|
|
3780
|
+
if (!cause || !spec) return `Read the initiative with taiga_get_initiative to see why, ${MOVE_ASIDE}`;
|
|
3781
|
+
const known = cause;
|
|
3782
|
+
if (canResumeHalt(known)) return `taiga_resume_initiative lifts it, ${MOVE_ASIDE}`;
|
|
3783
|
+
if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
|
|
3784
|
+
return `Only taking it off the line clears it (taiga_move_initiative): ${spec.clearedWhen}.`;
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* The entry the line waits on, by the queue's two kinds of blocker
|
|
3788
|
+
* (@taiga/types): an active entry holds every other one wherever it sits, so
|
|
3789
|
+
* it answers first; without one, the first halted entry holds the entries
|
|
3790
|
+
* behind it. -1 when neither is there.
|
|
3791
|
+
*/
|
|
3792
|
+
function blockingIndex(entries, stateOf) {
|
|
3793
|
+
const active = entries.findIndex((entry) => stateOf(entry) === "active");
|
|
3794
|
+
return active >= 0 ? active : entries.findIndex((entry) => stateOf(entry) === "halted");
|
|
3795
|
+
}
|
|
3796
|
+
/** The one sentence a person needs: what the line is doing, or what it is waiting on. */
|
|
3797
|
+
function waitingOn(rows) {
|
|
3798
|
+
const at = blockingIndex(rows, (row) => row.entryState);
|
|
3799
|
+
if (at < 0) return rows.length > 0 ? "Nothing in the queue is running; the first queued initiative starts when the line is free." : null;
|
|
3800
|
+
const head = rows[at];
|
|
3801
|
+
const name = typeof head.title === "string" ? `"${head.title}"` : `Initiative ${String(head.backlogItemId)}`;
|
|
3802
|
+
if (head.entryState === "halted") {
|
|
3803
|
+
const halt = head.halt;
|
|
3804
|
+
const ahead = at > 0 ? " Entries ahead of it are not held by it." : "";
|
|
3805
|
+
return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
|
|
3806
|
+
}
|
|
3807
|
+
const lifecycle = head.lifecycle;
|
|
3808
|
+
const held = "every other queued initiative waits until its pull request merges.";
|
|
3809
|
+
if (!lifecycle) return `${name} is the active entry; ${held}`;
|
|
3810
|
+
const pr = typeof head.prUrl === "string" ? ` (${head.prUrl})` : "";
|
|
3811
|
+
if (lifecycle.cta === "start_build") return `${name} has its plan and waits for the Build slot; ${held}`;
|
|
3812
|
+
if (head.handedBack) return `${name} was handed back to a person: ${head.handedBack}${pr}; ${held}`;
|
|
3813
|
+
if (lifecycle.turn === "you") return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
|
|
3814
|
+
return `${name} is ${String(lifecycle.stage)}${pr}; ${held}`;
|
|
3815
|
+
}
|
|
3816
|
+
/**
|
|
3817
|
+
* The product's queue as a person reads it: each live entry in order with its
|
|
3818
|
+
* initiative's title and where that initiative is, a halted entry with why and
|
|
3819
|
+
* what lifts it, and one sentence saying what the line is waiting on. The
|
|
3820
|
+
* route carries item ids only; each is named by one item read (when the key
|
|
3821
|
+
* may read the backlog), and a read the API fails leaves that entry unnamed
|
|
3822
|
+
* rather than failing the queue.
|
|
3823
|
+
*/
|
|
3824
|
+
async function queueView(client, product, options = { name: "all" }) {
|
|
3825
|
+
const [queue, paused] = await Promise.all([client.listQueue(product), linePausedSince(client, product)]);
|
|
3826
|
+
const entries = queue.data.filter((entry) => entry.state !== "settled");
|
|
3827
|
+
const canName = client.hasCapability("backlog:read");
|
|
3828
|
+
const blocking = blockingIndex(entries, (entry) => entry.state);
|
|
3829
|
+
const toName = !canName ? [] : options.name === "all" ? entries.slice(0, NAMED_ENTRIES).map((_, index) => index) : blocking >= 0 ? [blocking] : [];
|
|
3830
|
+
const settled = await Promise.allSettled(toName.map((index) => client.getBacklogItem(entries[index].backlogItemId)));
|
|
3831
|
+
const reads = new Map(toName.map((index, at) => [index, settled[at]]));
|
|
3832
|
+
let unnamed = options.name === "all" ? entries.length - toName.length : 0;
|
|
3833
|
+
const rows = entries.map((entry, index) => {
|
|
3834
|
+
const read = reads.get(index);
|
|
3835
|
+
if (read?.status === "rejected") {
|
|
3836
|
+
if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
|
|
3837
|
+
unnamed += 1;
|
|
3838
|
+
}
|
|
3839
|
+
const item = read?.status === "fulfilled" ? read.value.data : null;
|
|
3840
|
+
const lifecycle = item?.lifecycle;
|
|
3841
|
+
const latestBuild = lifecycle?.latestBuild;
|
|
3842
|
+
const row = {
|
|
3843
|
+
position: index + 1,
|
|
3844
|
+
backlogItemId: entry.backlogItemId,
|
|
3845
|
+
entryState: entry.state
|
|
3846
|
+
};
|
|
3847
|
+
if (item) {
|
|
3848
|
+
Object.assign(row, {
|
|
3849
|
+
title: item.title,
|
|
3850
|
+
state: item.state,
|
|
3851
|
+
planningStatus: item.planningStatus,
|
|
3852
|
+
needsHumanCause: item.needsHumanCause,
|
|
3853
|
+
criteria: criteriaCounts(item),
|
|
3854
|
+
lifecycle: lifecycle ? {
|
|
3855
|
+
turn: lifecycle.turn,
|
|
3856
|
+
stage: lifecycle.stage,
|
|
3857
|
+
cta: lifecycle.cta
|
|
3858
|
+
} : null,
|
|
3859
|
+
...latestBuild ? {
|
|
3860
|
+
runId: latestBuild.id,
|
|
3861
|
+
prUrl: latestBuild.prUrl ?? null
|
|
3862
|
+
} : {}
|
|
3863
|
+
});
|
|
3864
|
+
const handback = handbackSaid(latestBuild?.reviewEscalation);
|
|
3865
|
+
if (handback) row.handedBack = handback;
|
|
3866
|
+
}
|
|
3867
|
+
if (entry.state === "halted") {
|
|
3868
|
+
const cause = typeof entry.haltCause === "string" ? entry.haltCause : null;
|
|
3869
|
+
row.halt = {
|
|
3870
|
+
cause,
|
|
3871
|
+
reason: (cause ? renderHalt({
|
|
3872
|
+
cause,
|
|
3873
|
+
params: entry.haltParams ?? {}
|
|
3874
|
+
}) : "") || (typeof entry.haltReason === "string" ? entry.haltReason : "The queue stopped here."),
|
|
3875
|
+
nextStep: haltNextStep(cause)
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
return row;
|
|
3879
|
+
});
|
|
3880
|
+
const note = unnamed === 0 ? null : !canName ? "Entries carry initiativeId only: naming them needs backlog:read, which this key lacks." : entries.length > NAMED_ENTRIES ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry initiativeId only.` : "An entry whose initiative could not be read carries initiativeId only; taiga_get_initiative reads it.";
|
|
3881
|
+
const line = typeof paused === "string" ? `The line is paused (since ${paused}): nothing is planned or built until a person resumes it.` : null;
|
|
3882
|
+
return {
|
|
3883
|
+
linePaused: paused === null ? null : typeof paused === "string",
|
|
3884
|
+
entries: rows,
|
|
3885
|
+
waitingOn: line ?? waitingOn(rows),
|
|
3886
|
+
...note ? { note } : {}
|
|
3887
|
+
};
|
|
3888
|
+
}
|
|
3889
|
+
/**
|
|
3890
|
+
* When the product's line was paused, false when it runs, null when the
|
|
3891
|
+
* product cannot be read (the queue is still worth reading without it).
|
|
3892
|
+
*/
|
|
3893
|
+
async function linePausedSince(client, product) {
|
|
3894
|
+
try {
|
|
3895
|
+
const row = (await client.getProduct(product)).data;
|
|
3896
|
+
return typeof row.lineStoppedAt === "string" ? row.lineStoppedAt : false;
|
|
3897
|
+
} catch (error) {
|
|
3898
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
3899
|
+
throw error;
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
function registerQueueTools(server, client) {
|
|
3903
|
+
if (!client.hasCapability("products:read")) return;
|
|
3904
|
+
server.registerTool("taiga_get_line", {
|
|
3905
|
+
title: "Get the product queue",
|
|
3906
|
+
description: "Read a product's line, the queue the coding agent works one initiative at a time, and what it waits on: each entry in order with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why it stopped and what lifts it, linePaused (null when the product could not be read), and waitingOn, one sentence saying what the line is waiting for. The line advances only when the active entry's pull request merges, so an entry waiting on a review holds everything behind it. Use this when a queued initiative is not moving.",
|
|
3907
|
+
inputSchema: z.object({ productId }),
|
|
3908
|
+
annotations: READ_ONLY
|
|
3909
|
+
}, (params) => runTool(async () => toolResult(await queueView(client, params.productId))));
|
|
3910
|
+
if (!client.hasCapability("products:write")) return;
|
|
3911
|
+
server.registerTool("taiga_pause_line", {
|
|
3912
|
+
title: "Pause or resume a product's line",
|
|
3913
|
+
description: "Pause the product's whole line, or resume it. Pausing cancels the plan being written and stops the build in flight where it stands (both are flagged), and nothing more is planned or built; initiatives keep their places. Resuming plans the interrupted initiative again from scratch, carries the interrupted build on from its first incomplete step, and the line picks up where it was; a build you had stopped yourself before pausing stays stopped. Idempotent. Pausing interrupts work and resuming starts it: read taiga_get_line first to see what it would interrupt, and ask the person.",
|
|
3914
|
+
inputSchema: z.object({
|
|
3915
|
+
productId,
|
|
3916
|
+
paused: z.boolean().describe("true pauses the line, false resumes it")
|
|
3917
|
+
}),
|
|
3918
|
+
annotations: OPERATE_SETTING
|
|
3919
|
+
}, (params) => runTool(async () => {
|
|
3920
|
+
const row = (await client.setLineStopped(params.productId, params.paused)).data;
|
|
3921
|
+
return toolResult({
|
|
3922
|
+
productId: row.id,
|
|
3923
|
+
linePaused: typeof row.lineStoppedAt === "string",
|
|
3924
|
+
lineStoppedAt: row.lineStoppedAt ?? null
|
|
3925
|
+
});
|
|
3803
3926
|
}));
|
|
3804
3927
|
}
|
|
3805
3928
|
//#endregion
|
|
@@ -3886,6 +4009,8 @@ function situationOf(item) {
|
|
|
3886
4009
|
const pr = typeof build.prNumber === "number" ? ` (PR #${build.prNumber})` : "";
|
|
3887
4010
|
const said = BUILD_RUN_STATUSES.includes(build.status) ? RUN_STANDS[landedDisposition(build.status, build.prMerged === true)] : build.status;
|
|
3888
4011
|
stage += `; its latest run ${said}${pr}`;
|
|
4012
|
+
const handback = handbackSaid(build.reviewEscalation);
|
|
4013
|
+
if (handback) stage += `, handed back because ${handback}`;
|
|
3889
4014
|
}
|
|
3890
4015
|
parts.push(`${stage}.`);
|
|
3891
4016
|
if (typeof item["needsHumanCause"] === "string") {
|
|
@@ -3896,7 +4021,8 @@ function situationOf(item) {
|
|
|
3896
4021
|
if (why) parts.push(`The line stopped on it: ${why}.`);
|
|
3897
4022
|
}
|
|
3898
4023
|
const turn = life.turn === "you" ? "A person's turn" : life.turn === "agent" ? "The agent's turn" : null;
|
|
3899
|
-
const
|
|
4024
|
+
const handedBack = handbackSaid(build?.reviewEscalation);
|
|
4025
|
+
const move = life.cta === "review_pr" && handedBack ? `a person should ${handbackNextStep(build.reviewEscalation)}` : typeof life.cta === "string" ? CTA_MOVE[life.cta] : void 0;
|
|
3900
4026
|
if (life.cta === "view_pr") parts.push(`Nothing to do: ${move}.`);
|
|
3901
4027
|
else if (turn && move) parts.push(`${turn}: ${move}.`);
|
|
3902
4028
|
else if (turn) parts.push(`${turn}.`);
|
|
@@ -4018,7 +4144,7 @@ function registerBacklogTools(server, client) {
|
|
|
4018
4144
|
function registerBacklogReads(server, client) {
|
|
4019
4145
|
server.registerTool("taiga_list_initiatives", {
|
|
4020
4146
|
title: "List initiatives",
|
|
4021
|
-
description: "List a product's initiatives
|
|
4147
|
+
description: "List a product's initiatives (its backlog): titles, category, status, where each one is on the line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on, in a sentence) and criteria, how many of its acceptance criteria are met (null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked off, so a done item can show fewer met than it has). Use this to find the item you are working on (query finds one by title), then read its acceptance criteria and technical context with taiga_get_initiative. Archived items are left out unless asked for.",
|
|
4022
4148
|
inputSchema: z.object({
|
|
4023
4149
|
productId,
|
|
4024
4150
|
query: z.string().trim().min(1).max(200).optional().describe("Part of the title, any case: only the items that contain it"),
|
|
@@ -4119,7 +4245,7 @@ function registerRequest(server, client) {
|
|
|
4119
4245
|
function registerMove(server, client) {
|
|
4120
4246
|
server.registerTool("taiga_move_initiative", {
|
|
4121
4247
|
title: "Move initiative on the board",
|
|
4122
|
-
description: `Move an initiative on the board, or order it within a column: backlog and todo are yours and the line never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan being written or the run in flight: that is how work is stopped, and it throws that work away. A move to ${PR_CLOSING_STATES.join(", ")} CLOSES a pull request the initiative is still waiting on (a move to queue keeps it, and done leaves it to be merged); Taiga leaves a comment on the pull request saying why. The state machine refuses a move that is not allowed (CONFLICT). A move into queue starts paid work and needs products:write. Ask the person before a move that starts work, stops work or closes a pull request. archived: true files the initiative where it stands (pass its current column as state; never from the Build station, plan or build) and archived: false brings it back; archived work is left out of lists unless includeArchived asks for it.`,
|
|
4248
|
+
description: `Move an initiative on the board, take it off the line, or order it within a column: backlog and todo are yours and the line never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan being written or the run in flight: that is how work is stopped, and it throws that work away. A move to ${PR_CLOSING_STATES.join(", ")} CLOSES a pull request the initiative is still waiting on (a move to queue keeps it, and done leaves it to be merged); Taiga leaves a comment on the pull request saying why. The state machine refuses a move that is not allowed (CONFLICT). A move into queue starts paid work and needs products:write. Ask the person before a move that starts work, stops work or closes a pull request. archived: true files the initiative where it stands (pass its current column as state; never from the Build station, plan or build) and archived: false brings it back; archived work is left out of lists unless includeArchived asks for it.`,
|
|
4123
4249
|
inputSchema: z.object({
|
|
4124
4250
|
initiativeId,
|
|
4125
4251
|
state: z.enum(USER_DROP_STATES).describe("The column to move into"),
|
|
@@ -4242,7 +4368,7 @@ function registerReviewGate(server, client) {
|
|
|
4242
4368
|
function registerResume(server, client) {
|
|
4243
4369
|
server.registerTool("taiga_resume_initiative", {
|
|
4244
4370
|
title: "Resume initiative",
|
|
4245
|
-
description: "
|
|
4371
|
+
description: "Carry on an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished part (the steps already committed are kept), a refused build start is cleared and retried. This is the way to carry work on: a run is the record of an attempt, and decisions about the work are made on the initiative. Refused (CONFLICT) when nothing is flagged or the cause has no resume: a plan that failed with needsHumanCause null (lifecycle.cta retry_plan, planFailureCause says why) is planned again with taiga_plan_again. Starts paid work.",
|
|
4246
4372
|
inputSchema: z.object({ initiativeId }),
|
|
4247
4373
|
annotations: OPERATE
|
|
4248
4374
|
}, (params) => runTool(async () => outcome((await client.resumeInitiative(params.initiativeId)).data)));
|
|
@@ -4341,6 +4467,8 @@ function standingSummary(standing) {
|
|
|
4341
4467
|
}
|
|
4342
4468
|
return {
|
|
4343
4469
|
controls: listed.length,
|
|
4470
|
+
controlsInPolicies: controls.length,
|
|
4471
|
+
controlsWithNothingRecorded: controls.length - listed.length,
|
|
4344
4472
|
violations: violations.length,
|
|
4345
4473
|
bySeverity,
|
|
4346
4474
|
resolvedViolations: resolved
|
|
@@ -4385,7 +4513,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4385
4513
|
}));
|
|
4386
4514
|
server.registerTool("taiga_get_finding", {
|
|
4387
4515
|
title: "Get a maintenance finding",
|
|
4388
|
-
description: "Read one finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), the advisory and the fixed version, the place in the code, the policy controls it breaches, why it is ranked where it is, its history (reintroduced, previously resolved) and the remediation under way. A secret's value is never stored; the finding names the rule and the location only.",
|
|
4516
|
+
description: "Read one maintenance finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), the advisory and the fixed version, the place in the code, the policy controls it breaches, why it is ranked where it is, its history (reintroduced, previously resolved) and the remediation under way. A secret's value is never stored; the finding names the rule and the location only.",
|
|
4389
4517
|
inputSchema: z.object({ findingId }),
|
|
4390
4518
|
annotations: READ_ONLY
|
|
4391
4519
|
}, (params) => runTool(async () => {
|
|
@@ -4397,7 +4525,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4397
4525
|
}));
|
|
4398
4526
|
server.registerTool("taiga_get_policy_standing", {
|
|
4399
4527
|
title: "Get a product's policy standing",
|
|
4400
|
-
description: "
|
|
4528
|
+
description: "How a product stands against the organization's own published policies: the controls it violates, each with its violations (critical and high only, each citing the policy's sentence and the document passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, awaiting verification), and when the documents were derived and assessed, led by a summary of the counts. A control is breached when open scanner findings map to it, diverged when the assessment of the product documents found them contradicting it (a control can be both; state names the stronger, breached). A violation's work is the initiative raised for it; live: false means that initiative is done or canceled without the violation going away, so it needs a person or a new fix. A control breached by scanner findings counts them by tier (findingsByTier): a breach of routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation is not listed, and it is not \"passing\" either: there is no such state; summary.controlsWithNothingRecorded counts them. Pass full: true for every violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
|
|
4401
4529
|
inputSchema: z.object({
|
|
4402
4530
|
productId,
|
|
4403
4531
|
full: z.boolean().optional().describe("Carry each violation's evidence, the policy excerpt and the resolved violations (default false)")
|
|
@@ -4409,12 +4537,15 @@ function registerMaintenanceTools(server, client) {
|
|
|
4409
4537
|
const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
|
|
4410
4538
|
const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
|
|
4411
4539
|
const summary = standingSummary(standing);
|
|
4540
|
+
const note = standing.assessment?.state === "never" ? "Never assessed: the documents have not been checked against the policies yet, so nothing here is an all-clear. An assessment runs when the reality documents are derived or refreshed (taiga_refresh_reality_documents, paid work: ask first)." : null;
|
|
4412
4541
|
if (params.full) return toolResult({
|
|
4413
4542
|
summary,
|
|
4543
|
+
...note ? { note } : {},
|
|
4414
4544
|
...standing
|
|
4415
4545
|
});
|
|
4416
4546
|
return toolResult({
|
|
4417
4547
|
summary,
|
|
4548
|
+
...note ? { note } : {},
|
|
4418
4549
|
productId: standing.productId,
|
|
4419
4550
|
hasPolicies: standing.hasPolicies,
|
|
4420
4551
|
assessment: standing.assessment,
|
|
@@ -4435,7 +4566,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4435
4566
|
if (!client.hasCapability("products:write")) return;
|
|
4436
4567
|
server.registerTool("taiga_ignore_finding", {
|
|
4437
4568
|
title: "Ignore or reopen a finding",
|
|
4438
|
-
description: "Ignore a finding that does not apply or whose risk is accepted, with the reason (false_positive, not_reachable, accepted_risk, wont_fix), an optional note and an optional expiry after which it resurfaces if it still exists; or reopen an ignored one (ignored: false). Ignored findings are kept and reviewable. There is no \"mark fixed\": a finding resolves when the next sweep no longer detects it. For a secret that is only in history, rotate the credential outside Taiga, then ignore it with a reason. Ignoring hides a shared signal and can record the team's acceptance of a risk, which is the person's decision: ask the person first.",
|
|
4569
|
+
description: "Ignore a maintenance finding, with a reason, or reopen it. Ignore one that does not apply or whose risk is accepted, with the reason (false_positive, not_reachable, accepted_risk, wont_fix), an optional note and an optional expiry after which it resurfaces if it still exists; or reopen an ignored one (ignored: false). Ignored findings are kept and reviewable. There is no \"mark fixed\": a finding resolves when the next sweep no longer detects it. For a secret that is only in history, rotate the credential outside Taiga, then ignore it with a reason. Ignoring hides a shared signal and can record the team's acceptance of a risk, which is the person's decision: ask the person first.",
|
|
4439
4570
|
inputSchema: z.object({
|
|
4440
4571
|
findingId,
|
|
4441
4572
|
ignored: z.boolean().describe("true ignores the finding, false reopens it"),
|
|
@@ -4457,7 +4588,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4457
4588
|
}));
|
|
4458
4589
|
server.registerTool("taiga_fix_finding", {
|
|
4459
4590
|
title: "Fix a finding",
|
|
4460
|
-
description: "Ask
|
|
4591
|
+
description: "Ask Taiga to fix a maintenance finding: it raises an initiative and adds it to the end of the product's queue, where the line plans it in turn; whether the build then waits for a person is the initiative's review gate, as for any initiative. The fix is a normal run that ends in a pull request you review and merge. With wholeGroup: true it fixes the finding's whole group in one initiative, as the page does: for a dependency, every open advisory on that package at that installed version in one version bump; for code or infrastructure, every location the same rule flagged. Absent for a secret only in history, anything in a submodule, and a dependency with no fixed version (remediableBy says upstream_only); the API refuses those. Starts paid work.",
|
|
4461
4592
|
inputSchema: z.object({
|
|
4462
4593
|
productId,
|
|
4463
4594
|
findingId,
|
|
@@ -4483,7 +4614,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4483
4614
|
}));
|
|
4484
4615
|
server.registerTool("taiga_fix_policy_violation", {
|
|
4485
4616
|
title: "Fix policy violations",
|
|
4486
|
-
description: "Ask
|
|
4617
|
+
description: "Ask Taiga to fix a product's policy violations (it raises initiatives): name a control to cover every violation recorded against it that has no initiative yet, or a single violation (violationId) when the control has more than one. Taiga raises one initiative and adds it to the end of the product's queue, exactly as a finding fix does. The violation stays listed while the work is under way: only the next assessment can record that the documents no longer show it. Starts paid work.",
|
|
4487
4618
|
inputSchema: z.object({
|
|
4488
4619
|
productId,
|
|
4489
4620
|
controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
|
|
@@ -4533,6 +4664,9 @@ const CONTEXT_BUILD_FIELDS = [
|
|
|
4533
4664
|
"prUrl",
|
|
4534
4665
|
"prState",
|
|
4535
4666
|
"prMerged",
|
|
4667
|
+
"prDraft",
|
|
4668
|
+
"reviewEscalation",
|
|
4669
|
+
"error",
|
|
4536
4670
|
"createdAt"
|
|
4537
4671
|
];
|
|
4538
4672
|
/**
|
|
@@ -4595,12 +4729,12 @@ const PARTS = {
|
|
|
4595
4729
|
factory: "factories:read",
|
|
4596
4730
|
documents: "products:read",
|
|
4597
4731
|
policies: "tenant:documents:read",
|
|
4598
|
-
|
|
4732
|
+
initiatives: "backlog:read",
|
|
4599
4733
|
repositories: "products:read",
|
|
4600
|
-
|
|
4601
|
-
|
|
4734
|
+
runs: "products:read",
|
|
4735
|
+
actionRequired: "products:read",
|
|
4602
4736
|
maintenance: "products:read",
|
|
4603
|
-
|
|
4737
|
+
policyStanding: "products:read"
|
|
4604
4738
|
};
|
|
4605
4739
|
/** What the API said when a part failed, in the words the bundle reports. */
|
|
4606
4740
|
function unavailableReason(reason) {
|
|
@@ -4765,8 +4899,8 @@ async function backlogPart(client, productId) {
|
|
|
4765
4899
|
* The linked repositories. The route serves one page of 100 and takes no
|
|
4766
4900
|
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
4767
4901
|
*/
|
|
4768
|
-
async function repositoriesPart(
|
|
4769
|
-
const result = await
|
|
4902
|
+
async function repositoriesPart(links) {
|
|
4903
|
+
const result = await links();
|
|
4770
4904
|
return {
|
|
4771
4905
|
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
4772
4906
|
truncated: result.nextCursor !== null
|
|
@@ -4774,7 +4908,7 @@ async function repositoriesPart(client, productId) {
|
|
|
4774
4908
|
}
|
|
4775
4909
|
async function buildsPart(client, productId) {
|
|
4776
4910
|
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
4777
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(
|
|
4911
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRow);
|
|
4778
4912
|
}
|
|
4779
4913
|
/**
|
|
4780
4914
|
* What waits on a person for this product, counted by kind (PRO-2800): the
|
|
@@ -4815,30 +4949,35 @@ async function maintenancePart(client, productId) {
|
|
|
4815
4949
|
}
|
|
4816
4950
|
/** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
|
|
4817
4951
|
async function standingPart(client, productId) {
|
|
4818
|
-
const
|
|
4952
|
+
const standing = (await client.getPolicyStanding(productId)).data;
|
|
4953
|
+
const summary = standingSummary(standing);
|
|
4954
|
+
const state = standing.assessment?.state;
|
|
4819
4955
|
return {
|
|
4820
4956
|
openViolations: summary.violations,
|
|
4821
4957
|
bySeverity: summary.bySeverity,
|
|
4822
|
-
controlsViolated: summary.controls
|
|
4958
|
+
controlsViolated: summary.controls,
|
|
4959
|
+
...state === "never" || state === "failed" ? { note: `The assessment ${state === "never" ? "has never run" : "failed"}: no violations here is not an all-clear.` } : {}
|
|
4823
4960
|
};
|
|
4824
4961
|
}
|
|
4825
4962
|
async function productContext(client, product, options) {
|
|
4826
4963
|
const productId = product.id;
|
|
4827
4964
|
const granted = (part) => client.hasCapability(PARTS[part]);
|
|
4828
4965
|
const notGranted = Object.keys(PARTS).filter((part) => !granted(part));
|
|
4966
|
+
let linksRead;
|
|
4967
|
+
const links = () => linksRead ??= client.listProductRepositories(productId);
|
|
4829
4968
|
const fetchers = {
|
|
4830
4969
|
factory: () => factoryPart(client, String(product.factoryId)),
|
|
4831
4970
|
documents: () => documentsPart(client, product, options.documents),
|
|
4832
4971
|
policies: () => policiesPart(client),
|
|
4833
|
-
|
|
4834
|
-
repositories: () => repositoriesPart(
|
|
4835
|
-
|
|
4836
|
-
|
|
4972
|
+
initiatives: () => backlogPart(client, productId),
|
|
4973
|
+
repositories: () => repositoriesPart(links),
|
|
4974
|
+
runs: () => buildsPart(client, productId),
|
|
4975
|
+
actionRequired: () => attentionPart(client, productId),
|
|
4837
4976
|
maintenance: () => maintenancePart(client, productId),
|
|
4838
|
-
|
|
4977
|
+
policyStanding: () => standingPart(client, productId)
|
|
4839
4978
|
};
|
|
4840
4979
|
const parts = Object.keys(fetchers).filter(granted);
|
|
4841
|
-
const [settled, readiness] = await Promise.all([Promise.allSettled(parts.map((part) => fetchers[part]())), setupReadiness(client, product)]);
|
|
4980
|
+
const [settled, readiness] = await Promise.all([Promise.allSettled(parts.map((part) => fetchers[part]())), setupReadiness(client, product, links)]);
|
|
4842
4981
|
const bundle = { product: {
|
|
4843
4982
|
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
4844
4983
|
projectType: isImportedProduct(product) ? "brownfield" : "greenfield",
|
|
@@ -4875,7 +5014,7 @@ function registerContextTools(server, client) {
|
|
|
4875
5014
|
if (!client.hasCapability("products:read")) return;
|
|
4876
5015
|
server.registerTool("taiga_get_product_context", {
|
|
4877
5016
|
title: "Get product context",
|
|
4878
|
-
description: `Where a product stands
|
|
5017
|
+
description: `Where a product stands, in one call: the first read for any question about a product. Name it by ID, slug or the git remote URL of a checkout. The parts: product (and, while in setup, readiness: what it still needs and the next move); factory, with its instructions in full; documents, the specification and architecture as outlines (section ids and sizes); policies, each published policy with its section ids; initiatives, the open ones (taiga_list_initiatives has the done and canceled); repositories; runs, the latest ten, each with its outcome in a sentence; actionRequired, what waits on a person, counted by kind; maintenance, open findings by severity and the last sweep; policyStanding, open policy violations by severity. A part the API refused or failed is named under unavailable, a part the key cannot read under notGranted; neither is shown as empty. Before writing code, documents: budget brings the whole sections that fit, about ${DOCUMENT_BUDGET / 1e3}k characters each, and full brings them whole. taiga_get_product is the product record and its settings alone.`,
|
|
4879
5018
|
inputSchema: z.object({
|
|
4880
5019
|
productId: productId.optional(),
|
|
4881
5020
|
slug: slug.optional(),
|
|
@@ -4930,7 +5069,7 @@ function registerAutonomyTools(server, client) {
|
|
|
4930
5069
|
}));
|
|
4931
5070
|
if (client.hasCapability("backlog:write")) server.registerTool("taiga_set_auto_merge", {
|
|
4932
5071
|
title: "Set whether an initiative's pull request merges on its own",
|
|
4933
|
-
description: "
|
|
5072
|
+
description: "Set whether one initiative's pull request merges on its own: true lets its pull request merge itself once the repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false makes it wait for a person; null follows the product's default. The final word within what the organization and factories allow: where either has turned autonomous merge off, true changes nothing. Taiga merges through your rules, never around them.",
|
|
4934
5073
|
inputSchema: z.object({
|
|
4935
5074
|
initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it"),
|
|
4936
5075
|
autoMerge: z.boolean().nullable().describe("true merges on its own, false waits for a person, null follows the product")
|
|
@@ -4991,22 +5130,62 @@ function itemRow(row, now, full) {
|
|
|
4991
5130
|
};
|
|
4992
5131
|
}
|
|
4993
5132
|
/** What is open, counted by product and kind: the answer to "what needs attention" without reading every row. */
|
|
4994
|
-
function summaryOf(rows) {
|
|
5133
|
+
function summaryOf(rows, stale) {
|
|
4995
5134
|
const byProduct = /* @__PURE__ */ new Map();
|
|
4996
5135
|
for (const row of rows) {
|
|
4997
5136
|
const key = String(row["productId"]);
|
|
4998
|
-
const entry = byProduct.get(key) ?? {
|
|
5137
|
+
const entry = byProduct.get(key) ?? (row["productName"] ? {
|
|
4999
5138
|
productId: key,
|
|
5000
|
-
product: row["productName"]
|
|
5139
|
+
product: row["productName"],
|
|
5001
5140
|
open: 0,
|
|
5002
5141
|
byKind: {}
|
|
5003
|
-
}
|
|
5142
|
+
} : {
|
|
5143
|
+
productId: key,
|
|
5144
|
+
open: 0,
|
|
5145
|
+
byKind: {}
|
|
5146
|
+
});
|
|
5004
5147
|
entry.open += 1;
|
|
5005
5148
|
entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
|
|
5149
|
+
if (stale.has(String(row["id"]))) entry.stale = (entry.stale ?? 0) + 1;
|
|
5006
5150
|
byProduct.set(key, entry);
|
|
5007
5151
|
}
|
|
5008
5152
|
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
5009
5153
|
}
|
|
5154
|
+
/** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
|
|
5155
|
+
const STALE_CHECKS = 10;
|
|
5156
|
+
/**
|
|
5157
|
+
* Which "Review & merge" items no longer stand, and why (PRO-2800): the run's
|
|
5158
|
+
* pull request closed or merged, or its initiative was canceled or finished
|
|
5159
|
+
* without it. Two reviewing agents counted such leftovers as live gates; the
|
|
5160
|
+
* row still is open in Action Required, so this says what it is now rather
|
|
5161
|
+
* than hiding it. Best effort, a handful of runs: a failed read leaves the
|
|
5162
|
+
* row as it was.
|
|
5163
|
+
*/
|
|
5164
|
+
async function staleReviews(client, rows) {
|
|
5165
|
+
const byId = new Map(rows.filter((row) => row["kind"] === "review_pr" && row["subjectType"] === "build_run").map((row) => [String(row["id"]), row]));
|
|
5166
|
+
const candidates = [...byId.values()].slice(0, STALE_CHECKS);
|
|
5167
|
+
let unchecked = byId.size - candidates.length;
|
|
5168
|
+
const out = /* @__PURE__ */ new Map();
|
|
5169
|
+
await Promise.all(candidates.map(async (row) => {
|
|
5170
|
+
try {
|
|
5171
|
+
const run = (await client.getBuildRun(String(row["subjectId"]))).data;
|
|
5172
|
+
if (run["prMerged"] === true) return void out.set(String(row["id"]), "its pull request has merged");
|
|
5173
|
+
if (run["prState"] === "closed") return void out.set(String(row["id"]), "its pull request was closed");
|
|
5174
|
+
if (typeof run["backlogItemId"] !== "string") return;
|
|
5175
|
+
if (!client.hasCapability("backlog:read")) return void (unchecked += 1);
|
|
5176
|
+
const item = (await client.getBacklogItem(run["backlogItemId"])).data;
|
|
5177
|
+
if (item["state"] === "canceled" || item["status"] === "canceled") out.set(String(row["id"]), "its initiative was canceled; the pull request is still open on the forge");
|
|
5178
|
+
else if (item["state"] === "done" || item["status"] === "done") out.set(String(row["id"]), "its initiative is done; this run is not the one that landed it");
|
|
5179
|
+
} catch (error) {
|
|
5180
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5181
|
+
unchecked += 1;
|
|
5182
|
+
}
|
|
5183
|
+
}));
|
|
5184
|
+
return {
|
|
5185
|
+
stale: out,
|
|
5186
|
+
unchecked
|
|
5187
|
+
};
|
|
5188
|
+
}
|
|
5010
5189
|
/** Pages read for the summary; past this it says it counted only those. */
|
|
5011
5190
|
const SUMMARY_PAGE = 100;
|
|
5012
5191
|
const SETUP_TASK_FIELDS = [
|
|
@@ -5067,11 +5246,18 @@ function sameKeyOnce(rows) {
|
|
|
5067
5246
|
}
|
|
5068
5247
|
return out;
|
|
5069
5248
|
}
|
|
5249
|
+
/** A page of folded rows, saying how many records it folded so totalCount (records) and the rows reconcile. */
|
|
5250
|
+
function folded(rows, nextCursor, records) {
|
|
5251
|
+
return {
|
|
5252
|
+
...page(rows, nextCursor),
|
|
5253
|
+
...rows.length < records ? { recordsOnPage: records } : {}
|
|
5254
|
+
};
|
|
5255
|
+
}
|
|
5070
5256
|
function registerInboxTools(server, client) {
|
|
5071
5257
|
if (!client.hasCapability("products:read")) return;
|
|
5072
5258
|
server.registerTool("taiga_list_action_required", {
|
|
5073
5259
|
title: "List what needs a person",
|
|
5074
|
-
description: "
|
|
5260
|
+
description: "What waits on a person: reviews and setup items, with a summary by product. The inbox is the team's, not one person's. It holds things to review (a plan, a set of initiatives, a document decision, a pull request, maintenance findings, new policy violations) and things to set up that only a person can (a secret, an environment variable, an account, a package registry, a cloud permission). Across every product the key can open, or one product. The first page opens with summary: what is open, counted by product and kind, so \"what needs attention\" is one call, and totalCount. Each row says how many days it has waited (waitingDays) and, for a pull request review, the prUrl. A pull request review that no longer stands (its pull request merged or closed, or its initiative canceled or done) says so under stale, and the summary counts those per product. category gate is the reviews, setup the setup items; resolved: true reads the archive. An item snoozed until later is left out until then (snoozedHidden counts them). full: true adds each item's metadata. Unlike taiga_list_setup_tasks, which lists the setup items with exactly what to set and where.",
|
|
5075
5261
|
inputSchema: z.object({
|
|
5076
5262
|
productId: productId.optional(),
|
|
5077
5263
|
category: z.enum(["gate", "setup"]).optional().describe("gate: things to review; setup: things to set up"),
|
|
@@ -5104,19 +5290,33 @@ function registerInboxTools(server, client) {
|
|
|
5104
5290
|
const rows = result.data;
|
|
5105
5291
|
const due = rows.filter(isDue);
|
|
5106
5292
|
const hidden = rows.length - due.length;
|
|
5293
|
+
const overviewDue = overview ? overview.data.filter(isDue) : [];
|
|
5294
|
+
const checked = resolved ? {
|
|
5295
|
+
stale: /* @__PURE__ */ new Map(),
|
|
5296
|
+
unchecked: 0
|
|
5297
|
+
} : await staleReviews(client, [...due, ...overviewDue]);
|
|
5298
|
+
const stale = checked.stale;
|
|
5107
5299
|
return toolResult({
|
|
5108
5300
|
...overview ? {
|
|
5109
|
-
summary: summaryOf(
|
|
5301
|
+
summary: summaryOf(overviewDue, stale),
|
|
5110
5302
|
...overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}
|
|
5111
5303
|
} : {},
|
|
5112
|
-
...page(due.map((row) =>
|
|
5304
|
+
...page(due.map((row) => {
|
|
5305
|
+
const item = itemRow(row, now, full === true);
|
|
5306
|
+
const why = stale.get(String(row["id"]));
|
|
5307
|
+
return why ? {
|
|
5308
|
+
...item,
|
|
5309
|
+
stale: why
|
|
5310
|
+
} : item;
|
|
5311
|
+
}), result.nextCursor),
|
|
5113
5312
|
...totalOf(result),
|
|
5114
|
-
...hidden > 0 ? { snoozedHidden: hidden } : {}
|
|
5313
|
+
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
5314
|
+
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} pull request review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
|
|
5115
5315
|
});
|
|
5116
5316
|
}));
|
|
5117
5317
|
server.registerTool("taiga_list_setup_tasks", {
|
|
5118
5318
|
title: "List setup tasks",
|
|
5119
|
-
description: "
|
|
5319
|
+
description: "What a person must set up for a product (secrets, variables, accounts), with exactly what and where. These are the tasks agents raised for work only a person can do: the name of an environment variable, secret or API key to create (key; never its value, which Taiga does not store or display), the provider page to do it on, and the phase that says when it blocks you (prereq: before the deploy, which fails without it; wiring: connecting generated values back to providers; followup: billing approvals, extra permissions and the like that can wait until after launch). A build does not wait for any of them. Across every product the key can open (each row names its product), or one. The tasks still to do, oldest first, the order they depend on each other; includeDone: true adds the done and dismissed ones. The first page says how many there are on every page (totalCount). One row per variable: a variable filed twice (as a secret and as an environment variable) lists the other records under alsoFiledAs, an open one first (folded within a page). totalCount counts records, and a page that folded some says recordsOnPage. Unlike taiga_list_action_required, whose setup items are the same work as rows to resolve, this says exactly what to set and where.",
|
|
5120
5320
|
inputSchema: z.object({
|
|
5121
5321
|
productId: productId.optional(),
|
|
5122
5322
|
includeDone: z.boolean().optional().describe("Also the tasks already done or dismissed (default false)"),
|
|
@@ -5131,7 +5331,7 @@ function registerInboxTools(server, client) {
|
|
|
5131
5331
|
...params.cursor === void 0 ? { withTotal: true } : {}
|
|
5132
5332
|
});
|
|
5133
5333
|
return toolResult({
|
|
5134
|
-
...
|
|
5334
|
+
...folded(sameKeyOnce(pickFields(result.data, SETUP_TASK_FIELDS)), result.nextCursor, result.data.length),
|
|
5135
5335
|
...totalOf(result)
|
|
5136
5336
|
});
|
|
5137
5337
|
}));
|
|
@@ -5297,7 +5497,7 @@ function registerKnowledgeTools(server, client) {
|
|
|
5297
5497
|
if (!client.hasCapability("customDocuments:write")) return;
|
|
5298
5498
|
server.registerTool("taiga_add_knowledge", {
|
|
5299
5499
|
title: "Add knowledge",
|
|
5300
|
-
description: "File a
|
|
5500
|
+
description: "File a reference document as knowledge for agents to search: Markdown or plain text, at the level it is relevant: a product (productId), a factory (factoryId), or the organization (neither; that takes an owner or admin, because every factory and product inherits it). Good candidates are what a new engineer would be handed to read: specifications and contracts to conform to, glossaries and code lists, third-party documentation, internal write-ups. A rule that applies every time belongs in instructions instead. The file is processed before agents can read it; taiga_list_knowledge says when it is ready. PDFs and spreadsheets are added in the web. The same content already filed at that level comes back as that file, with deduplicated: true: it was not created here, so do not delete it as if it were. Every agent at that level reads it from then on, and processing it is paid work.",
|
|
5301
5501
|
inputSchema: z.object({
|
|
5302
5502
|
...scopeInputs,
|
|
5303
5503
|
fileName: z.string().trim().min(1).max(200).regex(/\.(md|txt)$/i, "A Markdown (.md) or plain-text (.txt) file name").describe("The file name, ending in .md or .txt"),
|
|
@@ -5436,7 +5636,7 @@ function registerObserveTools(server, client) {
|
|
|
5436
5636
|
if (!client.hasCapability("products:read")) return;
|
|
5437
5637
|
server.registerTool("taiga_get_deployments", {
|
|
5438
5638
|
title: "Get what runs where",
|
|
5439
|
-
description: "What
|
|
5639
|
+
description: "What runs in each of a product's environments, and recent deployments: what is running now, anything deploying, and the recent history, newest first: each deployment with its commit, pull request, status, who deployed it and, for a failure, the error the provider reported. What runs is the most recent deployment that actually succeeded, so a newer failed attempt shows beside the version still running, not in its place. The history is mirrored from the provider, including deployments made outside Taiga; syncStatus says when it last synced. Pass environmentId for one environment's history. Taiga observes deployments and never performs them.",
|
|
5440
5640
|
inputSchema: z.object({
|
|
5441
5641
|
productId,
|
|
5442
5642
|
environmentId: environmentId.optional(),
|
|
@@ -5484,7 +5684,7 @@ function registerObserveTools(server, client) {
|
|
|
5484
5684
|
}));
|
|
5485
5685
|
server.registerTool("taiga_get_delivery_metrics", {
|
|
5486
5686
|
title: "Get delivery metrics",
|
|
5487
|
-
description: "DORA's five
|
|
5687
|
+
description: "DORA's five delivery metrics for one environment (the one marked production, as the page reports them), computed from its deployment history over the last 30, 60 or 90 days: deployment frequency, lead time and failed deployment recovery time (throughput), change-failure rate and rework rate (instability), each with its trend against the window before, and a performance band for all but rework rate, which DORA has published none for. Failures and recoveries are read from deployments, not incidents. A trend built on fewer than ten deployments is approximate; a metric no deployment fed says so instead of showing a number. Previews and checks do not count.",
|
|
5488
5688
|
inputSchema: z.object({
|
|
5489
5689
|
productId,
|
|
5490
5690
|
environmentId,
|
|
@@ -5497,7 +5697,7 @@ function registerObserveTools(server, client) {
|
|
|
5497
5697
|
}));
|
|
5498
5698
|
server.registerTool("taiga_get_monitoring", {
|
|
5499
5699
|
title: "Get a product's monitoring",
|
|
5500
|
-
description: "How a product's environments are doing: each environment's current health from Taiga's availability checks of its App URL, with its uptime and when it was last checked; a status Taiga cannot verify now reads as not checked rather than the last thing it knew, and a new environment as baseline learning. With environmentId, that environment's uptime over a 30, 60 or 90-day window, and the experience signals the beacon reports (Core Web Vitals, JavaScript errors, client-observed API health) where it is installed; until then they read as not set up, which means no data, not good data. Setting up the beacon raises an initiative from the environment's settings in the web.",
|
|
5700
|
+
description: "How a product's environments are doing (health, uptime): each environment's current health from Taiga's availability checks of its App URL, with its uptime and when it was last checked; a status Taiga cannot verify now reads as not checked rather than the last thing it knew, and a new environment as baseline learning. With environmentId, that environment's uptime over a 30, 60 or 90-day window, and the experience signals the beacon reports (Core Web Vitals, JavaScript errors, client-observed API health) where it is installed; until then they read as not set up, which means no data, not good data. Setting up the beacon raises an initiative from the environment's settings in the web.",
|
|
5501
5701
|
inputSchema: z.object({
|
|
5502
5702
|
productId,
|
|
5503
5703
|
environmentId: environmentId.optional(),
|
|
@@ -5606,7 +5806,7 @@ function registerProductDocumentWrites(server, client) {
|
|
|
5606
5806
|
}, (params) => runTool(() => publishDraft(productScope(params.productId, params.type))));
|
|
5607
5807
|
server.registerTool("taiga_mark_product_document_reviewed", {
|
|
5608
5808
|
title: "Mark a product document reviewed",
|
|
5609
|
-
description: "Record that a person reviewed a product document
|
|
5809
|
+
description: "Record that a person reviewed a product document: its latest published version, as the Documents page shows it beside each version; once every published document is reviewed, the product's review item in Action Required resolves. The mark says a human read it, so make it only when the person tells you they have: reading it yourself is not a review.",
|
|
5610
5810
|
inputSchema: z.object({
|
|
5611
5811
|
productId,
|
|
5612
5812
|
type: reviewableType
|
|
@@ -20477,7 +20677,7 @@ function registerRepositoryTools(server, client) {
|
|
|
20477
20677
|
if (!client.hasCapability("vcs:manage")) return;
|
|
20478
20678
|
server.registerTool("taiga_link_repository", {
|
|
20479
20679
|
title: "Link a repository to a product",
|
|
20480
|
-
description: "Link a repository the GitHub connection can reach (taiga_list_linkable_repositories gives its id)
|
|
20680
|
+
description: "Link a GitHub repository to a product: one the GitHub connection can reach (taiga_list_linkable_repositories gives its id), or with replacesRepositoryId swap the one linked now for it in one step (naming the same repository there changes nothing; linking one already linked without it is refused). A link Taiga manages (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, sweeps scan it, and its deployment history is imported in the background.",
|
|
20481
20681
|
inputSchema: z.object({
|
|
20482
20682
|
productId,
|
|
20483
20683
|
githubRepositoryId: idInput("GitHub repository", "taiga_list_linkable_repositories gives it"),
|
|
@@ -20793,7 +20993,7 @@ function registerSetupTools(server, client) {
|
|
|
20793
20993
|
}));
|
|
20794
20994
|
server.registerTool("taiga_update_product", {
|
|
20795
20995
|
title: "Change a product's settings",
|
|
20796
|
-
description: "Change a product's name
|
|
20996
|
+
description: "Change a product's name or what Taiga builds for it. A new name changes the slug, so links to the product change. What Taiga builds for it: its infrastructure code and CI/CD pipelines (null returns one to the organization default), the sentence on how a half Taiga does not build is done today, and whether it inherits the organization's design standards (null follows the default for how the product started). Later plans read the new answer; work already planned is not redone. Autonomy is taiga_set_product_autonomy.",
|
|
20797
20997
|
inputSchema: z.object({
|
|
20798
20998
|
productId,
|
|
20799
20999
|
name: z.string().trim().min(1).max(255).optional().describe("A new name"),
|
|
@@ -20816,7 +21016,7 @@ function registerSetupTools(server, client) {
|
|
|
20816
21016
|
}));
|
|
20817
21017
|
server.registerTool("taiga_refresh_reality_documents", {
|
|
20818
21018
|
title: "Refresh a product's documents from its code",
|
|
20819
|
-
description: "Derive the reality documents again from the code on the branch Taiga works on (the link's effectiveBranch: the chosen branch, or the repository's default): all of them, or the ones named (with what they are derived from). For a document the import misread, refresh it rather than importing again; unchanged sections are not republished. Runs an agent in the background and returns the job; taiga_wait_for_job says when it is done.",
|
|
21019
|
+
description: "Derive the reality documents again from the code, on the branch Taiga works on (the link's effectiveBranch: the chosen branch, or the repository's default): all of them, or the ones named (with what they are derived from). For a document the import misread, refresh it rather than importing again; unchanged sections are not republished. Runs an agent in the background and returns the job; taiga_wait_for_job says when it is done.",
|
|
20820
21020
|
inputSchema: z.object({
|
|
20821
21021
|
productId,
|
|
20822
21022
|
docTypes: z.array(z.enum(REALITY_DERIVED_DOCUMENT_TYPES)).min(1).max(REALITY_DERIVED_DOCUMENT_TYPES.length).optional().describe("The documents to refresh; omit for all")
|
|
@@ -21099,6 +21299,8 @@ const RUN_REPORT_FIELDS = [
|
|
|
21099
21299
|
"prState",
|
|
21100
21300
|
"prDraft",
|
|
21101
21301
|
"prMerged",
|
|
21302
|
+
"reviewEscalation",
|
|
21303
|
+
"error",
|
|
21102
21304
|
"prNumber",
|
|
21103
21305
|
"baseBranch",
|
|
21104
21306
|
"actions"
|
|
@@ -21123,7 +21325,7 @@ function judgeBuild(until) {
|
|
|
21123
21325
|
function registerWaitTools(server, client, options) {
|
|
21124
21326
|
if (client.hasCapability("backlog:read")) server.registerTool("taiga_wait_for_initiative", {
|
|
21125
21327
|
title: "Wait for an initiative",
|
|
21126
|
-
description: "Wait
|
|
21328
|
+
description: "Wait for an initiative to reach a point (planned, building, a pull request, done), instead of reading it again and again: changed (anything about where it is or its latest run), planned (its plan landed), your_turn (it waits on a person: a plan to review, a pull request, a flag), pr_open (a pull request open now; a merged or closed one does not count), run_settled (its latest run stopped moving) or done (finished; a canceled one stops the wait). Returns as soon as that holds (reached: true), early when it cannot hold without a person (a flag, a failed plan, a plan waiting for its verdict, a pull request waiting for a review, a cancelled initiative, or a landed plan whose review gate cannot be read; stoppedBecause says which), or at the timeout with reached: false: call again to keep waiting. Conditions are states, not events: an initiative whose latest run is already settled answers run_settled at once; item is null when not even the first read answered in time. Planning takes minutes and a build up to an hour.",
|
|
21127
21329
|
inputSchema: z.object({
|
|
21128
21330
|
initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it"),
|
|
21129
21331
|
until: z.enum(ITEM_CONDITIONS).describe("What to wait for"),
|
|
@@ -21156,7 +21358,7 @@ function registerWaitTools(server, client, options) {
|
|
|
21156
21358
|
}));
|
|
21157
21359
|
if (client.hasCapability("products:read")) server.registerTool("taiga_wait_for_run", {
|
|
21158
21360
|
title: "Wait for a run",
|
|
21159
|
-
description: "Wait
|
|
21361
|
+
description: "Wait for a run to reach a point (a step, a pull request, settled): changed (status, steps or pull request), step_done (one more step finished), pr_open (open now, not merged or closed) or settled (it stopped moving: in review, completed, failed, stopped). Returns as soon as that holds (reached: true), early when the run settled without it (stoppedBecause; changed waits on, since a person resuming the run is a change), or at the timeout with reached: false: call again to keep waiting. Conditions are states: a run already settled answers settled at once; run is null when not even the first read answered in time.",
|
|
21160
21362
|
inputSchema: z.object({
|
|
21161
21363
|
runId: idInput("run", "taiga_list_runs gives it"),
|
|
21162
21364
|
until: z.enum(BUILD_CONDITIONS).describe("What to wait for"),
|
|
@@ -21172,7 +21374,7 @@ function registerWaitTools(server, client, options) {
|
|
|
21172
21374
|
return toolResult({
|
|
21173
21375
|
until: params.until,
|
|
21174
21376
|
...outcome,
|
|
21175
|
-
run: now ?
|
|
21377
|
+
run: now ? readableRow(pickFields([now], RUN_REPORT_FIELDS)[0]) : null
|
|
21176
21378
|
});
|
|
21177
21379
|
}));
|
|
21178
21380
|
if (client.hasCapability("products:read")) server.registerTool("taiga_wait_for_job", {
|
|
@@ -21206,9 +21408,10 @@ function registerWaitTools(server, client, options) {
|
|
|
21206
21408
|
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
21207
21409
|
* registered without it.
|
|
21208
21410
|
*/
|
|
21209
|
-
function withStrictInputs(server) {
|
|
21411
|
+
function withStrictInputs(server, registered) {
|
|
21210
21412
|
const register = server.registerTool.bind(server);
|
|
21211
21413
|
server.registerTool = ((name, config, callback) => {
|
|
21414
|
+
registered.set(name, config.annotations);
|
|
21212
21415
|
const schema = config.inputSchema;
|
|
21213
21416
|
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`);
|
|
21214
21417
|
const handler = callback;
|
|
@@ -21247,7 +21450,7 @@ const ALWAYS_LOAD = /* @__PURE__ */ new Set([
|
|
|
21247
21450
|
function writeNotice(name, annotations) {
|
|
21248
21451
|
if (!annotations || annotations.readOnlyHint === void 0) throw new Error(`Tool ${name}: annotations must say whether it is read-only`);
|
|
21249
21452
|
if (annotations.readOnlyHint) return "";
|
|
21250
|
-
return annotations.destructiveHint ? "
|
|
21453
|
+
return annotations.destructiveHint ? "Ask first: paid or shared work. " : "Writes. ";
|
|
21251
21454
|
}
|
|
21252
21455
|
/**
|
|
21253
21456
|
* Register all MCP tools, resources and prompts based on the API key's effective capabilities.
|
|
@@ -21255,7 +21458,8 @@ function writeNotice(name, annotations) {
|
|
|
21255
21458
|
* never sees tools it can't use.
|
|
21256
21459
|
*/
|
|
21257
21460
|
function registerAllTools(server, client, options = {}) {
|
|
21258
|
-
|
|
21461
|
+
const registered = /* @__PURE__ */ new Map();
|
|
21462
|
+
withStrictInputs(server, registered);
|
|
21259
21463
|
registerProductTools(server, client);
|
|
21260
21464
|
registerDocumentTools(server, client);
|
|
21261
21465
|
registerBacklogTools(server, client);
|
|
@@ -21272,9 +21476,39 @@ function registerAllTools(server, client, options = {}) {
|
|
|
21272
21476
|
registerRepositoryTools(server, client);
|
|
21273
21477
|
registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
|
|
21274
21478
|
registerContextTools(server, client);
|
|
21479
|
+
if (registered.size > 0) registerWhoami(server, client, registered);
|
|
21275
21480
|
registerResources(server, client);
|
|
21276
21481
|
registerPrompts(server, client);
|
|
21277
21482
|
}
|
|
21483
|
+
/**
|
|
21484
|
+
* Who this key is and what it may do (PRO-2800): reviewing agents read tool
|
|
21485
|
+
* descriptions saying "owners and admins link repositories" with no way to
|
|
21486
|
+
* learn their own role until a write was refused. Registered last, so it can
|
|
21487
|
+
* name every tool the key was given.
|
|
21488
|
+
*/
|
|
21489
|
+
function registerWhoami(server, client, registered) {
|
|
21490
|
+
server.registerTool("taiga_whoami", {
|
|
21491
|
+
title: "Who am I",
|
|
21492
|
+
description: "Who this API key acts as and what it may do: the person's email and organization role, the key's scopes (null means every capability the person has), its capabilities, and the tools it was given, split into reads, writes, and the ask-first writes (paid agent work or changes others see). A tool the key's capabilities do not cover is not offered at all, rather than refused when called.",
|
|
21493
|
+
inputSchema: z.object({}),
|
|
21494
|
+
annotations: READ_ONLY
|
|
21495
|
+
}, () => runTool(async () => {
|
|
21496
|
+
const me = client.getIntrospection();
|
|
21497
|
+
const tools = [...registered.entries()].filter(([name]) => name !== "taiga_whoami");
|
|
21498
|
+
const named = (pick) => tools.filter(([, notes]) => pick(notes)).map(([name]) => name);
|
|
21499
|
+
return toolResult({
|
|
21500
|
+
email: me?.email ?? null,
|
|
21501
|
+
role: me?.role ?? null,
|
|
21502
|
+
scopes: me?.scopes ?? null,
|
|
21503
|
+
capabilities: me?.capabilities ?? [],
|
|
21504
|
+
tools: {
|
|
21505
|
+
reads: named((notes) => notes?.readOnlyHint === true),
|
|
21506
|
+
writes: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint !== true),
|
|
21507
|
+
askFirst: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint === true)
|
|
21508
|
+
}
|
|
21509
|
+
});
|
|
21510
|
+
}));
|
|
21511
|
+
}
|
|
21278
21512
|
//#endregion
|
|
21279
21513
|
//#region src/tools/instructions.ts
|
|
21280
21514
|
/**
|
|
@@ -21292,20 +21526,25 @@ const SERVER_INSTRUCTIONS = [
|
|
|
21292
21526
|
"Taiga is a policy-aware software delivery platform: an organization's policies and knowledge, its factories",
|
|
21293
21527
|
"and products, each product's initiatives (its backlog), the line that plans and builds them one at a time, the",
|
|
21294
21528
|
"coding agent's runs and pull requests, maintenance findings, and Action Required, where work waits on a person.",
|
|
21295
|
-
"
|
|
21296
|
-
"or compliance, findings, deployments
|
|
21529
|
+
"Use these tools (names start with taiga_) whenever the person asks about a Taiga product, an initiative, a run",
|
|
21530
|
+
"or pull request, policy or compliance, findings, deployments, or setting a product up.",
|
|
21297
21531
|
"",
|
|
21298
|
-
"**Start here.** taiga_get_product_context (
|
|
21299
|
-
"call: what it still needs while in setup,
|
|
21300
|
-
"recent runs with their outcome,
|
|
21301
|
-
"taiga_list_products
|
|
21532
|
+
"**Start here.** taiga_get_product_context (a product by git remote URL, slug or ID) says where it stands in one",
|
|
21533
|
+
"call: what it still needs while in setup, documents as outlines, policies as section ids, open initiatives,",
|
|
21534
|
+
"recent runs with their outcome, what waits on a person, findings and policy violations. Then read only what you",
|
|
21535
|
+
"need. Across products: taiga_list_products, taiga_list_action_required. taiga_whoami says what this key may do.",
|
|
21302
21536
|
"",
|
|
21303
21537
|
"**Words.** An initiative is a unit of work in the backlog; a run is one attempt by the coding agent to build it;",
|
|
21304
21538
|
"the line is the product's queue; a factory owns a set of products. IDs are UUIDs, never names; a NOT_FOUND names",
|
|
21305
|
-
"the tool that lists the right ones. A
|
|
21539
|
+
"the tool that lists the right ones. A GitHub repository id is not a repository link id. Say a refusal as it is.",
|
|
21540
|
+
"",
|
|
21541
|
+
"**Reading.** Lists page with nextCursor (Action Required, setup tasks, findings: totalCount). A document has two",
|
|
21542
|
+
"timelines: plan (what was meant) and reality (derived from the code). outcome, situation and nextStep are",
|
|
21543
|
+
"one-sentence summaries of the record they sit on; read the record for the detail.",
|
|
21306
21544
|
"",
|
|
21307
|
-
"**Before you write.** A tool that writes
|
|
21308
|
-
"person
|
|
21545
|
+
"**Before you write.** A tool that writes opens with \"Writes.\" or \"Ask first:\". Before an \"Ask first\" tool, tell",
|
|
21546
|
+
"the person what you are about to do and wait for their yes: it starts paid agent work, interrupts it, or changes",
|
|
21547
|
+
"what others see.",
|
|
21309
21548
|
"",
|
|
21310
21549
|
"**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from",
|
|
21311
21550
|
"scratch, stamping checks, starting a run over, and deleting anything."
|