@taiga-ai/mcp-server 0.11.0 → 0.12.0-rc.419
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 +912 -53
- package/package.json +1 -1
- package/src/client.ts +26 -9
- package/src/server.integration.test.ts +1134 -16
- package/src/tools/backlogs.ts +131 -16
- package/src/tools/builds.ts +53 -6
- package/src/tools/context.ts +132 -22
- package/src/tools/errors.ts +4 -4
- package/src/tools/index.ts +29 -1
- package/src/tools/instructions.ts +15 -7
- package/src/tools/products.ts +27 -2
- package/src/tools/prompts.ts +5 -2
- package/src/tools/queue.ts +168 -0
- package/src/tools/sections.test.ts +36 -1
- package/src/tools/sections.ts +50 -0
- package/src/tools/summarize.ts +23 -0
- package/src/tools/wait.ts +445 -0
- package/taiga-ai-mcp-server-0.12.0-rc.419.tgz +0 -0
- package/tsconfig.json +4 -0
- package/taiga-ai-mcp-server-0.11.0.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -114,7 +114,7 @@ var TaigaClient = class {
|
|
|
114
114
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
115
115
|
this.apiKey = opts.apiKey;
|
|
116
116
|
}
|
|
117
|
-
async request(method, path, body,
|
|
117
|
+
async request(method, path, body, options = {}) {
|
|
118
118
|
const url = `${this.baseUrl}/v1${path}`;
|
|
119
119
|
const headers = {
|
|
120
120
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -124,17 +124,18 @@ var TaigaClient = class {
|
|
|
124
124
|
const response = await fetch(url, {
|
|
125
125
|
method,
|
|
126
126
|
headers,
|
|
127
|
-
body: body === void 0 ? null : JSON.stringify(body)
|
|
127
|
+
body: body === void 0 ? null : JSON.stringify(body),
|
|
128
|
+
...options.signal ? { signal: options.signal } : {}
|
|
128
129
|
});
|
|
129
130
|
if (!response.ok) {
|
|
130
131
|
const text = await response.text().catch(() => "");
|
|
131
132
|
throw new TaigaApiError(method, path, response.status, text);
|
|
132
133
|
}
|
|
133
134
|
const parsed = await response.json();
|
|
134
|
-
return
|
|
135
|
+
return options.keepTenant ? parsed : withoutTenant(parsed);
|
|
135
136
|
}
|
|
136
137
|
async introspect() {
|
|
137
|
-
const result = await this.request("GET", "/auth/introspect", void 0, {
|
|
138
|
+
const result = await this.request("GET", "/auth/introspect", void 0, { keepTenant: true });
|
|
138
139
|
this.introspection = result.data;
|
|
139
140
|
return result.data;
|
|
140
141
|
}
|
|
@@ -157,8 +158,8 @@ var TaigaClient = class {
|
|
|
157
158
|
const qs = params.toString();
|
|
158
159
|
return this.request("GET", `/products${qs ? `?${qs}` : ""}`);
|
|
159
160
|
}
|
|
160
|
-
async getProduct(id) {
|
|
161
|
-
return this.request("GET", `/products/${seg(id)}
|
|
161
|
+
async getProduct(id, signal) {
|
|
162
|
+
return this.request("GET", `/products/${seg(id)}`, void 0, { signal });
|
|
162
163
|
}
|
|
163
164
|
async listProductRepositories(productId) {
|
|
164
165
|
return this.request("GET", `/products/${seg(productId)}/repositories`);
|
|
@@ -225,8 +226,8 @@ var TaigaClient = class {
|
|
|
225
226
|
const qs = params.toString();
|
|
226
227
|
return this.request("GET", `/products/${seg(productId)}/backlog${qs ? `?${qs}` : ""}`);
|
|
227
228
|
}
|
|
228
|
-
async getBacklogItem(itemId) {
|
|
229
|
-
return this.request("GET", `/backlog/${seg(itemId)}
|
|
229
|
+
async getBacklogItem(itemId, signal) {
|
|
230
|
+
return this.request("GET", `/backlog/${seg(itemId)}`, void 0, { signal });
|
|
230
231
|
}
|
|
231
232
|
async createBacklogItem(productId, body) {
|
|
232
233
|
return this.request("POST", `/products/${seg(productId)}/backlog`, {
|
|
@@ -242,6 +243,14 @@ var TaigaClient = class {
|
|
|
242
243
|
async resumeInitiative(itemId) {
|
|
243
244
|
return this.request("POST", `/backlog/${seg(itemId)}/resume`);
|
|
244
245
|
}
|
|
246
|
+
/** The initiative's own review gate: true builds a landed plan at once, false stops for a person, null follows the product. */
|
|
247
|
+
async setAutoBuild(itemId, autoBuild) {
|
|
248
|
+
return this.request("PUT", `/backlog/${seg(itemId)}/auto-build`, { autoBuild });
|
|
249
|
+
}
|
|
250
|
+
/** The product's live queue, in order. */
|
|
251
|
+
async listQueue(productId) {
|
|
252
|
+
return this.request("GET", `/products/${seg(productId)}/queue`);
|
|
253
|
+
}
|
|
245
254
|
async getActivePlan(itemId) {
|
|
246
255
|
return this.request("GET", `/backlog/${seg(itemId)}/plans/active`);
|
|
247
256
|
}
|
|
@@ -268,8 +277,8 @@ var TaigaClient = class {
|
|
|
268
277
|
const qs = params.toString();
|
|
269
278
|
return this.request("GET", `/builds${qs ? `?${qs}` : ""}`);
|
|
270
279
|
}
|
|
271
|
-
async getBuildRun(id) {
|
|
272
|
-
return this.request("GET", `/builds/${seg(id)}
|
|
280
|
+
async getBuildRun(id, signal) {
|
|
281
|
+
return this.request("GET", `/builds/${seg(id)}`, void 0, { signal });
|
|
273
282
|
}
|
|
274
283
|
async stopBuildRun(id) {
|
|
275
284
|
return this.request("POST", `/builds/${seg(id)}/stop`);
|
|
@@ -662,7 +671,14 @@ const QUEUE_GLOBAL_BLOCKING_STATES = ["active"];
|
|
|
662
671
|
*/
|
|
663
672
|
const QUEUE_POSITIONAL_BLOCKING_STATES = ["halted"];
|
|
664
673
|
[...QUEUE_GLOBAL_BLOCKING_STATES, ...QUEUE_POSITIONAL_BLOCKING_STATES];
|
|
665
|
-
|
|
674
|
+
/**
|
|
675
|
+
* Why the queue stopped, as data.
|
|
676
|
+
*
|
|
677
|
+
* The `message` values for causes that already existed are VERBATIM the strings
|
|
678
|
+
* the reconciler used to write, so this change is invisible to anyone reading
|
|
679
|
+
* their queue.
|
|
680
|
+
*/
|
|
681
|
+
const HALT_CAUSES = {
|
|
666
682
|
/**
|
|
667
683
|
* A PERSON stopped the plan (PRO-1144). Distinct from `planning_failed`
|
|
668
684
|
* because the two leave the initiative in the same state and mean opposite
|
|
@@ -842,7 +858,52 @@ Object.keys({
|
|
|
842
858
|
clearedWhen: "never on its own; there is no principal left to run as",
|
|
843
859
|
exits: ["remove"]
|
|
844
860
|
}
|
|
845
|
-
}
|
|
861
|
+
};
|
|
862
|
+
Object.keys(HALT_CAUSES).filter((c) => c !== "awaiting_plan_review" && c !== "armed_start_missing");
|
|
863
|
+
function haltClearsItself(cause) {
|
|
864
|
+
return haltHasExit(cause, "auto");
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* May a person lift this halt by hand?
|
|
868
|
+
*
|
|
869
|
+
* The complement of `haltClearsItself`, and NOT its negation: a cause can be
|
|
870
|
+
* neither (queued_by_erased, whose only exit is removal, because there is no
|
|
871
|
+
* principal left to run as no matter who presses what). Reads the registry so
|
|
872
|
+
* the API's guard and the button's visibility come from one place instead of
|
|
873
|
+
* two lists that drift.
|
|
874
|
+
*/
|
|
875
|
+
function canResumeHalt(cause) {
|
|
876
|
+
return haltHasExit(cause, "resume");
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* The one place that indexes the registry by a cause, so the one place that has
|
|
880
|
+
* to survive a cause it does not know.
|
|
881
|
+
*
|
|
882
|
+
* The type says that cannot happen, and server-side it cannot: the column has a
|
|
883
|
+
* CHECK generated from this same list. But the BROWSER holds a bundle that can
|
|
884
|
+
* be older than the API it is talking to, and `HALT_CAUSES[unknown].exits`
|
|
885
|
+
* throws, which takes down the whole list rather than one badge. Fail closed:
|
|
886
|
+
* an unknown cause offers no exits, so it neither auto-clears nor shows a
|
|
887
|
+
* Resume button, and the row falls back to the prose the server sent.
|
|
888
|
+
*/
|
|
889
|
+
function haltHasExit(cause, exit) {
|
|
890
|
+
const spec = HALT_CAUSES[cause];
|
|
891
|
+
return spec ? spec.exits.includes(exit) : false;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* The sentence for a halt, rendered from its cause and params.
|
|
895
|
+
*
|
|
896
|
+
* ONE renderer, used both by the reconciler that stores the prose today and by
|
|
897
|
+
* the read path that will compute it instead (PRO-1039 PR 3). A slot with no
|
|
898
|
+
* matching param renders as the empty string rather than leaking `{name}` into
|
|
899
|
+
* a user's queue.
|
|
900
|
+
*/
|
|
901
|
+
function renderHalt(h) {
|
|
902
|
+
const spec = HALT_CAUSES[h.cause];
|
|
903
|
+
if (!spec) return "";
|
|
904
|
+
const params = h.params;
|
|
905
|
+
return spec.message.replace(/\{(\w+)\}/g, (_m, key) => params[key] ?? "");
|
|
906
|
+
}
|
|
846
907
|
//#endregion
|
|
847
908
|
//#region ../../packages/types/dist/build.js
|
|
848
909
|
const BUILD_RUN_STATUSES = [
|
|
@@ -892,7 +953,17 @@ function runDisposition(status) {
|
|
|
892
953
|
* = in_flight minus 'pending' (queued is not yet executing).
|
|
893
954
|
*/
|
|
894
955
|
const BUILD_RUN_ACTIVE_STATUSES = BUILD_RUN_STATUSES.filter((s) => runDisposition(s) === "in_flight" && s !== "pending");
|
|
895
|
-
|
|
956
|
+
/**
|
|
957
|
+
* Terminal-or-handoff statuses: the run will not move again WITHOUT a human
|
|
958
|
+
* (dispatcher failure path preserves them; the FE realtime hook refreshes list
|
|
959
|
+
* views when a phase_change carries one). 'needs_review' (the gate) is included
|
|
960
|
+
* because the agent is done and the FE treats it like a terminal for
|
|
961
|
+
* list-display purposes; 'failed'/'stopped' are re-armable but settled.
|
|
962
|
+
* = everything that is not in_flight.
|
|
963
|
+
* NB: buildRunStateMachine.terminalStatuses ({completed, closed}) is a
|
|
964
|
+
* DIFFERENT, narrower concept: transitions a user command may never leave.
|
|
965
|
+
*/
|
|
966
|
+
const BUILD_RUN_TERMINAL_STATUSES = BUILD_RUN_STATUSES.filter((s) => runDisposition(s) !== "in_flight");
|
|
896
967
|
[...BUILD_RUN_ACTIVE_STATUSES.filter((s) => s !== "reviewing")];
|
|
897
968
|
[...TURN_EVENT_TRIGGERS];
|
|
898
969
|
//#endregion
|
|
@@ -1073,6 +1144,29 @@ const ARTIFACT_SCALE_FRAME_NAMES = [
|
|
|
1073
1144
|
].map((token) => token.frame);
|
|
1074
1145
|
[...ARTIFACT_CSS_VARIABLES, ...ARTIFACT_SCALE_FRAME_NAMES];
|
|
1075
1146
|
new Set(ARTIFACT_CSS_VARIABLES.filter((name) => name !== "--radius"));
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region ../../packages/types/dist/auto-build.js
|
|
1149
|
+
/**
|
|
1150
|
+
* Auto-build: the review-plans gate (PRO-1305).
|
|
1151
|
+
*
|
|
1152
|
+
* "Build as soon as the plan is ready?" is asked at two scopes -- the project's
|
|
1153
|
+
* default (`projects.auto_build`) and the initiative's own standing answer
|
|
1154
|
+
* (`backlog_items.auto_build`) -- in the same polarity as auto-merge, and this
|
|
1155
|
+
* is the one place they are combined, so the line, the queue's halt
|
|
1156
|
+
* derivation and the settings surfaces cannot drift apart on what "gated"
|
|
1157
|
+
* means.
|
|
1158
|
+
*/
|
|
1159
|
+
/**
|
|
1160
|
+
* Does this plan build without stopping for a person to read it?
|
|
1161
|
+
*
|
|
1162
|
+
* The initiative's own answer wins; the project's default answers when it has
|
|
1163
|
+
* none. Unlike auto-merge there is no ceiling above the project: reviewing a
|
|
1164
|
+
* plan is a working preference, not a permission, and PRO-1305 gives the
|
|
1165
|
+
* initiative the override in both directions for exactly that reason.
|
|
1166
|
+
*/
|
|
1167
|
+
function resolveAutoBuild(scopes) {
|
|
1168
|
+
return scopes.initiative ?? scopes.product;
|
|
1169
|
+
}
|
|
1076
1170
|
Object.freeze({
|
|
1077
1171
|
productKinds: true,
|
|
1078
1172
|
applicationOwnership: true
|
|
@@ -1481,6 +1575,56 @@ function instructionsText(sections) {
|
|
|
1481
1575
|
}
|
|
1482
1576
|
return parts.join("\n\n").trim() || null;
|
|
1483
1577
|
}
|
|
1578
|
+
function cutTo(doc, sections, kept) {
|
|
1579
|
+
const omittedSections = Object.keys(sections).filter((id) => !Object.hasOwn(kept, id)).map((id) => ({
|
|
1580
|
+
id,
|
|
1581
|
+
characters: sectionContent(sections, id)?.length ?? 0
|
|
1582
|
+
}));
|
|
1583
|
+
return {
|
|
1584
|
+
doc: {
|
|
1585
|
+
...doc,
|
|
1586
|
+
data: {
|
|
1587
|
+
...doc.data,
|
|
1588
|
+
sections: kept
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
omittedSections
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* The named sections of a section-shaped document, in the order the API
|
|
1596
|
+
* returns them (a jsonb column: shorter ids first, then by bytes, not the
|
|
1597
|
+
* order a person wrote them);
|
|
1598
|
+
* every other section is listed with its size so the reader knows what else
|
|
1599
|
+
* there is. Null when the document is not made of sections.
|
|
1600
|
+
*/
|
|
1601
|
+
function pickSections(doc, ids) {
|
|
1602
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
1603
|
+
if (!sections) return null;
|
|
1604
|
+
return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* As many whole sections as fit `budget` characters of text, the rest listed
|
|
1608
|
+
* with their sizes so the reader can ask for them by id. Whole, never cut: a
|
|
1609
|
+
* section's text is one argument, and half of it reads as all of it. Not "the
|
|
1610
|
+
* opening": the document's own order is not in the data (a jsonb column
|
|
1611
|
+
* hands back shorter ids first), so a section that does not fit is skipped
|
|
1612
|
+
* and a later, smaller one may still be carried. Null when the document is
|
|
1613
|
+
* not made of sections.
|
|
1614
|
+
*/
|
|
1615
|
+
function budgetSections(doc, budget) {
|
|
1616
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
1617
|
+
if (!sections) return null;
|
|
1618
|
+
const kept = [];
|
|
1619
|
+
let used = 0;
|
|
1620
|
+
for (const id of Object.keys(sections)) {
|
|
1621
|
+
const length = sectionContent(sections, id)?.length ?? 0;
|
|
1622
|
+
if (used + length > budget) continue;
|
|
1623
|
+
kept.push([id, sections[id]]);
|
|
1624
|
+
used += length;
|
|
1625
|
+
}
|
|
1626
|
+
return cutTo(doc, sections, Object.fromEntries(kept));
|
|
1627
|
+
}
|
|
1484
1628
|
//#endregion
|
|
1485
1629
|
//#region src/tools/summarize.ts
|
|
1486
1630
|
/**
|
|
@@ -1502,6 +1646,30 @@ function page(data, nextCursor) {
|
|
|
1502
1646
|
nextCursor: nextCursor ?? null
|
|
1503
1647
|
};
|
|
1504
1648
|
}
|
|
1649
|
+
/**
|
|
1650
|
+
* How many of an initiative's acceptance criteria are met: one pair a row can
|
|
1651
|
+
* carry, so "done with two unmet" shows in a list without reading the item.
|
|
1652
|
+
* Null while it has none (an unplanned initiative).
|
|
1653
|
+
*/
|
|
1654
|
+
function criteriaCounts(item) {
|
|
1655
|
+
const criteria = item.acceptanceCriteria;
|
|
1656
|
+
if (!Array.isArray(criteria) || criteria.length === 0) return null;
|
|
1657
|
+
return {
|
|
1658
|
+
met: criteria.filter((c) => typeof c === "object" && c !== null && c.met === true).length,
|
|
1659
|
+
total: criteria.length
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Initiative rows cut to `fields`, each with its criteria counts. A row that
|
|
1664
|
+
* does not carry `acceptanceCriteria` at all gets no count rather than a
|
|
1665
|
+
* null that would read as "has none".
|
|
1666
|
+
*/
|
|
1667
|
+
function itemRows(items, fields) {
|
|
1668
|
+
return pickFields(items, fields).map((row, index) => "acceptanceCriteria" in items[index] ? {
|
|
1669
|
+
...row,
|
|
1670
|
+
criteria: criteriaCounts(items[index])
|
|
1671
|
+
} : row);
|
|
1672
|
+
}
|
|
1505
1673
|
//#endregion
|
|
1506
1674
|
//#region src/tools/products.ts
|
|
1507
1675
|
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
@@ -1750,11 +1918,12 @@ function registerProductTools(server, client) {
|
|
|
1750
1918
|
}));
|
|
1751
1919
|
server.registerTool("taiga_get_product_document", {
|
|
1752
1920
|
title: "Get product document",
|
|
1753
|
-
description: "Get the full content of one product document from one timeline. Read the plan to learn what to build: specification (requirements), architecture (design), threat_model (security risks), technology_decisions. Read reality, when the product has it, to learn what the code actually does before changing it. Omit origin for the product's own timeline. A read that finds nothing says which documents and timelines the product does have.",
|
|
1921
|
+
description: "Get the full content of one product document from one timeline. Read the plan to learn what to build: specification (requirements), architecture (design), threat_model (security risks), technology_decisions. Read reality, when the product has it, to learn what the code actually does before changing it. Omit origin for the product's own timeline. A large document runs to tens of thousands of characters: name the sections you need (the ids taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes. A read that finds nothing says which documents and timelines the product does have.",
|
|
1754
1922
|
inputSchema: {
|
|
1755
1923
|
productId,
|
|
1756
1924
|
type: z.enum(PRODUCT_DOCUMENT_TYPES).describe("Document type -- taiga_list_product_documents shows which ones the product has"),
|
|
1757
|
-
origin: originInput
|
|
1925
|
+
origin: originInput,
|
|
1926
|
+
sections: z.array(z.string().min(1).max(200)).min(1).max(50).optional().describe("Only these sections, by id; omit for the whole document")
|
|
1758
1927
|
},
|
|
1759
1928
|
annotations: READ_ONLY
|
|
1760
1929
|
}, (params) => runTool(async () => {
|
|
@@ -1762,7 +1931,17 @@ function registerProductTools(server, client) {
|
|
|
1762
1931
|
if (!params.origin) product = (await client.getProduct(params.productId)).data;
|
|
1763
1932
|
const origin = params.origin ?? defaultOriginFor(product, params.type);
|
|
1764
1933
|
try {
|
|
1765
|
-
|
|
1934
|
+
const doc = stripProvenance((await client.getProductDocument(params.productId, params.type, origin)).data);
|
|
1935
|
+
if (!params.sections) return toolResult(doc);
|
|
1936
|
+
const cut = pickSections(doc, new Set(params.sections));
|
|
1937
|
+
if (!cut) return toolError("VALIDATION_FAILED", `The ${origin} ${params.type} is not made of sections; read it without sections.`);
|
|
1938
|
+
const all = Object.keys(doc.data.sections);
|
|
1939
|
+
const unknown = params.sections.filter((id) => !all.includes(id));
|
|
1940
|
+
if (unknown.length > 0) return toolError("NOT_FOUND", `The ${origin} ${params.type} has no section ${unknown.join(", ")}; its sections are ${all.join(", ")}.`);
|
|
1941
|
+
return toolResult({
|
|
1942
|
+
...cut.doc,
|
|
1943
|
+
omittedSections: cut.omittedSections
|
|
1944
|
+
});
|
|
1766
1945
|
} catch (error) {
|
|
1767
1946
|
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
1768
1947
|
product ??= (await client.getProduct(params.productId)).data;
|
|
@@ -1983,6 +2162,130 @@ function registerDocumentTools(server, client) {
|
|
|
1983
2162
|
}
|
|
1984
2163
|
}
|
|
1985
2164
|
//#endregion
|
|
2165
|
+
//#region src/tools/queue.ts
|
|
2166
|
+
/** How many entries the read names; past this an entry keeps its backlogItemId only. */
|
|
2167
|
+
const NAMED_ENTRIES = 25;
|
|
2168
|
+
const MOVE_ASIDE = "or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.";
|
|
2169
|
+
/**
|
|
2170
|
+
* What lifts a halt, in the tools that do it. Read from the halt registry in
|
|
2171
|
+
* @taiga/types (how each cause clears, and whether a person may lift it), so
|
|
2172
|
+
* the advice cannot drift from what the API accepts; plan review is the one
|
|
2173
|
+
* cause a tool answers directly.
|
|
2174
|
+
*/
|
|
2175
|
+
function haltNextStep(cause) {
|
|
2176
|
+
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).";
|
|
2177
|
+
const spec = cause ? HALT_CAUSES[cause] : void 0;
|
|
2178
|
+
if (!cause || !spec) return `Read the initiative with taiga_get_backlog_item to see why, ${MOVE_ASIDE}`;
|
|
2179
|
+
const known = cause;
|
|
2180
|
+
if (canResumeHalt(known)) return `taiga_resume_backlog_item lifts it, ${MOVE_ASIDE}`;
|
|
2181
|
+
if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
|
|
2182
|
+
return `Only taking it off the line clears it (taiga_move_backlog_item): ${spec.clearedWhen}.`;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* The entry the line waits on, by the queue's two kinds of blocker
|
|
2186
|
+
* (@taiga/types): an active entry holds every other one wherever it sits, so
|
|
2187
|
+
* it answers first; without one, the first halted entry holds the entries
|
|
2188
|
+
* behind it. -1 when neither is there.
|
|
2189
|
+
*/
|
|
2190
|
+
function blockingIndex(entries, stateOf) {
|
|
2191
|
+
const active = entries.findIndex((entry) => stateOf(entry) === "active");
|
|
2192
|
+
return active >= 0 ? active : entries.findIndex((entry) => stateOf(entry) === "halted");
|
|
2193
|
+
}
|
|
2194
|
+
/** The one sentence a person needs: what the line is doing, or what it is waiting on. */
|
|
2195
|
+
function waitingOn(rows) {
|
|
2196
|
+
const at = blockingIndex(rows, (row) => row.entryState);
|
|
2197
|
+
if (at < 0) return rows.length > 0 ? "Nothing in the queue is running; the first queued initiative starts when the line is free." : null;
|
|
2198
|
+
const head = rows[at];
|
|
2199
|
+
const name = typeof head.title === "string" ? `"${head.title}"` : `Initiative ${String(head.backlogItemId)}`;
|
|
2200
|
+
if (head.entryState === "halted") {
|
|
2201
|
+
const halt = head.halt;
|
|
2202
|
+
const ahead = at > 0 ? " Entries ahead of it are not held by it." : "";
|
|
2203
|
+
return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
|
|
2204
|
+
}
|
|
2205
|
+
const lifecycle = head.lifecycle;
|
|
2206
|
+
const held = "every other queued initiative waits until its pull request merges.";
|
|
2207
|
+
if (!lifecycle) return `${name} is the active entry; ${held}`;
|
|
2208
|
+
const pr = typeof head.prUrl === "string" ? ` (${head.prUrl})` : "";
|
|
2209
|
+
if (lifecycle.cta === "start_build") return `${name} has its plan and waits for the Build slot; ${held}`;
|
|
2210
|
+
if (lifecycle.turn === "you") return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
|
|
2211
|
+
return `${name} is ${String(lifecycle.stage)}${pr}; ${held}`;
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* The product's queue as a person reads it: each live entry in order with its
|
|
2215
|
+
* initiative's title and where that initiative is, a halted entry with why and
|
|
2216
|
+
* what lifts it, and one sentence saying what the line is waiting on. The
|
|
2217
|
+
* route carries item ids only; each is named by one item read (when the key
|
|
2218
|
+
* may read the backlog), and a read the API fails leaves that entry unnamed
|
|
2219
|
+
* rather than failing the queue.
|
|
2220
|
+
*/
|
|
2221
|
+
async function queueView(client, product, options = { name: "all" }) {
|
|
2222
|
+
const entries = (await client.listQueue(product)).data.filter((entry) => entry.state !== "settled");
|
|
2223
|
+
const canName = client.hasCapability("backlog:read");
|
|
2224
|
+
const blocking = blockingIndex(entries, (entry) => entry.state);
|
|
2225
|
+
const toName = !canName ? [] : options.name === "all" ? entries.slice(0, NAMED_ENTRIES).map((_, index) => index) : blocking >= 0 ? [blocking] : [];
|
|
2226
|
+
const settled = await Promise.allSettled(toName.map((index) => client.getBacklogItem(String(entries[index].backlogItemId))));
|
|
2227
|
+
const reads = new Map(toName.map((index, at) => [index, settled[at]]));
|
|
2228
|
+
let unnamed = options.name === "all" ? entries.length - toName.length : 0;
|
|
2229
|
+
const rows = entries.map((entry, index) => {
|
|
2230
|
+
const read = reads.get(index);
|
|
2231
|
+
if (read?.status === "rejected") {
|
|
2232
|
+
if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
|
|
2233
|
+
unnamed += 1;
|
|
2234
|
+
}
|
|
2235
|
+
const item = read?.status === "fulfilled" ? read.value.data : null;
|
|
2236
|
+
const lifecycle = item?.lifecycle;
|
|
2237
|
+
const latestBuild = lifecycle?.latestBuild;
|
|
2238
|
+
const row = {
|
|
2239
|
+
position: index + 1,
|
|
2240
|
+
backlogItemId: entry.backlogItemId,
|
|
2241
|
+
entryState: entry.state
|
|
2242
|
+
};
|
|
2243
|
+
if (item) Object.assign(row, {
|
|
2244
|
+
title: item.title,
|
|
2245
|
+
state: item.state,
|
|
2246
|
+
planningStatus: item.planningStatus,
|
|
2247
|
+
needsHumanCause: item.needsHumanCause,
|
|
2248
|
+
criteria: criteriaCounts(item),
|
|
2249
|
+
lifecycle: lifecycle ? {
|
|
2250
|
+
turn: lifecycle.turn,
|
|
2251
|
+
stage: lifecycle.stage,
|
|
2252
|
+
cta: lifecycle.cta
|
|
2253
|
+
} : null,
|
|
2254
|
+
...latestBuild ? {
|
|
2255
|
+
buildId: latestBuild.id,
|
|
2256
|
+
prUrl: latestBuild.prUrl ?? null
|
|
2257
|
+
} : {}
|
|
2258
|
+
});
|
|
2259
|
+
if (entry.state === "halted") {
|
|
2260
|
+
const cause = typeof entry.haltCause === "string" ? entry.haltCause : null;
|
|
2261
|
+
row.halt = {
|
|
2262
|
+
cause,
|
|
2263
|
+
reason: (cause ? renderHalt({
|
|
2264
|
+
cause,
|
|
2265
|
+
params: entry.haltParams ?? {}
|
|
2266
|
+
}) : "") || (typeof entry.haltReason === "string" ? entry.haltReason : "The queue stopped here."),
|
|
2267
|
+
nextStep: haltNextStep(cause)
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
return row;
|
|
2271
|
+
});
|
|
2272
|
+
const note = unnamed === 0 ? null : !canName ? "Entries carry backlogItemId 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 backlogItemId only.` : "An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.";
|
|
2273
|
+
return {
|
|
2274
|
+
entries: rows,
|
|
2275
|
+
waitingOn: waitingOn(rows),
|
|
2276
|
+
...note ? { note } : {}
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
function registerQueueTools(server, client) {
|
|
2280
|
+
if (!client.hasCapability("products:read")) return;
|
|
2281
|
+
server.registerTool("taiga_get_queue", {
|
|
2282
|
+
title: "Get the product queue",
|
|
2283
|
+
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, 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.",
|
|
2284
|
+
inputSchema: { productId },
|
|
2285
|
+
annotations: READ_ONLY
|
|
2286
|
+
}, (params) => runTool(async () => toolResult(await queueView(client, params.productId))));
|
|
2287
|
+
}
|
|
2288
|
+
//#endregion
|
|
1986
2289
|
//#region src/tools/backlogs.ts
|
|
1987
2290
|
/** What a list row needs to say for the model to pick an item to read in full. */
|
|
1988
2291
|
const BACKLOG_SUMMARY_FIELDS = [
|
|
@@ -2014,6 +2317,7 @@ const ITEM_OUTCOME_FIELDS = [
|
|
|
2014
2317
|
"planningStatus",
|
|
2015
2318
|
"needsHumanCause",
|
|
2016
2319
|
"needsHumanDetail",
|
|
2320
|
+
"autoBuild",
|
|
2017
2321
|
"archivedAt",
|
|
2018
2322
|
"lifecycle"
|
|
2019
2323
|
];
|
|
@@ -2036,14 +2340,22 @@ const PLAN_STEP_SUMMARY_FIELDS = [
|
|
|
2036
2340
|
"reads",
|
|
2037
2341
|
"createsResources"
|
|
2038
2342
|
];
|
|
2039
|
-
|
|
2343
|
+
/** The outcome fields of an initiative, with how many of its criteria are met. */
|
|
2344
|
+
const outcomeOf = (item) => {
|
|
2345
|
+
const row = item;
|
|
2346
|
+
return {
|
|
2347
|
+
...pickFields([row], ITEM_OUTCOME_FIELDS)[0],
|
|
2348
|
+
criteria: criteriaCounts(row)
|
|
2349
|
+
};
|
|
2350
|
+
};
|
|
2351
|
+
const outcome = (item) => toolResult(outcomeOf(item));
|
|
2040
2352
|
/**
|
|
2041
2353
|
* Each tool is registered under the capability its route requires, so a
|
|
2042
2354
|
* key sees exactly what it may call: reads on backlog:read, the create and
|
|
2043
2355
|
* the board move on backlog:write, queueing on both backlog:write and
|
|
2044
2356
|
* products:write (the state route checks both for a drop into queue), the
|
|
2045
|
-
* resume on products:write alone, as
|
|
2046
|
-
* also comes back from a create or a queue), and the plan verdicts on
|
|
2357
|
+
* resume and the review gate on products:write alone, as their routes are
|
|
2358
|
+
* (the item ID they take also comes back from a create or a queue), and the plan verdicts on
|
|
2047
2359
|
* products:write together with backlog:read, because the plan ID they take
|
|
2048
2360
|
* comes only from the plan read: a key that cannot read plans has nothing
|
|
2049
2361
|
* to feed them.
|
|
@@ -2054,12 +2366,13 @@ function registerBacklogTools(server, client) {
|
|
|
2054
2366
|
if (may("backlog:write")) registerBacklogWrites(server, client);
|
|
2055
2367
|
if (may("backlog:write") && may("products:write")) registerQueue(server, client);
|
|
2056
2368
|
if (may("products:write")) registerResume(server, client);
|
|
2369
|
+
if (may("products:write")) registerReviewGate(server, client);
|
|
2057
2370
|
if (may("backlog:read") && may("products:write")) registerPlanVerdicts(server, client);
|
|
2058
2371
|
}
|
|
2059
2372
|
function registerBacklogReads(server, client) {
|
|
2060
2373
|
server.registerTool("taiga_list_backlog_items", {
|
|
2061
2374
|
title: "List backlog items",
|
|
2062
|
-
description: "List a product's backlog items (initiatives): titles, category, status
|
|
2375
|
+
description: "List a product's backlog items (initiatives): titles, category, status, where each one is on the line (backlog, todo, queue, plan, build, done) and criteria, how many of its acceptance criteria are met (null before it is planned). Use this to find the item you are working on, then read its acceptance criteria and technical context with taiga_get_backlog_item. Archived items are left out unless asked for.",
|
|
2063
2376
|
inputSchema: {
|
|
2064
2377
|
productId,
|
|
2065
2378
|
status: z.enum([
|
|
@@ -2086,7 +2399,7 @@ function registerBacklogReads(server, client) {
|
|
|
2086
2399
|
annotations: READ_ONLY
|
|
2087
2400
|
}, (params) => runTool(async () => {
|
|
2088
2401
|
const result = await client.listBacklogItems(params.productId, params);
|
|
2089
|
-
return toolResult(page(
|
|
2402
|
+
return toolResult(page(itemRows(result.data, BACKLOG_SUMMARY_FIELDS), result.nextCursor));
|
|
2090
2403
|
}));
|
|
2091
2404
|
server.registerTool("taiga_get_backlog_item", {
|
|
2092
2405
|
title: "Get backlog item",
|
|
@@ -2154,21 +2467,89 @@ function registerBacklogWrites(server, client) {
|
|
|
2154
2467
|
}
|
|
2155
2468
|
/** The columns a drop into starts paid work, which the state route gates on products:write. */
|
|
2156
2469
|
const LINE_STARTING_STATES = /* @__PURE__ */ new Set(["queue", "plan"]);
|
|
2470
|
+
/** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
|
|
2471
|
+
const isApiFailure$1 = (error) => error instanceof TaigaApiError || isNetworkFailure(error);
|
|
2472
|
+
/**
|
|
2473
|
+
* Whether this initiative's plan builds the moment it lands, and which scope
|
|
2474
|
+
* decided it: its own setting, or the product's default when it has none.
|
|
2475
|
+
* Null when the product cannot be read; the outcome then says so instead.
|
|
2476
|
+
*/
|
|
2477
|
+
async function reviewGate(client, item) {
|
|
2478
|
+
const own = typeof item.autoBuild === "boolean" ? item.autoBuild : null;
|
|
2479
|
+
try {
|
|
2480
|
+
const product = own === null ? (await client.getProduct(String(item.productId))).data : {};
|
|
2481
|
+
const builds = resolveAutoBuild({
|
|
2482
|
+
initiative: own,
|
|
2483
|
+
product: typeof product.autoBuild === "boolean" ? product.autoBuild : true
|
|
2484
|
+
});
|
|
2485
|
+
return {
|
|
2486
|
+
buildsWithoutReview: builds,
|
|
2487
|
+
decidedBy: own === null ? "product" : "initiative",
|
|
2488
|
+
meaning: builds ? "The plan is built as soon as it lands; nobody reads it first." : "The line stops at the plan for a person to approve or reject it (taiga_get_plan, taiga_approve_plan)."
|
|
2489
|
+
};
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
if (!isApiFailure$1(error)) throw error;
|
|
2492
|
+
return null;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
/** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
|
|
2496
|
+
async function placeInQueue(client, item) {
|
|
2497
|
+
try {
|
|
2498
|
+
const view = await queueView(client, String(item.productId), { name: "blocking" });
|
|
2499
|
+
return {
|
|
2500
|
+
position: view.entries.find((row) => row.backlogItemId === item.id)?.position ?? null,
|
|
2501
|
+
of: view.entries.length,
|
|
2502
|
+
waitingOn: view.waitingOn
|
|
2503
|
+
};
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
if (!isApiFailure$1(error)) throw error;
|
|
2506
|
+
return null;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
const autoBuildInput = z.boolean().nullable().describe("false stops the line at this initiative's plan for a person to read it; true builds it as soon as it lands; null follows the product's default");
|
|
2157
2510
|
function registerQueue(server, client) {
|
|
2158
2511
|
server.registerTool("taiga_queue_backlog_item", {
|
|
2159
2512
|
title: "Queue backlog item",
|
|
2160
|
-
description: "Add an initiative to the product queue, the line the coding agent works: queued initiatives are planned and built one at a time, each advancing when the previous pull request merges. This starts paid work: a planner run, then
|
|
2513
|
+
description: "Add an initiative to the product queue, the line the coding agent works: queued initiatives are planned and built one at a time, each advancing when the previous pull request merges. This starts paid work: a planner run, then a coding run that pushes commits and opens a pull request in the product repository. Whether the plan is built at once or waits for a person is the initiative's review gate: pass autoBuild: false to read the plan before anything is built (the product's default applies otherwise, and the outcome says which it is); the gate is set first and stays set if the move is then refused. Ask the person before calling this. An initiative already in the queue stays where it is unless afterId says where to go; one being planned or built cannot move (CONFLICT). Returns the initiative with its new state, its review gate and its place in the queue.",
|
|
2161
2514
|
inputSchema: {
|
|
2162
2515
|
itemId,
|
|
2163
|
-
afterId: z.string().uuid().nullable().optional().describe("The queued initiative to follow; null for the head of the queue; omit to append a new entry or leave a queued one where it is")
|
|
2516
|
+
afterId: z.string().uuid().nullable().optional().describe("The queued initiative to follow; null for the head of the queue; omit to append a new entry or leave a queued one where it is"),
|
|
2517
|
+
autoBuild: autoBuildInput.optional().describe("Set the initiative's review gate before queueing: false to read the plan first, true to build it at once, null to follow the product; omit to leave the gate as it is")
|
|
2164
2518
|
},
|
|
2165
2519
|
annotations: OPERATE
|
|
2166
2520
|
}, (params) => runTool(async () => {
|
|
2167
|
-
const { itemId: id, ...placement } = params;
|
|
2168
|
-
|
|
2521
|
+
const { itemId: id, autoBuild, ...placement } = params;
|
|
2522
|
+
if (autoBuild !== void 0) await client.setAutoBuild(id, autoBuild);
|
|
2523
|
+
const item = (await client.transitionInitiative(id, {
|
|
2169
2524
|
state: "queue",
|
|
2170
2525
|
...placement
|
|
2171
|
-
})).data
|
|
2526
|
+
})).data;
|
|
2527
|
+
const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
|
|
2528
|
+
return toolResult({
|
|
2529
|
+
...outcomeOf(item),
|
|
2530
|
+
reviewGate: gate,
|
|
2531
|
+
queue,
|
|
2532
|
+
...gate && queue ? {} : { note: "Part of the outcome could not be read; taiga_get_queue and taiga_get_product say the rest." }
|
|
2533
|
+
});
|
|
2534
|
+
}));
|
|
2535
|
+
}
|
|
2536
|
+
function registerReviewGate(server, client) {
|
|
2537
|
+
server.registerTool("taiga_set_auto_build", {
|
|
2538
|
+
title: "Set an initiative's review gate",
|
|
2539
|
+
description: "Set whether an initiative's plan is built as soon as it lands (true) or waits for a person to read it and approve or reject it (false); null follows the product's default. Standing: it applies to every plan the initiative gets, including one being written now. Opening the gate on a plan that is already waiting starts its build, so ask the person first. Returns the initiative with the gate that now applies.",
|
|
2540
|
+
inputSchema: {
|
|
2541
|
+
itemId,
|
|
2542
|
+
autoBuild: autoBuildInput
|
|
2543
|
+
},
|
|
2544
|
+
annotations: OPERATE
|
|
2545
|
+
}, (params) => runTool(async () => {
|
|
2546
|
+
const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data;
|
|
2547
|
+
const gate = await reviewGate(client, item);
|
|
2548
|
+
return toolResult({
|
|
2549
|
+
...outcomeOf(item),
|
|
2550
|
+
reviewGate: gate,
|
|
2551
|
+
...gate ? {} : { note: "The product's default could not be read; taiga_get_product says it." }
|
|
2552
|
+
});
|
|
2172
2553
|
}));
|
|
2173
2554
|
}
|
|
2174
2555
|
function registerResume(server, client) {
|
|
@@ -2239,6 +2620,31 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
2239
2620
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
2240
2621
|
const NAMED_PRODUCTS = 100;
|
|
2241
2622
|
const buildId = z.string().uuid().describe("Build run ID -- get from taiga_list_builds");
|
|
2623
|
+
/** A step without its instructions: what it is, how it stands and what it changed. */
|
|
2624
|
+
const STEP_SUMMARY_FIELDS = [
|
|
2625
|
+
"stepOrder",
|
|
2626
|
+
"title",
|
|
2627
|
+
"status",
|
|
2628
|
+
"currentPhase",
|
|
2629
|
+
"commitSha",
|
|
2630
|
+
"filesChanged",
|
|
2631
|
+
"verification",
|
|
2632
|
+
"error",
|
|
2633
|
+
"errorClassification",
|
|
2634
|
+
"startedAt",
|
|
2635
|
+
"completedAt"
|
|
2636
|
+
];
|
|
2637
|
+
/** Past this many characters a step's headline is cut; the full read has the rest. */
|
|
2638
|
+
const HEADLINE_LIMIT = 600;
|
|
2639
|
+
/**
|
|
2640
|
+
* The first paragraph of a step's summary: the agent writes its result
|
|
2641
|
+
* there (what changed, which checks passed), and the detail after it.
|
|
2642
|
+
*/
|
|
2643
|
+
function summaryHeadline(summary) {
|
|
2644
|
+
if (typeof summary !== "string" || !summary.trim()) return null;
|
|
2645
|
+
const first = summary.trim().split(/\n\s*\n/, 1)[0].trim();
|
|
2646
|
+
return first.length > HEADLINE_LIMIT ? `${first.slice(0, HEADLINE_LIMIT).trimEnd()}...` : first;
|
|
2647
|
+
}
|
|
2242
2648
|
/**
|
|
2243
2649
|
* The runs of every product the key can see, each row naming its product.
|
|
2244
2650
|
* The route carries product ids only; one products read names them, and a
|
|
@@ -2290,15 +2696,26 @@ function registerBuildTools(server, client) {
|
|
|
2290
2696
|
}));
|
|
2291
2697
|
server.registerTool("taiga_get_build", {
|
|
2292
2698
|
title: "Get build run",
|
|
2293
|
-
description: "Get one run
|
|
2294
|
-
inputSchema: {
|
|
2699
|
+
description: "Get one run with its steps: the run (status, pull request, hardening, metrics) and each step's title, status, commit, files changed, checks, any error and headline, the first paragraph of what the agent reported. Pass full: true for each step's complete instructions and report, several thousand tokens on a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it opened. Its actions list says what it accepts: stop (taiga_stop_build), resume or retry_from_failed_step (both taiga_resume_build), start_over (web UI only for now).",
|
|
2700
|
+
inputSchema: {
|
|
2701
|
+
buildId,
|
|
2702
|
+
full: z.boolean().optional().describe("Carry each step's complete instructions and report (default false: a summary)")
|
|
2703
|
+
},
|
|
2295
2704
|
annotations: READ_ONLY
|
|
2296
2705
|
}, (params) => runTool(async () => {
|
|
2297
2706
|
const [run, steps] = await Promise.all([client.getBuildRun(params.buildId), client.listBuildRunSteps(params.buildId)]);
|
|
2298
|
-
return toolResult({
|
|
2707
|
+
if (params.full) return toolResult({
|
|
2299
2708
|
...run.data,
|
|
2300
2709
|
steps: steps.data
|
|
2301
2710
|
});
|
|
2711
|
+
const rows = steps.data;
|
|
2712
|
+
return toolResult({
|
|
2713
|
+
...run.data,
|
|
2714
|
+
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
2715
|
+
...step,
|
|
2716
|
+
headline: summaryHeadline(rows[index].summary)
|
|
2717
|
+
}))
|
|
2718
|
+
});
|
|
2302
2719
|
}));
|
|
2303
2720
|
if (!client.hasCapability("products:write")) return;
|
|
2304
2721
|
server.registerTool("taiga_stop_build", {
|
|
@@ -2341,8 +2758,66 @@ const REPOSITORY_SUMMARY_FIELDS = [
|
|
|
2341
2758
|
];
|
|
2342
2759
|
/** How many of the latest runs to carry; taiga_list_builds pages the rest. */
|
|
2343
2760
|
const BUILDS_IN_CONTEXT = 10;
|
|
2761
|
+
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_build has the rest. */
|
|
2762
|
+
const CONTEXT_BUILD_FIELDS = [
|
|
2763
|
+
"id",
|
|
2764
|
+
"runNumber",
|
|
2765
|
+
"backlogItemId",
|
|
2766
|
+
"targetTitle",
|
|
2767
|
+
"status",
|
|
2768
|
+
"failedReason",
|
|
2769
|
+
"completedSteps",
|
|
2770
|
+
"totalSteps",
|
|
2771
|
+
"prUrl",
|
|
2772
|
+
"prState",
|
|
2773
|
+
"prMerged",
|
|
2774
|
+
"createdAt"
|
|
2775
|
+
];
|
|
2776
|
+
/**
|
|
2777
|
+
* Characters of section text each of the two documents may bring by default.
|
|
2778
|
+
* Two of them plus the rest of the bundle stay well inside the 25k-token
|
|
2779
|
+
* tool result Claude Code accepts (a 67k-character bundle was refused); a
|
|
2780
|
+
* larger product's documents come cut, with the sections left out named.
|
|
2781
|
+
*/
|
|
2782
|
+
const DOCUMENT_BUDGET = 12e3;
|
|
2783
|
+
/**
|
|
2784
|
+
* The document without each section's citations: on a reality document they
|
|
2785
|
+
* are the code evidence behind every claim and as long as the text itself,
|
|
2786
|
+
* which a session's opening read does not need. The full read keeps them.
|
|
2787
|
+
* Says whether any were dropped, so the bundle can tell the reader.
|
|
2788
|
+
*/
|
|
2789
|
+
function withoutCitations(doc) {
|
|
2790
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
2791
|
+
if (!sections) return {
|
|
2792
|
+
doc,
|
|
2793
|
+
dropped: false
|
|
2794
|
+
};
|
|
2795
|
+
let dropped = false;
|
|
2796
|
+
const kept = Object.fromEntries(Object.entries(sections).map(([id, section]) => {
|
|
2797
|
+
if (!isRecord(section) || !("citations" in section)) return [id, section];
|
|
2798
|
+
dropped = true;
|
|
2799
|
+
const { citations: _citations, ...rest } = section;
|
|
2800
|
+
return [id, rest];
|
|
2801
|
+
}));
|
|
2802
|
+
return {
|
|
2803
|
+
doc: {
|
|
2804
|
+
...doc,
|
|
2805
|
+
data: {
|
|
2806
|
+
...doc.data,
|
|
2807
|
+
sections: kept
|
|
2808
|
+
}
|
|
2809
|
+
},
|
|
2810
|
+
dropped
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2344
2813
|
/** Pages of 100 the backlog part follows before it stops and says so. */
|
|
2345
2814
|
const BACKLOG_PAGE_LIMIT = 10;
|
|
2815
|
+
/**
|
|
2816
|
+
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
2817
|
+
* taiga_list_backlog_items pages the rest. Keeps a product with hundreds of
|
|
2818
|
+
* open initiatives inside the tool result the document budget is sized for.
|
|
2819
|
+
*/
|
|
2820
|
+
const BACKLOG_IN_CONTEXT = 50;
|
|
2346
2821
|
const PARTS = {
|
|
2347
2822
|
factory: "factories:read",
|
|
2348
2823
|
documents: "products:read",
|
|
@@ -2396,11 +2871,11 @@ async function factoryPart(client, factoryId) {
|
|
|
2396
2871
|
}
|
|
2397
2872
|
}
|
|
2398
2873
|
/**
|
|
2399
|
-
* The specification and architecture from the product's own timeline:
|
|
2400
|
-
* by default, or as section ids when
|
|
2401
|
-
* with a note, where unpublished.
|
|
2874
|
+
* The specification and architecture from the product's own timeline: their
|
|
2875
|
+
* whole sections up to a budget by default, in full or as section ids when
|
|
2876
|
+
* asked. Null, with a note, where unpublished.
|
|
2402
2877
|
*/
|
|
2403
|
-
async function documentsPart(client, product,
|
|
2878
|
+
async function documentsPart(client, product, depth) {
|
|
2404
2879
|
const productId = String(product.id);
|
|
2405
2880
|
const origin = defaultOriginFor(product);
|
|
2406
2881
|
const reads = await Promise.allSettled(CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin)));
|
|
@@ -2410,8 +2885,26 @@ async function documentsPart(client, product, full) {
|
|
|
2410
2885
|
const read = reads[index];
|
|
2411
2886
|
if (read.status === "fulfilled") {
|
|
2412
2887
|
const doc = stripProvenance(read.value.data);
|
|
2413
|
-
if (full) out[type] = doc;
|
|
2414
|
-
else {
|
|
2888
|
+
if (depth === "full") out[type] = doc;
|
|
2889
|
+
else if (depth === "budget") {
|
|
2890
|
+
const lean = withoutCitations(doc);
|
|
2891
|
+
if (lean.dropped) notes.push(`The ${origin} ${type} leaves out each section's citations (its code evidence); taiga_get_product_document carries them.`);
|
|
2892
|
+
const cut = budgetSections(lean.doc, DOCUMENT_BUDGET);
|
|
2893
|
+
if (!cut) {
|
|
2894
|
+
const small = JSON.stringify(lean.doc).length <= DOCUMENT_BUDGET;
|
|
2895
|
+
out[type] = small ? lean.doc : {
|
|
2896
|
+
version: doc.version,
|
|
2897
|
+
sections: null
|
|
2898
|
+
};
|
|
2899
|
+
if (!small) notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
2900
|
+
} else if (cut.omittedSections.length > 0) {
|
|
2901
|
+
out[type] = {
|
|
2902
|
+
...cut.doc,
|
|
2903
|
+
omittedSections: cut.omittedSections
|
|
2904
|
+
};
|
|
2905
|
+
notes.push(`The ${origin} ${type} carries the whole sections that fit; read the ones under omittedSections with taiga_get_product_document, naming them in sections.`);
|
|
2906
|
+
} else out[type] = cut.doc;
|
|
2907
|
+
} else {
|
|
2415
2908
|
const sections = sectionsOf(doc.data ?? {});
|
|
2416
2909
|
if (sections) out[type] = {
|
|
2417
2910
|
version: doc.version,
|
|
@@ -2457,11 +2950,13 @@ const OPEN_BACKLOG_STATUSES = ["backlog", "in_progress"];
|
|
|
2457
2950
|
async function backlogPart(client, productId) {
|
|
2458
2951
|
const open = /* @__PURE__ */ new Map();
|
|
2459
2952
|
let truncated = false;
|
|
2953
|
+
let pageCapped = false;
|
|
2460
2954
|
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
2461
2955
|
let cursor;
|
|
2462
2956
|
for (let pageIndex = 0;; pageIndex += 1) {
|
|
2463
2957
|
if (pageIndex === BACKLOG_PAGE_LIMIT) {
|
|
2464
2958
|
truncated = true;
|
|
2959
|
+
pageCapped = true;
|
|
2465
2960
|
break;
|
|
2466
2961
|
}
|
|
2467
2962
|
const result = await client.listBacklogItems(productId, {
|
|
@@ -2477,9 +2972,12 @@ async function backlogPart(client, productId) {
|
|
|
2477
2972
|
cursor = result.nextCursor;
|
|
2478
2973
|
}
|
|
2479
2974
|
}
|
|
2975
|
+
const rows = [...open.values()].sort((a, b) => String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0);
|
|
2976
|
+
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
2480
2977
|
return {
|
|
2481
|
-
items:
|
|
2482
|
-
truncated
|
|
2978
|
+
items: itemRows(rows.slice(-50), BACKLOG_SUMMARY_FIELDS),
|
|
2979
|
+
truncated,
|
|
2980
|
+
...pageCapped ? { note: "More than 1000 open items in one status: only the oldest of them were read, so the newest may be missing here; taiga_list_backlog_items pages the rest." } : {}
|
|
2483
2981
|
};
|
|
2484
2982
|
}
|
|
2485
2983
|
/**
|
|
@@ -2495,7 +2993,7 @@ async function repositoriesPart(client, productId) {
|
|
|
2495
2993
|
}
|
|
2496
2994
|
async function buildsPart(client, productId) {
|
|
2497
2995
|
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
2498
|
-
return pickFields(rows,
|
|
2996
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS);
|
|
2499
2997
|
}
|
|
2500
2998
|
async function productContext(client, product, options) {
|
|
2501
2999
|
const productId = String(product.id);
|
|
@@ -2503,7 +3001,7 @@ async function productContext(client, product, options) {
|
|
|
2503
3001
|
const notGranted = Object.keys(PARTS).filter((part) => !granted(part));
|
|
2504
3002
|
const fetchers = {
|
|
2505
3003
|
factory: () => factoryPart(client, String(product.factoryId)),
|
|
2506
|
-
documents: () => documentsPart(client, product, options.
|
|
3004
|
+
documents: () => documentsPart(client, product, options.documents),
|
|
2507
3005
|
policies: () => policiesPart(client),
|
|
2508
3006
|
backlog: () => backlogPart(client, productId),
|
|
2509
3007
|
repositories: () => repositoriesPart(client, productId),
|
|
@@ -2523,23 +3021,35 @@ async function productContext(client, product, options) {
|
|
|
2523
3021
|
else if (isApiFailure(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
2524
3022
|
else throw result.reason;
|
|
2525
3023
|
});
|
|
2526
|
-
|
|
3024
|
+
const result = {
|
|
2527
3025
|
...bundle,
|
|
2528
3026
|
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
2529
3027
|
unavailable,
|
|
2530
|
-
readNext: "Read a policy in full with taiga_get_document, another product document with taiga_get_product_document, a backlog item with taiga_get_backlog_item and a run with taiga_get_build."
|
|
3028
|
+
readNext: "Read a policy in full with taiga_get_document, another product document with taiga_get_product_document, a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run with taiga_get_build."
|
|
3029
|
+
};
|
|
3030
|
+
let characters = JSON.stringify(result, null, 2).length;
|
|
3031
|
+
for (let measured = -1; measured !== characters;) {
|
|
3032
|
+
measured = characters;
|
|
3033
|
+
characters = JSON.stringify({
|
|
3034
|
+
...result,
|
|
3035
|
+
characters: measured
|
|
3036
|
+
}, null, 2).length;
|
|
3037
|
+
}
|
|
3038
|
+
return toolResult({
|
|
3039
|
+
...result,
|
|
3040
|
+
characters
|
|
2531
3041
|
});
|
|
2532
3042
|
}
|
|
2533
3043
|
function registerContextTools(server, client) {
|
|
2534
3044
|
if (!client.hasCapability("products:read")) return;
|
|
2535
3045
|
server.registerTool("taiga_get_product_context", {
|
|
2536
3046
|
title: "Get product context",
|
|
2537
|
-
description:
|
|
3047
|
+
description: `Everything a coding session starts from, in one call: the product and which document timeline is its own, its factory with the instructions in full, the specification and architecture from that timeline, every published policy as its section ids, the open backlog items, the linked repositories and the latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused or failed is named under unavailable with what it said; a part the key cannot read is named under notGranted; neither is shown as empty. The two documents bring as many whole sections as fit, about ${DOCUMENT_BUDGET / 1e3}k characters each, and name the sections left out (omittedSections) for taiga_get_product_document to read; fullDocuments: true carries them whole, tens of thousands of tokens on a large product and more than some clients accept, and false carries their section ids only. characters says how large the result is. Call this first, then read what you need.`,
|
|
2538
3048
|
inputSchema: {
|
|
2539
3049
|
productId: productId.optional(),
|
|
2540
3050
|
slug: slug.optional(),
|
|
2541
3051
|
remoteUrl: remoteUrl.optional(),
|
|
2542
|
-
fullDocuments: z.boolean().
|
|
3052
|
+
fullDocuments: z.boolean().optional().describe("true carries the specification and architecture whole, false as section ids only; omit for the whole sections that fit a budget")
|
|
2543
3053
|
},
|
|
2544
3054
|
annotations: READ_ONLY
|
|
2545
3055
|
}, (params) => runTool(async () => {
|
|
@@ -2549,7 +3059,8 @@ function registerContextTools(server, client) {
|
|
|
2549
3059
|
"remoteUrl"
|
|
2550
3060
|
]);
|
|
2551
3061
|
if ("refusal" in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
2552
|
-
|
|
3062
|
+
const documents = params.fullDocuments === void 0 ? "budget" : params.fullDocuments ? "full" : "sectionIds";
|
|
3063
|
+
return productContext(client, resolved.product, { documents });
|
|
2553
3064
|
}));
|
|
2554
3065
|
}
|
|
2555
3066
|
//#endregion
|
|
@@ -17187,7 +17698,7 @@ function registerPrompts(server, client) {
|
|
|
17187
17698
|
"item you are implementing with taiga_get_backlog_item. Parts listed under notGranted or unavailable",
|
|
17188
17699
|
"could not be read; say so rather than assuming they are empty.",
|
|
17189
17700
|
"",
|
|
17190
|
-
fence("json", (await productContext(client, resolved.product, {
|
|
17701
|
+
fence("json", (await productContext(client, resolved.product, { documents: "full" })).content[0]?.text ?? "{}")
|
|
17191
17702
|
].join("\n"));
|
|
17192
17703
|
});
|
|
17193
17704
|
if (client.hasCapability("backlog:read") && client.hasCapability("products:read")) server.registerPrompt("taiga_implement_backlog_item", {
|
|
@@ -17379,17 +17890,357 @@ function registerResources(server, client) {
|
|
|
17379
17890
|
});
|
|
17380
17891
|
}
|
|
17381
17892
|
//#endregion
|
|
17893
|
+
//#region src/tools/wait.ts
|
|
17894
|
+
const DEFAULT_WAIT_OPTIONS = { pollIntervalMs: 5e3 };
|
|
17895
|
+
/**
|
|
17896
|
+
* Under the 60 seconds many clients allow a call by default (the MCP SDK's
|
|
17897
|
+
* own request timeout), with room for the last read.
|
|
17898
|
+
*/
|
|
17899
|
+
const DEFAULT_TIMEOUT_SECONDS = 50;
|
|
17900
|
+
const timeoutSeconds = z.number().int().min(1).max(600).optional().describe(`How long to wait at most (default ${DEFAULT_TIMEOUT_SECONDS}). Keep it under your client's tool-call limit; many stop a call at 60 seconds.`);
|
|
17901
|
+
function pause(ms, signal) {
|
|
17902
|
+
return new Promise((resolve) => {
|
|
17903
|
+
if (signal.aborted) return resolve();
|
|
17904
|
+
const timer = setTimeout(done, ms);
|
|
17905
|
+
function done() {
|
|
17906
|
+
clearTimeout(timer);
|
|
17907
|
+
signal.removeEventListener("abort", done);
|
|
17908
|
+
resolve();
|
|
17909
|
+
}
|
|
17910
|
+
signal.addEventListener("abort", done, { once: true });
|
|
17911
|
+
});
|
|
17912
|
+
}
|
|
17913
|
+
/** Failed reads in a row a wait rides out before it gives up and says why. */
|
|
17914
|
+
const TRANSIENT_FAILURES_TOLERATED = 3;
|
|
17915
|
+
/** A failure the next read may not hit: rate limited, the API erroring or unreachable. Not a refusal or a miss. */
|
|
17916
|
+
function isTransient(error) {
|
|
17917
|
+
if (isNetworkFailure(error)) return true;
|
|
17918
|
+
return error instanceof TaigaApiError && (error.status === 429 || error.status >= 500);
|
|
17919
|
+
}
|
|
17920
|
+
/**
|
|
17921
|
+
* Read until `judge` gives a verdict or the time is up. Every read is bound
|
|
17922
|
+
* by what is left of the timeout and by the call's cancellation, so a slow
|
|
17923
|
+
* API cannot hold the wait past either: a read cut short ends the wait with
|
|
17924
|
+
* the last state seen (none, when not even the first read came back). A
|
|
17925
|
+
* read that fails transiently is ridden out, a few in a row and the first
|
|
17926
|
+
* read included, so one 502 does not throw away minutes of waiting; a
|
|
17927
|
+
* refusal fails as it comes. Each
|
|
17928
|
+
* read that leaves the wait open reports progress when the client asked for
|
|
17929
|
+
* it, which also keeps a client that resets its timeout on progress from
|
|
17930
|
+
* cutting the wait short.
|
|
17931
|
+
*/
|
|
17932
|
+
async function waitFor(read, judge, timeoutMs, options, progress) {
|
|
17933
|
+
const started = Date.now();
|
|
17934
|
+
const deadline = started + timeoutMs;
|
|
17935
|
+
let first = null;
|
|
17936
|
+
let now = null;
|
|
17937
|
+
let verdict = null;
|
|
17938
|
+
let failures = 0;
|
|
17939
|
+
for (let attempt = 0; !progress.signal.aborted; attempt += 1) {
|
|
17940
|
+
if (attempt > 0) {
|
|
17941
|
+
const left = deadline - Date.now();
|
|
17942
|
+
if (left <= 0) break;
|
|
17943
|
+
await pause(Math.min(options.pollIntervalMs, left), progress.signal);
|
|
17944
|
+
}
|
|
17945
|
+
const remaining = deadline - Date.now();
|
|
17946
|
+
if (progress.signal.aborted || remaining <= 0) break;
|
|
17947
|
+
const bound = AbortSignal.timeout(remaining);
|
|
17948
|
+
try {
|
|
17949
|
+
now = await read(AbortSignal.any([progress.signal, bound]));
|
|
17950
|
+
first ??= now;
|
|
17951
|
+
failures = 0;
|
|
17952
|
+
} catch (error) {
|
|
17953
|
+
if (bound.aborted || progress.signal.aborted) break;
|
|
17954
|
+
failures += 1;
|
|
17955
|
+
if (!isTransient(error) || failures >= TRANSIENT_FAILURES_TOLERATED) throw error;
|
|
17956
|
+
continue;
|
|
17957
|
+
}
|
|
17958
|
+
verdict = judge(now, first);
|
|
17959
|
+
if (verdict) break;
|
|
17960
|
+
if (attempt > 0 && progress.progressToken !== void 0) await progress.sendNotification({
|
|
17961
|
+
method: "notifications/progress",
|
|
17962
|
+
params: {
|
|
17963
|
+
progressToken: progress.progressToken,
|
|
17964
|
+
progress: Date.now() - started,
|
|
17965
|
+
total: timeoutMs,
|
|
17966
|
+
message: "Still waiting"
|
|
17967
|
+
}
|
|
17968
|
+
}).catch(() => void 0);
|
|
17969
|
+
}
|
|
17970
|
+
const waitedSeconds = Math.round((Date.now() - started) / 1e3);
|
|
17971
|
+
if (verdict) return {
|
|
17972
|
+
...verdict,
|
|
17973
|
+
waitedSeconds,
|
|
17974
|
+
now
|
|
17975
|
+
};
|
|
17976
|
+
return {
|
|
17977
|
+
reached: false,
|
|
17978
|
+
stoppedBecause: progress.signal.aborted ? "cancelled" : "timeout",
|
|
17979
|
+
waitedSeconds,
|
|
17980
|
+
now
|
|
17981
|
+
};
|
|
17982
|
+
}
|
|
17983
|
+
const SETTLED_RUN = new Set(BUILD_RUN_TERMINAL_STATUSES);
|
|
17984
|
+
/**
|
|
17985
|
+
* A pull request that is open now. A run keeps its prUrl after the pull
|
|
17986
|
+
* request merges or closes, so the URL alone says one existed, not that it
|
|
17987
|
+
* is open; the mirrored state and the merged latch say which. An unknown
|
|
17988
|
+
* state (null, just opened) counts as open.
|
|
17989
|
+
*/
|
|
17990
|
+
function prIsOpen(run) {
|
|
17991
|
+
return typeof run.prUrl === "string" && run.prUrl !== "" && run.prMerged !== true && run.prState !== "closed";
|
|
17992
|
+
}
|
|
17993
|
+
const ITEM_CONDITIONS = [
|
|
17994
|
+
"changed",
|
|
17995
|
+
"planned",
|
|
17996
|
+
"your_turn",
|
|
17997
|
+
"pr_open",
|
|
17998
|
+
"run_settled",
|
|
17999
|
+
"done"
|
|
18000
|
+
];
|
|
18001
|
+
const latestBuildOf = (item) => item.lifecycle?.latestBuild ?? null;
|
|
18002
|
+
const lifecycleOf = (item) => item.lifecycle ?? {};
|
|
18003
|
+
/** What "changed" compares: where the item is, whose turn it is and how its latest run stands. */
|
|
18004
|
+
function itemSignature(item) {
|
|
18005
|
+
const lifecycle = lifecycleOf(item);
|
|
18006
|
+
const run = latestBuildOf(item);
|
|
18007
|
+
return JSON.stringify([
|
|
18008
|
+
item.state,
|
|
18009
|
+
item.planningStatus,
|
|
18010
|
+
item.needsHumanCause,
|
|
18011
|
+
lifecycle.stage,
|
|
18012
|
+
lifecycle.turn,
|
|
18013
|
+
lifecycle.cta,
|
|
18014
|
+
run?.id,
|
|
18015
|
+
run?.status,
|
|
18016
|
+
run?.completedSteps,
|
|
18017
|
+
run?.prUrl,
|
|
18018
|
+
run?.prState,
|
|
18019
|
+
run?.prMerged
|
|
18020
|
+
]);
|
|
18021
|
+
}
|
|
18022
|
+
/**
|
|
18023
|
+
* Whether the initiative waits on a person now: yes, no, or unknown. The
|
|
18024
|
+
* lifecycle says `turn: 'you'` for every landed plan (`start_build`), but a
|
|
18025
|
+
* plan whose review gate is open is waiting for the Build slot, not for
|
|
18026
|
+
* anyone: the queue keeps such an entry active and starts it on its own. When
|
|
18027
|
+
* the gate cannot be resolved (no gate of its own, and the product's default
|
|
18028
|
+
* unreadable) the answer is unknown, and the wait says so rather than guess.
|
|
18029
|
+
*/
|
|
18030
|
+
function personTurn(item, gate) {
|
|
18031
|
+
const lifecycle = lifecycleOf(item);
|
|
18032
|
+
if (lifecycle.turn !== "you") return "no";
|
|
18033
|
+
if (lifecycle.cta !== "start_build") return "yes";
|
|
18034
|
+
const own = typeof item.autoBuild === "boolean" ? item.autoBuild : null;
|
|
18035
|
+
const product = typeof gate.product === "boolean" ? gate.product : null;
|
|
18036
|
+
if (own === null && product === null) return "unknown";
|
|
18037
|
+
return resolveAutoBuild({
|
|
18038
|
+
initiative: own,
|
|
18039
|
+
product: product ?? true
|
|
18040
|
+
}) ? "no" : "yes";
|
|
18041
|
+
}
|
|
18042
|
+
function itemHolds(until, item, first, gate) {
|
|
18043
|
+
const lifecycle = lifecycleOf(item);
|
|
18044
|
+
const run = latestBuildOf(item);
|
|
18045
|
+
switch (until) {
|
|
18046
|
+
case "changed": return itemSignature(item) !== itemSignature(first);
|
|
18047
|
+
case "planned": return item.planningStatus === "planned";
|
|
18048
|
+
case "your_turn": return personTurn(item, gate) === "yes";
|
|
18049
|
+
case "pr_open": return run !== null && prIsOpen(run);
|
|
18050
|
+
case "run_settled": return run !== null && SETTLED_RUN.has(String(run.status));
|
|
18051
|
+
case "done": return lifecycle.stage === "done";
|
|
18052
|
+
}
|
|
18053
|
+
}
|
|
18054
|
+
function judgeItem(until, gate) {
|
|
18055
|
+
return (item, first) => {
|
|
18056
|
+
if (itemHolds(until, item, first, gate)) return { reached: true };
|
|
18057
|
+
if (until === "changed") return null;
|
|
18058
|
+
if (typeof item.needsHumanCause === "string") return {
|
|
18059
|
+
reached: false,
|
|
18060
|
+
stoppedBecause: `needs a person: ${item.needsHumanCause}`
|
|
18061
|
+
};
|
|
18062
|
+
const lifecycle = lifecycleOf(item);
|
|
18063
|
+
const turn = personTurn(item, gate);
|
|
18064
|
+
if (until !== "your_turn" && turn === "yes") return {
|
|
18065
|
+
reached: false,
|
|
18066
|
+
stoppedBecause: `waits on a person: ${String(lifecycle.cta ?? lifecycle.stage)}`
|
|
18067
|
+
};
|
|
18068
|
+
if (turn === "unknown") return {
|
|
18069
|
+
reached: false,
|
|
18070
|
+
stoppedBecause: "the plan has landed, and whether it waits for a review is unknown: the product's default could not be read (it needs products:read)"
|
|
18071
|
+
};
|
|
18072
|
+
if (until === "planned" && item.planningStatus === "failed") return {
|
|
18073
|
+
reached: false,
|
|
18074
|
+
stoppedBecause: "planning failed"
|
|
18075
|
+
};
|
|
18076
|
+
const stage = lifecycleOf(item).stage;
|
|
18077
|
+
if (stage === "canceled" || until !== "done" && stage === "done") return {
|
|
18078
|
+
reached: false,
|
|
18079
|
+
stoppedBecause: `the initiative is ${String(stage)}`
|
|
18080
|
+
};
|
|
18081
|
+
return null;
|
|
18082
|
+
};
|
|
18083
|
+
}
|
|
18084
|
+
/** The initiative as a wait reports it: where it is, whose turn, its latest run and its criteria. */
|
|
18085
|
+
function itemReport(item) {
|
|
18086
|
+
const run = latestBuildOf(item);
|
|
18087
|
+
return {
|
|
18088
|
+
...pickFields([item], [
|
|
18089
|
+
"id",
|
|
18090
|
+
"title",
|
|
18091
|
+
"state",
|
|
18092
|
+
"planningStatus",
|
|
18093
|
+
"needsHumanCause",
|
|
18094
|
+
"needsHumanDetail",
|
|
18095
|
+
"autoBuild"
|
|
18096
|
+
])[0],
|
|
18097
|
+
lifecycle: pickFields([lifecycleOf(item)], [
|
|
18098
|
+
"turn",
|
|
18099
|
+
"stage",
|
|
18100
|
+
"cta",
|
|
18101
|
+
"planFailureCause"
|
|
18102
|
+
])[0],
|
|
18103
|
+
latestBuild: run ? pickFields([run], [
|
|
18104
|
+
"id",
|
|
18105
|
+
"status",
|
|
18106
|
+
"completedSteps",
|
|
18107
|
+
"totalSteps",
|
|
18108
|
+
"prUrl",
|
|
18109
|
+
"prState",
|
|
18110
|
+
"prMerged",
|
|
18111
|
+
"actions"
|
|
18112
|
+
])[0] : null,
|
|
18113
|
+
criteria: criteriaCounts(item)
|
|
18114
|
+
};
|
|
18115
|
+
}
|
|
18116
|
+
const BUILD_CONDITIONS = [
|
|
18117
|
+
"changed",
|
|
18118
|
+
"step_done",
|
|
18119
|
+
"pr_open",
|
|
18120
|
+
"settled"
|
|
18121
|
+
];
|
|
18122
|
+
const RUN_REPORT_FIELDS = [
|
|
18123
|
+
"id",
|
|
18124
|
+
"backlogItemId",
|
|
18125
|
+
"targetTitle",
|
|
18126
|
+
"status",
|
|
18127
|
+
"failedReason",
|
|
18128
|
+
"completedSteps",
|
|
18129
|
+
"totalSteps",
|
|
18130
|
+
"prUrl",
|
|
18131
|
+
"prState",
|
|
18132
|
+
"prDraft",
|
|
18133
|
+
"prMerged",
|
|
18134
|
+
"actions"
|
|
18135
|
+
];
|
|
18136
|
+
const runSignature = (run) => JSON.stringify([
|
|
18137
|
+
run.status,
|
|
18138
|
+
run.completedSteps,
|
|
18139
|
+
run.prUrl,
|
|
18140
|
+
run.prState,
|
|
18141
|
+
run.prMerged
|
|
18142
|
+
]);
|
|
18143
|
+
function judgeBuild(until) {
|
|
18144
|
+
return (run, first) => {
|
|
18145
|
+
if (until === "changed" ? runSignature(run) !== runSignature(first) : until === "step_done" ? Number(run.completedSteps ?? 0) > Number(first.completedSteps ?? 0) : until === "pr_open" ? prIsOpen(run) : SETTLED_RUN.has(String(run.status))) return { reached: true };
|
|
18146
|
+
if (until !== "changed" && SETTLED_RUN.has(String(run.status))) return {
|
|
18147
|
+
reached: false,
|
|
18148
|
+
stoppedBecause: `the run is ${String(run.status)}`
|
|
18149
|
+
};
|
|
18150
|
+
return null;
|
|
18151
|
+
};
|
|
18152
|
+
}
|
|
18153
|
+
function registerWaitTools(server, client, options) {
|
|
18154
|
+
if (client.hasCapability("backlog:read")) server.registerTool("taiga_wait_for_backlog_item", {
|
|
18155
|
+
title: "Wait for an initiative",
|
|
18156
|
+
description: "Wait until an initiative reaches a point, 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.",
|
|
18157
|
+
inputSchema: {
|
|
18158
|
+
itemId: z.string().uuid().describe("Backlog item ID -- get from taiga_list_backlog_items"),
|
|
18159
|
+
until: z.enum(ITEM_CONDITIONS).describe("What to wait for"),
|
|
18160
|
+
timeoutSeconds
|
|
18161
|
+
},
|
|
18162
|
+
annotations: READ_ONLY
|
|
18163
|
+
}, (params, extra) => runTool(async () => {
|
|
18164
|
+
const gate = { product: void 0 };
|
|
18165
|
+
const read = async (signal) => {
|
|
18166
|
+
const item = (await client.getBacklogItem(params.itemId, signal)).data;
|
|
18167
|
+
if (gate.product === void 0 && typeof item.autoBuild !== "boolean") gate.product = client.hasCapability("products:read") ? await client.getProduct(String(item.productId), signal).then((p) => {
|
|
18168
|
+
const value = p.data.autoBuild;
|
|
18169
|
+
return typeof value === "boolean" ? value : true;
|
|
18170
|
+
}).catch((error) => {
|
|
18171
|
+
if (error instanceof TaigaApiError && (error.status === 403 || error.status === 404)) return null;
|
|
18172
|
+
throw error;
|
|
18173
|
+
}) : null;
|
|
18174
|
+
return item;
|
|
18175
|
+
};
|
|
18176
|
+
const { now, ...outcome } = await waitFor(read, judgeItem(params.until, gate), (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1e3, options, {
|
|
18177
|
+
progressToken: extra._meta?.progressToken,
|
|
18178
|
+
sendNotification: extra.sendNotification,
|
|
18179
|
+
signal: extra.signal
|
|
18180
|
+
});
|
|
18181
|
+
return toolResult({
|
|
18182
|
+
until: params.until,
|
|
18183
|
+
...outcome,
|
|
18184
|
+
item: now ? itemReport(now) : null
|
|
18185
|
+
});
|
|
18186
|
+
}));
|
|
18187
|
+
if (client.hasCapability("products:read")) server.registerTool("taiga_wait_for_build", {
|
|
18188
|
+
title: "Wait for a run",
|
|
18189
|
+
description: "Wait until a coding agent's run reaches a point: 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.",
|
|
18190
|
+
inputSchema: {
|
|
18191
|
+
buildId: z.string().uuid().describe("Build run ID -- get from taiga_list_builds"),
|
|
18192
|
+
until: z.enum(BUILD_CONDITIONS).describe("What to wait for"),
|
|
18193
|
+
timeoutSeconds
|
|
18194
|
+
},
|
|
18195
|
+
annotations: READ_ONLY
|
|
18196
|
+
}, (params, extra) => runTool(async () => {
|
|
18197
|
+
const { now, ...outcome } = await waitFor(async (signal) => (await client.getBuildRun(params.buildId, signal)).data, judgeBuild(params.until), (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1e3, options, {
|
|
18198
|
+
progressToken: extra._meta?.progressToken,
|
|
18199
|
+
sendNotification: extra.sendNotification,
|
|
18200
|
+
signal: extra.signal
|
|
18201
|
+
});
|
|
18202
|
+
return toolResult({
|
|
18203
|
+
until: params.until,
|
|
18204
|
+
...outcome,
|
|
18205
|
+
run: now ? pickFields([now], RUN_REPORT_FIELDS)[0] : null
|
|
18206
|
+
});
|
|
18207
|
+
}));
|
|
18208
|
+
}
|
|
18209
|
+
//#endregion
|
|
17382
18210
|
//#region src/tools/index.ts
|
|
17383
18211
|
/**
|
|
18212
|
+
* Every tool refuses an argument it does not take. A plain object schema
|
|
18213
|
+
* strips one silently: a review note passed to a tool with no place for it
|
|
18214
|
+
* vanished and the call still succeeded, and a misspelt option quietly
|
|
18215
|
+
* became its default. Strict, the call fails with "Unrecognized key" and the
|
|
18216
|
+
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
18217
|
+
* registered without it.
|
|
18218
|
+
*/
|
|
18219
|
+
function withStrictInputs(server) {
|
|
18220
|
+
const register = server.registerTool.bind(server);
|
|
18221
|
+
server.registerTool = ((name, config, callback) => {
|
|
18222
|
+
const shape = config.inputSchema;
|
|
18223
|
+
const isRawShape = typeof shape === "object" && shape !== null && !("_zod" in shape);
|
|
18224
|
+
return register(name, isRawShape ? {
|
|
18225
|
+
...config,
|
|
18226
|
+
inputSchema: z.strictObject(shape)
|
|
18227
|
+
} : config, callback);
|
|
18228
|
+
});
|
|
18229
|
+
return server;
|
|
18230
|
+
}
|
|
18231
|
+
/**
|
|
17384
18232
|
* Register all MCP tools, resources and prompts based on the API key's effective capabilities.
|
|
17385
18233
|
* Only tools matching the key's scopes are registered -- the AI assistant
|
|
17386
18234
|
* never sees tools it can't use.
|
|
17387
18235
|
*/
|
|
17388
|
-
function registerAllTools(server, client) {
|
|
18236
|
+
function registerAllTools(server, client, options = {}) {
|
|
18237
|
+
withStrictInputs(server);
|
|
17389
18238
|
registerProductTools(server, client);
|
|
17390
18239
|
registerDocumentTools(server, client);
|
|
17391
18240
|
registerBacklogTools(server, client);
|
|
17392
18241
|
registerBuildTools(server, client);
|
|
18242
|
+
registerQueueTools(server, client);
|
|
18243
|
+
registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
|
|
17393
18244
|
registerContextTools(server, client);
|
|
17394
18245
|
registerResources(server, client);
|
|
17395
18246
|
registerPrompts(server, client);
|
|
@@ -17435,12 +18286,19 @@ const SERVER_INSTRUCTIONS = [
|
|
|
17435
18286
|
"every product the key can see, and active: true the ones executing now.",
|
|
17436
18287
|
"",
|
|
17437
18288
|
"**Operating the line** (taiga_create_backlog_item / taiga_queue_backlog_item / taiga_move_backlog_item /",
|
|
17438
|
-
"taiga_resume_backlog_item / taiga_get_plan / taiga_approve_plan / taiga_reject_plan /
|
|
17439
|
-
"taiga_resume_build): create an initiative, put it in the queue the coding agent works,
|
|
17440
|
-
"
|
|
17441
|
-
"
|
|
17442
|
-
"
|
|
17443
|
-
"
|
|
18289
|
+
"taiga_resume_backlog_item / taiga_set_auto_build / taiga_get_plan / taiga_approve_plan / taiga_reject_plan /",
|
|
18290
|
+
"taiga_stop_build / taiga_resume_build): create an initiative, put it in the queue the coding agent works,",
|
|
18291
|
+
"move it on the board, decide whether its plan waits for a review (autoBuild: false) or is built at once, read",
|
|
18292
|
+
"and approve or reject its plan, stop or resume a run. Queueing, approving and resuming start paid agent work",
|
|
18293
|
+
"that pushes commits and opens pull requests; stopping interrupts it. Tell the person what you are about to",
|
|
18294
|
+
"start or stop and wait for their yes before calling these; a client that prompts on destructive tools does",
|
|
18295
|
+
"that for you. A refusal (CONFLICT) carries the API reason: say it as it is.",
|
|
18296
|
+
"",
|
|
18297
|
+
"**Watching the line** (taiga_get_queue / taiga_wait_for_backlog_item / taiga_wait_for_build): the queue",
|
|
18298
|
+
"read says what the line is waiting on (a plan to review, a pull request to merge, a halt and what lifts",
|
|
18299
|
+
"it). Planning takes minutes and a build up to an hour: wait with the wait tools rather than reading the",
|
|
18300
|
+
"same item again and again; each returns when the point is reached, when only a person can move it on,",
|
|
18301
|
+
"or at its timeout (call again to keep waiting).",
|
|
17444
18302
|
"",
|
|
17445
18303
|
"**Sections.** Policies and instructions are maps of sections. Each section carries its text and two",
|
|
17446
18304
|
"provenance fields you can read past: `source` says where the text originally came from (template:",
|
|
@@ -17452,7 +18310,8 @@ const SERVER_INSTRUCTIONS = [
|
|
|
17452
18310
|
"",
|
|
17453
18311
|
"Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout",
|
|
17454
18312
|
"(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the",
|
|
17455
|
-
"factory instructions, the specification and architecture
|
|
18313
|
+
"factory instructions, the specification and architecture as the whole sections that fit (the rest named for",
|
|
18314
|
+
"taiga_get_product_document to read by section), every policy as its section ids, the open backlog",
|
|
17456
18315
|
"items, the repositories and the latest runs in one call, and names any part it could not read. Then read",
|
|
17457
18316
|
"what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.",
|
|
17458
18317
|
"Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,",
|