@taiga-ai/mcp-server 0.23.0-rc.460 → 0.24.0-dev.461
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 +119 -29
- package/package.json +1 -1
- package/src/server.integration.test.ts +231 -7
- package/src/tools/backlogs.ts +17 -1
- package/src/tools/builds.ts +18 -3
- package/src/tools/errors.ts +16 -1
- package/src/tools/hints.ts +3 -3
- package/src/tools/inbox.ts +19 -2
- package/src/tools/inputs.ts +14 -11
- package/src/tools/instructions.ts +8 -8
- package/src/tools/maintenance.ts +16 -0
- package/src/tools/repositories.ts +46 -10
- package/src/tools/sections.ts +33 -2
- package/taiga-ai-mcp-server-0.24.0-dev.461.tgz +0 -0
- package/taiga-ai-mcp-server-0.23.0-rc.460.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -583,14 +583,14 @@ const OPERATE_SETTING = {
|
|
|
583
583
|
* schema's message, not the description, when a call fails validation, and
|
|
584
584
|
* "Invalid UUID" alone sends a model that passed a name to guess again.
|
|
585
585
|
*/
|
|
586
|
-
function idInput(what, from) {
|
|
587
|
-
return z.string().uuid({ message: `${/^[aeiou]/i.test(what) ? "An" : "A"} ${what} ID is a UUID, not a name: ${from}` }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${
|
|
586
|
+
function idInput(what, from, said = from) {
|
|
587
|
+
return z.string().uuid({ message: `${/^[aeiou]/i.test(what) ? "An" : "A"} ${what} ID is a UUID, not a name: ${from}` }).meta({ pattern: void 0 }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${said}`);
|
|
588
588
|
}
|
|
589
|
-
const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it");
|
|
589
|
+
const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it", "taiga_list_products gives it");
|
|
590
590
|
/** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
|
|
591
591
|
const pagination = {
|
|
592
592
|
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)"),
|
|
593
|
-
cursor: z.string().max(256).optional().describe("The
|
|
593
|
+
cursor: z.string().max(256).optional().describe("The previous page's nextCursor; omit for the first page")
|
|
594
594
|
};
|
|
595
595
|
const slug = z.string().max(63).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "A slug is lowercase words joined by hyphens").describe("Product slug, as it appears in Taiga URLs");
|
|
596
596
|
const remoteUrl = z.string().min(1).max(2048).describe("The git remote URL of a checkout (git remote get-url origin): https://, git@host:owner/repo.git or ssh://");
|
|
@@ -2278,9 +2278,9 @@ const BY_ARGUMENT = {
|
|
|
2278
2278
|
initiativeId: ITEM_ID_HINT,
|
|
2279
2279
|
runId: "taiga_list_runs lists the runs, of one product or of every product the key can see.",
|
|
2280
2280
|
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
2281
|
-
repositoryId: "taiga_list_product_repositories lists the product's repository links; a
|
|
2281
|
+
repositoryId: "taiga_list_product_repositories lists the product's repository links; a linkable repository id is not a link ID.",
|
|
2282
2282
|
replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
|
|
2283
|
-
|
|
2283
|
+
linkableRepositoryId: "taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.",
|
|
2284
2284
|
installationId: "taiga_list_linkable_repositories lists the connected GitHub accounts.",
|
|
2285
2285
|
jobId: "A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).",
|
|
2286
2286
|
factoryId: "taiga_list_factories lists the factories the key can see.",
|
|
@@ -2307,7 +2307,7 @@ const MISSING_THING = {
|
|
|
2307
2307
|
planId: /\bplan\b/i,
|
|
2308
2308
|
repositoryId: /repository link|\blink\b/i,
|
|
2309
2309
|
replacesRepositoryId: /repository link|\blink\b/i,
|
|
2310
|
-
|
|
2310
|
+
linkableRepositoryId: /github repository/i,
|
|
2311
2311
|
installationId: /installation|account/i,
|
|
2312
2312
|
jobId: /\bjob\b/i,
|
|
2313
2313
|
factoryId: /factory|team/i,
|
|
@@ -2365,10 +2365,24 @@ const OPAQUE_MAPS = /* @__PURE__ */ new Set([
|
|
|
2365
2365
|
"settings"
|
|
2366
2366
|
]);
|
|
2367
2367
|
/** A copy of an API value with its keys in the product's words, values untouched. */
|
|
2368
|
+
/**
|
|
2369
|
+
* Run actions only the web offers (PRO-2800): start_over was listed beside what an agent can call, on runs and on
|
|
2370
|
+
* every initiative's latest run, while the descriptions said it was web only. Split apart wherever actions appear.
|
|
2371
|
+
*/
|
|
2372
|
+
const WEB_ONLY_ACTIONS = /* @__PURE__ */ new Set(["start_over"]);
|
|
2368
2373
|
function inProductWords(value) {
|
|
2369
2374
|
if (Array.isArray(value)) return value.map(inProductWords);
|
|
2370
2375
|
if (value === null || typeof value !== "object") return value;
|
|
2371
2376
|
const out = {};
|
|
2377
|
+
const actions = value["actions"];
|
|
2378
|
+
if (Array.isArray(actions) && actions.some((action) => WEB_ONLY_ACTIONS.has(String(action)))) {
|
|
2379
|
+
const webOnly = actions.filter((action) => WEB_ONLY_ACTIONS.has(String(action)));
|
|
2380
|
+
value = {
|
|
2381
|
+
...value,
|
|
2382
|
+
actions: actions.filter((action) => !WEB_ONLY_ACTIONS.has(String(action))),
|
|
2383
|
+
webOnlyActions: webOnly
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2372
2386
|
for (const [key, inner] of Object.entries(value)) Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
|
|
2373
2387
|
value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
|
|
2374
2388
|
enumerable: true,
|
|
@@ -2684,6 +2698,24 @@ function controlSections(sections, ids) {
|
|
|
2684
2698
|
return found;
|
|
2685
2699
|
}
|
|
2686
2700
|
/**
|
|
2701
|
+
* A policy outline names the controls each section states (PRO-2800): a reviewer guessed secretsManagement from its
|
|
2702
|
+
* name, which works for secrets and not for "what covers logging of secret access". Read off each section's text.
|
|
2703
|
+
*/
|
|
2704
|
+
function withControls(outline, sections, policyType) {
|
|
2705
|
+
const pattern = new RegExp(`^\\|\\s*(${CONTROL_ID_SOURCE})\\s*\\|\\s*(.*?)\\s*(?:\\||$)`, "gm");
|
|
2706
|
+
return outline.map((entry) => {
|
|
2707
|
+
const text = sectionContent(sections, entry.id) ?? "";
|
|
2708
|
+
const ids = [...new Set([...text.matchAll(pattern)].filter((match) => !/^_?Reserved_?/i.test(match[2] ?? "")).map((match) => match[1]).filter((id) => {
|
|
2709
|
+
const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split("-")[0] ?? ""];
|
|
2710
|
+
return owner === void 0 || owner === policyType;
|
|
2711
|
+
}))];
|
|
2712
|
+
return ids.length > 0 ? {
|
|
2713
|
+
...entry,
|
|
2714
|
+
controls: ids
|
|
2715
|
+
} : entry;
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2687
2719
|
* The answer to a read that names sections: the ones the document has, the
|
|
2688
2720
|
* rest listed with their sizes, and the asked-for ids it lacks under
|
|
2689
2721
|
* unknownSections. One shape whatever matched (PRO-2778): a wrong id does not
|
|
@@ -2708,7 +2740,7 @@ function sectionReadResult(doc, asked, label, options = {}) {
|
|
|
2708
2740
|
const { data: _data, ...meta } = cut.doc;
|
|
2709
2741
|
return toolResult({
|
|
2710
2742
|
...meta,
|
|
2711
|
-
outline: cut.omittedSections,
|
|
2743
|
+
outline: options.controlIds && before ? withControls(cut.omittedSections, before, doc.type) : cut.omittedSections,
|
|
2712
2744
|
...unknownSections.length > 0 ? { unknownSections } : {},
|
|
2713
2745
|
...controlNote
|
|
2714
2746
|
});
|
|
@@ -3848,6 +3880,20 @@ const STEP_SUMMARY_FIELDS = [
|
|
|
3848
3880
|
"startedAt",
|
|
3849
3881
|
"completedAt"
|
|
3850
3882
|
];
|
|
3883
|
+
/**
|
|
3884
|
+
* A step a failed run never reached reads as planned, as it was (PRO-2800): a run rejected before it began showed
|
|
3885
|
+
* each step failed with "still in flight", though none had started. The API reaches failed only through in_progress,
|
|
3886
|
+
* which sets startedAt, so a failed step without one is a planned step the run's failure swept up.
|
|
3887
|
+
*/
|
|
3888
|
+
function neverStarted(step) {
|
|
3889
|
+
if (step["status"] !== "failed" || step["startedAt"]) return step;
|
|
3890
|
+
const { error: _error, ...rest } = step;
|
|
3891
|
+
return {
|
|
3892
|
+
...rest,
|
|
3893
|
+
status: "planned",
|
|
3894
|
+
completedAt: null
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3851
3897
|
/** Past this many characters a step's headline is cut; the full read has the rest. */
|
|
3852
3898
|
const HEADLINE_LIMIT = 600;
|
|
3853
3899
|
/**
|
|
@@ -3917,7 +3963,7 @@ function registerBuildTools(server, client) {
|
|
|
3917
3963
|
}));
|
|
3918
3964
|
server.registerTool("taiga_get_run", {
|
|
3919
3965
|
title: "Get run",
|
|
3920
|
-
description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true adds complete instructions and reports. actions: resume and retry_from_failed_step are taiga_resume_initiative; start_over
|
|
3966
|
+
description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true adds complete instructions and reports. actions: resume and retry_from_failed_step are taiga_resume_initiative; webOnlyActions (start_over) are in the web; to stop it, move the initiative out of Build. A maintenance run has no initiative and is retried from its finding.",
|
|
3921
3967
|
inputSchema: z.object({
|
|
3922
3968
|
runId,
|
|
3923
3969
|
full: z.boolean().optional().describe("Carry each step's complete instructions and report (default false: a summary)")
|
|
@@ -3926,7 +3972,7 @@ function registerBuildTools(server, client) {
|
|
|
3926
3972
|
}, (params) => runTool(async () => {
|
|
3927
3973
|
const [run, steps] = await Promise.all([client.getBuildRun(params.runId), client.listBuildRunSteps(params.runId)]);
|
|
3928
3974
|
const runRow = run.data;
|
|
3929
|
-
const rows = steps.data;
|
|
3975
|
+
const rows = steps.data.map((step) => neverStarted(step));
|
|
3930
3976
|
const where = stoppedIn(runRow, rows);
|
|
3931
3977
|
const stop = where ? { stoppedIn: where } : {};
|
|
3932
3978
|
const states = await initiativeStates(client, [runRow]);
|
|
@@ -3937,7 +3983,7 @@ function registerBuildTools(server, client) {
|
|
|
3937
3983
|
...readable,
|
|
3938
3984
|
...stop,
|
|
3939
3985
|
...verification,
|
|
3940
|
-
steps:
|
|
3986
|
+
steps: rows
|
|
3941
3987
|
});
|
|
3942
3988
|
return toolResult({
|
|
3943
3989
|
...readable,
|
|
@@ -4423,13 +4469,22 @@ function registerBacklogReads(server, client) {
|
|
|
4423
4469
|
return toolResult(page(situatedRows(result.data), result.nextCursor));
|
|
4424
4470
|
}
|
|
4425
4471
|
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "Open items, a board column or a title search answer one page; narrow them, or pass status: 'all' (or one status) to page through every item.");
|
|
4472
|
+
let closedSeen = 0;
|
|
4473
|
+
const needle = (query ?? "").toLowerCase();
|
|
4426
4474
|
const found = await searchPages((cursor) => client.listBacklogItems(params.productId, {
|
|
4427
4475
|
...apiFilters,
|
|
4428
4476
|
limit: 100,
|
|
4429
4477
|
cursor
|
|
4430
|
-
}), query ?? "", (row) => [row.title], params.limit ?? 25, (row) =>
|
|
4478
|
+
}), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => {
|
|
4479
|
+
if (state !== void 0 && row.state !== state) return false;
|
|
4480
|
+
if (!open || !["done", "canceled"].includes(row.status)) return true;
|
|
4481
|
+
if (query !== void 0 && String(row.title).toLowerCase().includes(needle)) closedSeen += 1;
|
|
4482
|
+
return false;
|
|
4483
|
+
});
|
|
4484
|
+
const hidden = query !== void 0 && state === void 0 && !found.more ? closedSeen : 0;
|
|
4431
4485
|
return toolResult({
|
|
4432
4486
|
...page(situatedRows(found.rows), null),
|
|
4487
|
+
...hidden > 0 ? { closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? " also matches" : "s also match"}; status: 'all' lists them.` } : {},
|
|
4433
4488
|
...found.more ? { more: "There may be more than the limit carries: narrow it with state or query, or list one status (backlog, in_progress, done or canceled) without them, which pages." } : {}
|
|
4434
4489
|
});
|
|
4435
4490
|
}));
|
|
@@ -4806,7 +4861,8 @@ function registerMaintenanceTools(server, client) {
|
|
|
4806
4861
|
sweepNote: "The sweep overview could not be read: an empty list here is not an all-clear."
|
|
4807
4862
|
} : sweep ? { sweep } : {},
|
|
4808
4863
|
...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
|
|
4809
|
-
...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {}
|
|
4864
|
+
...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {},
|
|
4865
|
+
...params.status === void 0 && params.severity === void 0 && params.kind === void 0 && typeof result.totalCount === "number" && sweep && sweep !== "unread" && typeof sweep["openFindings"] === "number" && sweep["openFindings"] !== result.totalCount ? { totalNote: "totalCount includes findings with a fix under way (remediating) and ignored findings whose ignore has expired; sweep.openFindings counts open ones only." } : {}
|
|
4810
4866
|
});
|
|
4811
4867
|
}));
|
|
4812
4868
|
server.registerTool("taiga_get_finding", {
|
|
@@ -4988,7 +5044,10 @@ function readWith(row, backlogRead) {
|
|
|
4988
5044
|
switch (row["subjectType"]) {
|
|
4989
5045
|
case "build_run": return "taiga_get_run (runId: subjectId)";
|
|
4990
5046
|
case "backlog_item": return backlogRead ? "taiga_get_plan (initiativeId: subjectId)" : void 0;
|
|
4991
|
-
case "product_document":
|
|
5047
|
+
case "product_document": {
|
|
5048
|
+
const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row["subjectId"]))?.[1];
|
|
5049
|
+
return type ? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true` : "taiga_list_product_documents (productId)";
|
|
5050
|
+
}
|
|
4992
5051
|
case "inbox_task": return "taiga_list_setup_tasks (productId)";
|
|
4993
5052
|
case "product": return {
|
|
4994
5053
|
review_backlog: backlogRead ? "taiga_list_initiatives (productId)" : void 0,
|
|
@@ -5270,6 +5329,7 @@ function registerInboxTools(server, client) {
|
|
|
5270
5329
|
} : item;
|
|
5271
5330
|
}), result.nextCursor),
|
|
5272
5331
|
...totalOf(result),
|
|
5332
|
+
...overview && typeof result.totalCount === "number" && (stale.size > 0 || overview.data.length > overviewDue.length || overview.nextCursor) ? { totalNote: "totalCount counts every item, stale and snoozed ones too; summary.open does not" + (overview.nextCursor ? `, and counts only the first ${SUMMARY_PAGE}.` : ".") } : {},
|
|
5273
5333
|
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
5274
5334
|
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
|
|
5275
5335
|
});
|
|
@@ -21012,7 +21072,7 @@ const GITHUB_REPOSITORY_FIELDS = [
|
|
|
21012
21072
|
"language",
|
|
21013
21073
|
"linked"
|
|
21014
21074
|
];
|
|
21015
|
-
const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a
|
|
21075
|
+
const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a linkable repository id is not one");
|
|
21016
21076
|
/** Round-robin over lists: the first of each, then the second of each, and so on. */
|
|
21017
21077
|
function interleave(lists) {
|
|
21018
21078
|
const out = [];
|
|
@@ -21021,6 +21081,28 @@ function interleave(lists) {
|
|
|
21021
21081
|
return out;
|
|
21022
21082
|
}
|
|
21023
21083
|
/**
|
|
21084
|
+
* A repository linked elsewhere names the product that holds it (PRO-2800): the API says "another product", and a
|
|
21085
|
+
* reviewer spent a call finding which. One read, the products the key can see; null leaves the API's refusal.
|
|
21086
|
+
*/
|
|
21087
|
+
async function holderOf(client, error, productId) {
|
|
21088
|
+
if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
|
|
21089
|
+
const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? "")?.[1];
|
|
21090
|
+
if (!name) return null;
|
|
21091
|
+
try {
|
|
21092
|
+
const holders = (await client.listProducts({
|
|
21093
|
+
repository: name,
|
|
21094
|
+
limit: 20
|
|
21095
|
+
})).data.filter((p) => p.id !== productId);
|
|
21096
|
+
return toolError("CONFLICT", `${name} is already linked to another product. ${holders.length > 0 ? `Products this key can see with a repository named ${name}: ${holders.map((p) => `${p.name} (${p.id})`).join(", ")}.` : "It is linked to a product this key cannot see."} A repository belongs to one product; unlink it there first (taiga_unlink_repository) if this product should have it.`, { holders: holders.map((p) => ({
|
|
21097
|
+
productId: p.id,
|
|
21098
|
+
name: p.name
|
|
21099
|
+
})) });
|
|
21100
|
+
} catch (inner) {
|
|
21101
|
+
if (inner instanceof TaigaApiError || isNetworkFailure(inner)) return null;
|
|
21102
|
+
throw inner;
|
|
21103
|
+
}
|
|
21104
|
+
}
|
|
21105
|
+
/**
|
|
21024
21106
|
* A product in setup says what it still needs after a repository write, as create_product and get_product do
|
|
21025
21107
|
* (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
|
|
21026
21108
|
* is done, so a failed read leaves readiness out rather than failing it.
|
|
@@ -21066,7 +21148,7 @@ function registerRepositoryTools(server, client) {
|
|
|
21066
21148
|
}
|
|
21067
21149
|
if (client.hasCapability("vcs:read")) server.registerTool("taiga_list_linkable_repositories", {
|
|
21068
21150
|
title: "Find a repository to link",
|
|
21069
|
-
description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id
|
|
21151
|
+
description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or installationId) reads one account, else rows come from every account in turn. linked: true means a product already links it (taiga_get_product with its URL says which). No connection means GitHub is not connected yet; someone who can install the app does that once, in the web.",
|
|
21070
21152
|
inputSchema: z.object({
|
|
21071
21153
|
query: z.string().trim().min(1).max(256).optional().describe("Part of the repository name"),
|
|
21072
21154
|
account: z.string().trim().min(1).max(140).optional().describe("The GitHub account login (the owner in owner/repo), case-insensitive"),
|
|
@@ -21116,16 +21198,24 @@ function registerRepositoryTools(server, client) {
|
|
|
21116
21198
|
description: "Link a GitHub repository to a product (taiga_list_linkable_repositories gives its id). replacesRepositoryId swaps out the one linked now in one step (the same one changes nothing); linking while one is linked without it is refused. A managed link (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.",
|
|
21117
21199
|
inputSchema: z.object({
|
|
21118
21200
|
productId,
|
|
21119
|
-
|
|
21201
|
+
linkableRepositoryId: idInput("linkable repository", "taiga_list_linkable_repositories gives it (a row's id; not GitHub's numeric id, not a link ID)"),
|
|
21120
21202
|
replacesRepositoryId: repositoryId.optional().describe("The link this one replaces")
|
|
21121
21203
|
}),
|
|
21122
21204
|
annotations: OPERATE
|
|
21123
21205
|
}, (params) => runTool(async () => {
|
|
21124
|
-
|
|
21125
|
-
|
|
21126
|
-
|
|
21206
|
+
let linked;
|
|
21207
|
+
try {
|
|
21208
|
+
linked = await client.linkRepository(params.productId, {
|
|
21209
|
+
githubRepositoryId: params.linkableRepositoryId,
|
|
21127
21210
|
...params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}
|
|
21128
|
-
})
|
|
21211
|
+
});
|
|
21212
|
+
} catch (error) {
|
|
21213
|
+
const holder = await holderOf(client, error, params.productId);
|
|
21214
|
+
if (holder) return holder;
|
|
21215
|
+
throw error;
|
|
21216
|
+
}
|
|
21217
|
+
return toolResult({
|
|
21218
|
+
...pickFields([linked.data], LINK_FIELDS)[0],
|
|
21129
21219
|
...await readinessAfter(client, params.productId)
|
|
21130
21220
|
});
|
|
21131
21221
|
}));
|
|
@@ -22090,8 +22180,8 @@ function registerWhoami(server, client, registered) {
|
|
|
22090
22180
|
* (PRO-2800).
|
|
22091
22181
|
*/
|
|
22092
22182
|
const SERVER_INSTRUCTIONS = [
|
|
22093
|
-
"Taiga is a policy-aware software delivery platform. Use these tools (taiga_*)
|
|
22094
|
-
"product
|
|
22183
|
+
"Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) for anything about a Taiga",
|
|
22184
|
+
"product: its initiatives, runs, policies, findings, deployments or setup.",
|
|
22095
22185
|
"",
|
|
22096
22186
|
"**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug",
|
|
22097
22187
|
"or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a",
|
|
@@ -22104,17 +22194,17 @@ const SERVER_INSTRUCTIONS = [
|
|
|
22104
22194
|
"a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web",
|
|
22105
22195
|
"conversation that specifies a new product. A factory owns products. People stamp checks in the web.",
|
|
22106
22196
|
"",
|
|
22107
|
-
"**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names
|
|
22108
|
-
"ones. Lists page with nextCursor
|
|
22109
|
-
"
|
|
22110
|
-
"timelines: plan (what was meant) and reality (derived from the code).",
|
|
22197
|
+
"**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names",
|
|
22198
|
+
"the tool that lists the right ones. Lists page with nextCursor; taiga_list_initiatives lists open ones unless",
|
|
22199
|
+
"status: 'all'. outcome, situation and nextStep are one-sentence summaries; setup writes return readiness. A",
|
|
22200
|
+
"product document has two timelines: plan (what was meant) and reality (derived from the code).",
|
|
22111
22201
|
"",
|
|
22112
22202
|
"**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others",
|
|
22113
22203
|
"see it).\"; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a",
|
|
22114
22204
|
"taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
|
|
22115
22205
|
"",
|
|
22116
|
-
"**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting
|
|
22117
|
-
"deleting factories and products."
|
|
22206
|
+
"**Only in the web:** connecting GitHub, members and roles, the Discovery conversation, stamping checks, starting",
|
|
22207
|
+
"a run over, and deleting factories and products."
|
|
22118
22208
|
].join("\n");
|
|
22119
22209
|
//#endregion
|
|
22120
22210
|
//#region src/server.ts
|
package/package.json
CHANGED
|
@@ -3553,7 +3553,9 @@ describe('MCP server integration', () => {
|
|
|
3553
3553
|
completedSteps: 1,
|
|
3554
3554
|
totalSteps: 3,
|
|
3555
3555
|
prUrl: null,
|
|
3556
|
-
|
|
3556
|
+
// start_over is the web's, said apart from what this server can call.
|
|
3557
|
+
actions: ['resume'],
|
|
3558
|
+
webOnlyActions: ['start_over'],
|
|
3557
3559
|
disposition: 'stopped',
|
|
3558
3560
|
outcome: 'Stopped before it finished (1 of 3 steps done).',
|
|
3559
3561
|
});
|
|
@@ -6000,7 +6002,7 @@ describe('MCP server integration', () => {
|
|
|
6000
6002
|
);
|
|
6001
6003
|
await client.callTool({
|
|
6002
6004
|
name: 'taiga_link_repository',
|
|
6003
|
-
arguments: { productId: PRODUCT_ID,
|
|
6005
|
+
arguments: { productId: PRODUCT_ID, linkableRepositoryId: GH_REPO_ID },
|
|
6004
6006
|
});
|
|
6005
6007
|
const set = parseToolResult(
|
|
6006
6008
|
await client.callTool({
|
|
@@ -6209,7 +6211,7 @@ describe('MCP server integration', () => {
|
|
|
6209
6211
|
});
|
|
6210
6212
|
const link = await client.callTool({
|
|
6211
6213
|
name: 'taiga_link_repository',
|
|
6212
|
-
arguments: { productId: PRODUCT_ID,
|
|
6214
|
+
arguments: { productId: PRODUCT_ID, linkableRepositoryId: GH_REPO_ID },
|
|
6213
6215
|
});
|
|
6214
6216
|
|
|
6215
6217
|
expect(finish.isError).toBe(true);
|
|
@@ -6433,7 +6435,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6433
6435
|
// The API names the link, so only the link is hinted: the product the call
|
|
6434
6436
|
// also named was right, and its hint read as blaming it (PRO-2800).
|
|
6435
6437
|
expect(parseToolResult(branches).message).toBe(
|
|
6436
|
-
"Repository link not found. taiga_list_product_repositories lists the product's repository links; a
|
|
6438
|
+
"Repository link not found. taiga_list_product_repositories lists the product's repository links; a linkable " +
|
|
6437
6439
|
'repository id is not a link ID.'
|
|
6438
6440
|
);
|
|
6439
6441
|
});
|
|
@@ -6456,7 +6458,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6456
6458
|
});
|
|
6457
6459
|
expect(parseToolResult(branches).message).toBe(
|
|
6458
6460
|
"The requested resource does not exist. taiga_list_product_repositories lists the product's repository " +
|
|
6459
|
-
'links; a
|
|
6461
|
+
'links; a linkable repository id is not a link ID. taiga_list_products lists the products the key can see; ' +
|
|
6460
6462
|
'a name is not an ID (taiga_get_product takes a slug).'
|
|
6461
6463
|
);
|
|
6462
6464
|
});
|
|
@@ -8253,7 +8255,7 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8253
8255
|
const linked = parseToolResult(
|
|
8254
8256
|
await client.callTool({
|
|
8255
8257
|
name: 'taiga_link_repository',
|
|
8256
|
-
arguments: { productId: PRODUCT_ID,
|
|
8258
|
+
arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
|
|
8257
8259
|
})
|
|
8258
8260
|
);
|
|
8259
8261
|
expect(linked.readiness?.nextStep).toEqual(expect.any(String));
|
|
@@ -8271,7 +8273,7 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8271
8273
|
const refused = parseToolResult(
|
|
8272
8274
|
await second.callTool({
|
|
8273
8275
|
name: 'taiga_link_repository',
|
|
8274
|
-
arguments: { productId: PRODUCT_ID,
|
|
8276
|
+
arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
|
|
8275
8277
|
})
|
|
8276
8278
|
);
|
|
8277
8279
|
expect(refused.message).toBe(
|
|
@@ -8590,3 +8592,225 @@ describe('what the sixth agent review found (PRO-2800)', () => {
|
|
|
8590
8592
|
expect(run).not.toHaveProperty('runtime');
|
|
8591
8593
|
});
|
|
8592
8594
|
});
|
|
8595
|
+
|
|
8596
|
+
describe('what the seventh agent review found (PRO-2800)', () => {
|
|
8597
|
+
beforeEach(() => {
|
|
8598
|
+
vi.restoreAllMocks();
|
|
8599
|
+
});
|
|
8600
|
+
|
|
8601
|
+
it('says an ID is a UUID by format, not by a pattern repeated on every ID', async () => {
|
|
8602
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
8603
|
+
|
|
8604
|
+
const { tools } = await client.listTools();
|
|
8605
|
+
const listing = JSON.stringify(tools);
|
|
8606
|
+
const refused = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: 'my item' } });
|
|
8607
|
+
|
|
8608
|
+
expect(listing).not.toMatch(/"pattern":"\^\(\[0-9a-fA-F\]\{8\}/);
|
|
8609
|
+
expect(listing).toMatch(/"format":"uuid"/);
|
|
8610
|
+
expect(JSON.stringify(refused.content)).toMatch(/An initiative ID is a UUID/);
|
|
8611
|
+
});
|
|
8612
|
+
|
|
8613
|
+
it('says when a title search under the open default also matches done or canceled initiatives', async () => {
|
|
8614
|
+
const row = (id: string, title: string, status: string) => ({ id, title, status, state: status });
|
|
8615
|
+
const { client } = await bootMcpPair({
|
|
8616
|
+
capabilities: ['backlog:read'],
|
|
8617
|
+
routes: {
|
|
8618
|
+
[`/v1/products/${PRODUCT_ID}/backlog`]: {
|
|
8619
|
+
data: [
|
|
8620
|
+
row('a', 'Secret detection in CI', 'done'),
|
|
8621
|
+
row('b', 'Secret detection v2', 'canceled'),
|
|
8622
|
+
row('c', 'Docs', 'backlog'),
|
|
8623
|
+
],
|
|
8624
|
+
nextCursor: null,
|
|
8625
|
+
},
|
|
8626
|
+
},
|
|
8627
|
+
});
|
|
8628
|
+
|
|
8629
|
+
const result = parseToolResult(
|
|
8630
|
+
await client.callTool({
|
|
8631
|
+
name: 'taiga_list_initiatives',
|
|
8632
|
+
arguments: { productId: PRODUCT_ID, query: 'secret detection' },
|
|
8633
|
+
})
|
|
8634
|
+
);
|
|
8635
|
+
|
|
8636
|
+
expect(result.data).toEqual([]);
|
|
8637
|
+
expect(result.closedMatches).toBe("2 done or canceled initiatives also match; status: 'all' lists them.");
|
|
8638
|
+
});
|
|
8639
|
+
|
|
8640
|
+
it('shows a step a failed run never reached as planned, and stoppedIn agrees', async () => {
|
|
8641
|
+
const { client } = await bootMcpPair({
|
|
8642
|
+
capabilities: ['products:read'],
|
|
8643
|
+
routes: {
|
|
8644
|
+
[`/v1/builds/${BUILD_ID}/steps`]: {
|
|
8645
|
+
data: [
|
|
8646
|
+
{
|
|
8647
|
+
id: 's1',
|
|
8648
|
+
stepOrder: 1,
|
|
8649
|
+
title: 'One',
|
|
8650
|
+
status: 'failed',
|
|
8651
|
+
startedAt: null,
|
|
8652
|
+
completedAt: '2026-10-10T10:00:00Z',
|
|
8653
|
+
error: 'still in flight',
|
|
8654
|
+
},
|
|
8655
|
+
],
|
|
8656
|
+
},
|
|
8657
|
+
[`/v1/builds/${BUILD_ID}`]: {
|
|
8658
|
+
data: { id: BUILD_ID, productId: PRODUCT_ID, status: 'failed', failedReason: 'dispatch_rejected' },
|
|
8659
|
+
},
|
|
8660
|
+
},
|
|
8661
|
+
});
|
|
8662
|
+
|
|
8663
|
+
const run = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
8664
|
+
|
|
8665
|
+
expect(run.steps[0].status).toBe('planned');
|
|
8666
|
+
expect(run.steps[0].completedAt).toBeNull();
|
|
8667
|
+
expect(run.steps[0]).not.toHaveProperty('error');
|
|
8668
|
+
// stoppedIn reads the same steps, so it says the run stopped before any began.
|
|
8669
|
+
expect(run.stoppedIn).toMatch(/^before the first step started/);
|
|
8670
|
+
});
|
|
8671
|
+
|
|
8672
|
+
it("names the document a document decision is about, since its subject id is no tool's input", async () => {
|
|
8673
|
+
const { client } = await bootMcpPair({
|
|
8674
|
+
capabilities: ['products:read'],
|
|
8675
|
+
routes: {
|
|
8676
|
+
'GET /v1/inbox': {
|
|
8677
|
+
data: [
|
|
8678
|
+
{
|
|
8679
|
+
id: 'd1',
|
|
8680
|
+
productId: PRODUCT_ID,
|
|
8681
|
+
kind: 'review_doc',
|
|
8682
|
+
subjectType: 'product_document',
|
|
8683
|
+
subjectId: `${PRODUCT_ID}:risk_register:decision:b389e792d793`,
|
|
8684
|
+
title: 'Decide',
|
|
8685
|
+
status: 'open',
|
|
8686
|
+
createdAt: new Date().toISOString(),
|
|
8687
|
+
},
|
|
8688
|
+
],
|
|
8689
|
+
nextCursor: null,
|
|
8690
|
+
},
|
|
8691
|
+
},
|
|
8692
|
+
});
|
|
8693
|
+
|
|
8694
|
+
const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
8695
|
+
|
|
8696
|
+
expect(list.data[0].readWith).toBe(
|
|
8697
|
+
"taiga_get_product_document (productId, type: risk_register); the decision's text is this row with full: true"
|
|
8698
|
+
);
|
|
8699
|
+
});
|
|
8700
|
+
|
|
8701
|
+
it('names the controls each section of a policy outline states', async () => {
|
|
8702
|
+
const { client } = await bootMcpPair({
|
|
8703
|
+
capabilities: ALL_CAPABILITIES,
|
|
8704
|
+
routes: {
|
|
8705
|
+
'GET /v1/tenants/current/documents/policy_security': {
|
|
8706
|
+
data: {
|
|
8707
|
+
id: 'p',
|
|
8708
|
+
type: 'policy_security',
|
|
8709
|
+
version: 1,
|
|
8710
|
+
isDraft: false,
|
|
8711
|
+
data: {
|
|
8712
|
+
sections: {
|
|
8713
|
+
scope: { content: 'All systems.' },
|
|
8714
|
+
secretsManagement: { content: '| SEC-5.1 | Vault |\n| SEC-5.3 | Log access | see SEC-5.1. |' },
|
|
8715
|
+
telecom: { content: '| TEL-TIC-1.1 | Lawful intercept |\n| TEL-TIC-1.2 | _Reserved_ |' },
|
|
8716
|
+
// A data control in the security policy is a stale mention, not this policy's control.
|
|
8717
|
+
stray: { content: '| DATA-1.1 | Classify data |' },
|
|
8718
|
+
},
|
|
8719
|
+
},
|
|
8720
|
+
},
|
|
8721
|
+
},
|
|
8722
|
+
},
|
|
8723
|
+
});
|
|
8724
|
+
|
|
8725
|
+
const outline = parseToolResult(
|
|
8726
|
+
await client.callTool({ name: 'taiga_get_document', arguments: { type: 'policy_security', sections: [] } })
|
|
8727
|
+
).outline;
|
|
8728
|
+
|
|
8729
|
+
expect(outline.map((entry: { id: string; controls?: string[] }) => [entry.id, entry.controls ?? []])).toEqual([
|
|
8730
|
+
['scope', []],
|
|
8731
|
+
['secretsManagement', ['SEC-5.1', 'SEC-5.3']],
|
|
8732
|
+
['telecom', ['TEL-TIC-1.1']],
|
|
8733
|
+
['stray', []],
|
|
8734
|
+
]);
|
|
8735
|
+
});
|
|
8736
|
+
|
|
8737
|
+
it('names the product a repository is already linked to', async () => {
|
|
8738
|
+
const { client } = await bootMcpPair({
|
|
8739
|
+
capabilities: ALL_CAPABILITIES,
|
|
8740
|
+
routes: {
|
|
8741
|
+
[`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
|
|
8742
|
+
409,
|
|
8743
|
+
'CONFLICT',
|
|
8744
|
+
'taiga-sandbox/import-test-chi is already linked to another product.'
|
|
8745
|
+
),
|
|
8746
|
+
'GET /v1/products': { data: [{ id: PRODUCT_ID_2, name: 'Imported' }], nextCursor: null },
|
|
8747
|
+
},
|
|
8748
|
+
});
|
|
8749
|
+
|
|
8750
|
+
const result = parseToolResult(
|
|
8751
|
+
await client.callTool({
|
|
8752
|
+
name: 'taiga_link_repository',
|
|
8753
|
+
arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
|
|
8754
|
+
})
|
|
8755
|
+
);
|
|
8756
|
+
|
|
8757
|
+
expect(result.error).toBe('CONFLICT');
|
|
8758
|
+
expect(result.message).toMatch(
|
|
8759
|
+
new RegExp(
|
|
8760
|
+
`Products this key can see with a repository named taiga-sandbox/import-test-chi: Imported \\(${PRODUCT_ID_2}\\)\\.`
|
|
8761
|
+
)
|
|
8762
|
+
);
|
|
8763
|
+
expect(result.details.holders).toEqual([{ productId: PRODUCT_ID_2, name: 'Imported' }]);
|
|
8764
|
+
});
|
|
8765
|
+
|
|
8766
|
+
it('says what totalCount counts beside an Action Required summary that counts differently, and only then', async () => {
|
|
8767
|
+
const item = (id: string, extra: Record<string, unknown> = {}) => ({
|
|
8768
|
+
id,
|
|
8769
|
+
productId: PRODUCT_ID,
|
|
8770
|
+
productName: 'Web',
|
|
8771
|
+
kind: 'setup_secret',
|
|
8772
|
+
subjectType: 'inbox_task',
|
|
8773
|
+
subjectId: id,
|
|
8774
|
+
title: id,
|
|
8775
|
+
status: 'open',
|
|
8776
|
+
createdAt: new Date().toISOString(),
|
|
8777
|
+
...extra,
|
|
8778
|
+
});
|
|
8779
|
+
const boot = (data: unknown[]) =>
|
|
8780
|
+
bootMcpPair({
|
|
8781
|
+
capabilities: ['products:read'],
|
|
8782
|
+
routes: { 'GET /v1/inbox': { data, nextCursor: null, totalCount: data.length } },
|
|
8783
|
+
});
|
|
8784
|
+
|
|
8785
|
+
const plain = await boot([item('a'), item('b')]);
|
|
8786
|
+
const quiet = parseToolResult(await plain.client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
8787
|
+
const snoozed = await boot([item('a'), item('b', { status: 'snoozed', snoozedUntil: '2999-01-01T00:00:00Z' })]);
|
|
8788
|
+
const said = parseToolResult(await snoozed.client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
8789
|
+
|
|
8790
|
+
expect(quiet).not.toHaveProperty('totalNote');
|
|
8791
|
+
expect(said.totalNote).toBe('totalCount counts every item, stale and snoozed ones too; summary.open does not.');
|
|
8792
|
+
});
|
|
8793
|
+
|
|
8794
|
+
it('says what the findings totalCount counts beside the sweep count, unfiltered only', async () => {
|
|
8795
|
+
const routes = {
|
|
8796
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [{ id: 'f1' }], nextCursor: null, totalCount: 33 },
|
|
8797
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: { openFindings: 31 } },
|
|
8798
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/runs`]: { data: [{ id: 's1', status: 'completed' }], nextCursor: null },
|
|
8799
|
+
};
|
|
8800
|
+
const { client } = await bootMcpPair({ capabilities: ['products:read'], routes });
|
|
8801
|
+
|
|
8802
|
+
const all = parseToolResult(
|
|
8803
|
+
await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
|
|
8804
|
+
);
|
|
8805
|
+
const critical = parseToolResult(
|
|
8806
|
+
await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID, severity: 'critical' } })
|
|
8807
|
+
);
|
|
8808
|
+
const secrets = parseToolResult(
|
|
8809
|
+
await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID, kind: 'secret' } })
|
|
8810
|
+
);
|
|
8811
|
+
|
|
8812
|
+
expect(all.totalNote).toMatch(/^totalCount includes findings with a fix under way/);
|
|
8813
|
+
expect(critical).not.toHaveProperty('totalNote');
|
|
8814
|
+
expect(secrets).not.toHaveProperty('totalNote');
|
|
8815
|
+
});
|
|
8816
|
+
});
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -369,15 +369,31 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
369
369
|
'(or one status) to page through every item.'
|
|
370
370
|
);
|
|
371
371
|
}
|
|
372
|
+
// A title search under the open default said nothing of the done and canceled ones it matched: a
|
|
373
|
+
// reviewer searched "secret detection", got [], and concluded the work never existed (PRO-2800). Counted
|
|
374
|
+
// in the same pass; exact when the pass read every page.
|
|
375
|
+
let closedSeen = 0;
|
|
376
|
+
const needle = (query ?? '').toLowerCase();
|
|
372
377
|
const found = await searchPages(
|
|
373
378
|
(cursor) => client.listBacklogItems(params.productId, { ...apiFilters, limit: SEARCH_PAGE_SIZE, cursor }),
|
|
374
379
|
query ?? '',
|
|
375
380
|
(row) => [row.title],
|
|
376
381
|
params.limit ?? 25,
|
|
377
|
-
(row) =>
|
|
382
|
+
(row) => {
|
|
383
|
+
if (state !== undefined && row.state !== state) return false;
|
|
384
|
+
if (!open || !['done', 'canceled'].includes(row.status)) return true;
|
|
385
|
+
if (query !== undefined && String(row.title).toLowerCase().includes(needle)) closedSeen += 1;
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
378
388
|
);
|
|
389
|
+
const hidden = query !== undefined && state === undefined && !found.more ? closedSeen : 0;
|
|
379
390
|
return toolResult({
|
|
380
391
|
...page(situatedRows(found.rows), null),
|
|
392
|
+
...(hidden > 0
|
|
393
|
+
? {
|
|
394
|
+
closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? ' also matches' : 's also match'}; status: 'all' lists them.`,
|
|
395
|
+
}
|
|
396
|
+
: {}),
|
|
381
397
|
...(found.more
|
|
382
398
|
? {
|
|
383
399
|
more:
|
package/src/tools/builds.ts
CHANGED
|
@@ -403,6 +403,18 @@ const STEP_SUMMARY_FIELDS = [
|
|
|
403
403
|
'completedAt',
|
|
404
404
|
] as const;
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* A step a failed run never reached reads as planned, as it was (PRO-2800): a run rejected before it began showed
|
|
408
|
+
* each step failed with "still in flight", though none had started. The API reaches failed only through in_progress,
|
|
409
|
+
* which sets startedAt, so a failed step without one is a planned step the run's failure swept up.
|
|
410
|
+
*/
|
|
411
|
+
function neverStarted(step: Record<string, unknown>): Record<string, unknown> {
|
|
412
|
+
if (step['status'] !== 'failed' || step['startedAt']) return step;
|
|
413
|
+
// The sweep also stamped completedAt and an error; a planned step has neither.
|
|
414
|
+
const { error: _error, ...rest } = step;
|
|
415
|
+
return { ...rest, status: 'planned', completedAt: null };
|
|
416
|
+
}
|
|
417
|
+
|
|
406
418
|
/** Past this many characters a step's headline is cut; the full read has the rest. */
|
|
407
419
|
const HEADLINE_LIMIT = 600;
|
|
408
420
|
|
|
@@ -540,7 +552,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
540
552
|
"the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped " +
|
|
541
553
|
'run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true ' +
|
|
542
554
|
'adds complete instructions and reports. actions: resume and retry_from_failed_step are ' +
|
|
543
|
-
'taiga_resume_initiative; start_over
|
|
555
|
+
'taiga_resume_initiative; webOnlyActions (start_over) are in the web; to stop it, move the initiative out of Build. A ' +
|
|
544
556
|
'maintenance run has no initiative and is retried from its finding.',
|
|
545
557
|
inputSchema: z.object({
|
|
546
558
|
runId,
|
|
@@ -560,14 +572,17 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
560
572
|
client.listBuildRunSteps(params.runId),
|
|
561
573
|
]);
|
|
562
574
|
const runRow = run.data;
|
|
563
|
-
|
|
575
|
+
// Steps a failed run never reached read as planned before anything else reads them, stoppedIn included.
|
|
576
|
+
const rows = steps.data.map((step) =>
|
|
577
|
+
neverStarted(step as unknown as Record<string, unknown>)
|
|
578
|
+
) as unknown as typeof steps.data;
|
|
564
579
|
const where = stoppedIn(runRow, rows);
|
|
565
580
|
const stop = where ? { stoppedIn: where } : {};
|
|
566
581
|
const states = await initiativeStates(client, [runRow as unknown as Record<string, unknown>]);
|
|
567
582
|
const readable = withInitiativeState(readableRun(runRow), states);
|
|
568
583
|
const note = verificationNote(rows as unknown as Record<string, unknown>[]);
|
|
569
584
|
const verification = note ? { verificationNote: note } : {};
|
|
570
|
-
if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps:
|
|
585
|
+
if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps: rows });
|
|
571
586
|
return toolResult({
|
|
572
587
|
...readable,
|
|
573
588
|
...stop,
|
package/src/tools/errors.ts
CHANGED
|
@@ -23,11 +23,26 @@ const PRODUCT_WORDS: Record<string, string> = {
|
|
|
23
23
|
const OPAQUE_MAPS = new Set(['sections', 'metadata', 'settings']);
|
|
24
24
|
|
|
25
25
|
/** A copy of an API value with its keys in the product's words, values untouched. */
|
|
26
|
+
/**
|
|
27
|
+
* Run actions only the web offers (PRO-2800): start_over was listed beside what an agent can call, on runs and on
|
|
28
|
+
* every initiative's latest run, while the descriptions said it was web only. Split apart wherever actions appear.
|
|
29
|
+
*/
|
|
30
|
+
const WEB_ONLY_ACTIONS = new Set(['start_over']);
|
|
31
|
+
|
|
26
32
|
export function inProductWords(value: unknown): unknown {
|
|
27
33
|
if (Array.isArray(value)) return value.map(inProductWords);
|
|
28
34
|
if (value === null || typeof value !== 'object') return value;
|
|
29
35
|
const out: Record<string, unknown> = {};
|
|
30
|
-
|
|
36
|
+
const actions = (value as Record<string, unknown>)['actions'];
|
|
37
|
+
if (Array.isArray(actions) && actions.some((action) => WEB_ONLY_ACTIONS.has(String(action)))) {
|
|
38
|
+
const webOnly = actions.filter((action) => WEB_ONLY_ACTIONS.has(String(action)));
|
|
39
|
+
value = {
|
|
40
|
+
...(value as Record<string, unknown>),
|
|
41
|
+
actions: actions.filter((action) => !WEB_ONLY_ACTIONS.has(String(action))),
|
|
42
|
+
webOnlyActions: webOnly,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
31
46
|
Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
|
|
32
47
|
value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
|
|
33
48
|
enumerable: true,
|
package/src/tools/hints.ts
CHANGED
|
@@ -14,9 +14,9 @@ const BY_ARGUMENT: Record<string, string> = {
|
|
|
14
14
|
runId: 'taiga_list_runs lists the runs, of one product or of every product the key can see.',
|
|
15
15
|
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
16
16
|
repositoryId:
|
|
17
|
-
"taiga_list_product_repositories lists the product's repository links; a
|
|
17
|
+
"taiga_list_product_repositories lists the product's repository links; a linkable repository id is not a link ID.",
|
|
18
18
|
replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
|
|
19
|
-
|
|
19
|
+
linkableRepositoryId:
|
|
20
20
|
'taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.',
|
|
21
21
|
installationId: 'taiga_list_linkable_repositories lists the connected GitHub accounts.',
|
|
22
22
|
jobId: 'A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).',
|
|
@@ -57,7 +57,7 @@ const MISSING_THING: Record<string, RegExp> = {
|
|
|
57
57
|
planId: /\bplan\b/i,
|
|
58
58
|
repositoryId: /repository link|\blink\b/i,
|
|
59
59
|
replacesRepositoryId: /repository link|\blink\b/i,
|
|
60
|
-
|
|
60
|
+
linkableRepositoryId: /github repository/i,
|
|
61
61
|
installationId: /installation|account/i,
|
|
62
62
|
jobId: /\bjob\b/i,
|
|
63
63
|
factoryId: /factory|team/i,
|
package/src/tools/inbox.ts
CHANGED
|
@@ -57,8 +57,14 @@ function readWith(row: Row, backlogRead: boolean): string | undefined {
|
|
|
57
57
|
case 'backlog_item':
|
|
58
58
|
// Only a tool the key has: the backlog reads need backlog:read.
|
|
59
59
|
return backlogRead ? 'taiga_get_plan (initiativeId: subjectId)' : undefined;
|
|
60
|
-
case 'product_document':
|
|
61
|
-
|
|
60
|
+
case 'product_document': {
|
|
61
|
+
// A document decision's subject is "<productId>:<type>:decision:<hash>", which no tool takes: name the
|
|
62
|
+
// document it is about, and say where the decision's own text is.
|
|
63
|
+
const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row['subjectId']))?.[1];
|
|
64
|
+
return type
|
|
65
|
+
? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true`
|
|
66
|
+
: 'taiga_list_product_documents (productId)';
|
|
67
|
+
}
|
|
62
68
|
case 'inbox_task':
|
|
63
69
|
return 'taiga_list_setup_tasks (productId)';
|
|
64
70
|
case 'product':
|
|
@@ -433,6 +439,17 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
433
439
|
result.nextCursor
|
|
434
440
|
),
|
|
435
441
|
...totalOf(result),
|
|
442
|
+
// totalCount is the API's, every item; the summary's open leaves stale and put-off ones out. A reviewer
|
|
443
|
+
// summed one and compared it with the other (68 against 63).
|
|
444
|
+
...(overview &&
|
|
445
|
+
typeof result.totalCount === 'number' &&
|
|
446
|
+
(stale.size > 0 || overview.data.length > overviewDue.length || overview.nextCursor)
|
|
447
|
+
? {
|
|
448
|
+
totalNote:
|
|
449
|
+
'totalCount counts every item, stale and snoozed ones too; summary.open does not' +
|
|
450
|
+
(overview.nextCursor ? `, and counts only the first ${SUMMARY_PAGE}.` : '.'),
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
436
453
|
...(hidden > 0 ? { snoozedHidden: hidden } : {}),
|
|
437
454
|
// Past the cap a review is not checked, so no stale on it is not "live".
|
|
438
455
|
...(checked.unchecked > 0
|
package/src/tools/inputs.ts
CHANGED
|
@@ -7,26 +7,29 @@ import { z } from 'zod';
|
|
|
7
7
|
* schema's message, not the description, when a call fails validation, and
|
|
8
8
|
* "Invalid UUID" alone sends a model that passed a name to guess again.
|
|
9
9
|
*/
|
|
10
|
-
export function idInput(what: string, from: string) {
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
export function idInput(what: string, from: string, said: string = from) {
|
|
11
|
+
return (
|
|
12
|
+
z
|
|
13
|
+
.string()
|
|
14
|
+
.uuid({ message: `${/^[aeiou]/i.test(what) ? 'An' : 'A'} ${what} ID is a UUID, not a name: ${from}` })
|
|
15
|
+
// format: uuid says it; the 190-character pattern zod adds beside it, on every ID of 79 tools, was the bulk
|
|
16
|
+
// of the schema a reviewer loaded (PRO-2800). Validation is unchanged.
|
|
17
|
+
.meta({ pattern: undefined })
|
|
18
|
+
.describe(`${what[0]!.toUpperCase()}${what.slice(1)} ID -- ${said}`)
|
|
19
|
+
);
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export const productId = idInput(
|
|
18
23
|
'product',
|
|
19
|
-
'taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it'
|
|
24
|
+
'taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it',
|
|
25
|
+
// On 39 tools: the full sentence is the refusal's, where it is needed.
|
|
26
|
+
'taiga_list_products gives it'
|
|
20
27
|
);
|
|
21
28
|
|
|
22
29
|
/** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
|
|
23
30
|
export const pagination = {
|
|
24
31
|
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
25
|
-
cursor: z
|
|
26
|
-
.string()
|
|
27
|
-
.max(256)
|
|
28
|
-
.optional()
|
|
29
|
-
.describe('The nextCursor of the previous page, to read the next one; omit for the first page'),
|
|
32
|
+
cursor: z.string().max(256).optional().describe("The previous page's nextCursor; omit for the first page"),
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
export const slug = z
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* (PRO-2800).
|
|
11
11
|
*/
|
|
12
12
|
export const SERVER_INSTRUCTIONS = [
|
|
13
|
-
'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*)
|
|
14
|
-
'product
|
|
13
|
+
'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) for anything about a Taiga',
|
|
14
|
+
'product: its initiatives, runs, policies, findings, deployments or setup.',
|
|
15
15
|
'',
|
|
16
16
|
'**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug',
|
|
17
17
|
'or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a',
|
|
@@ -24,17 +24,17 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
24
24
|
'a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web',
|
|
25
25
|
'conversation that specifies a new product. A factory owns products. People stamp checks in the web.',
|
|
26
26
|
'',
|
|
27
|
-
'**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names
|
|
28
|
-
'ones. Lists page with nextCursor
|
|
29
|
-
'
|
|
30
|
-
'timelines: plan (what was meant) and reality (derived from the code).',
|
|
27
|
+
'**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names',
|
|
28
|
+
'the tool that lists the right ones. Lists page with nextCursor; taiga_list_initiatives lists open ones unless',
|
|
29
|
+
"status: 'all'. outcome, situation and nextStep are one-sentence summaries; setup writes return readiness. A",
|
|
30
|
+
'product document has two timelines: plan (what was meant) and reality (derived from the code).',
|
|
31
31
|
'',
|
|
32
32
|
'**Writing.** A tool that writes opens with "Writes.", "Ask first (paid agent work)." or "Ask first (others',
|
|
33
33
|
'see it)."; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a',
|
|
34
34
|
"taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
|
|
35
35
|
'',
|
|
36
|
-
'**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting
|
|
37
|
-
'deleting factories and products.',
|
|
36
|
+
'**Only in the web:** connecting GitHub, members and roles, the Discovery conversation, stamping checks, starting',
|
|
37
|
+
'a run over, and deleting factories and products.',
|
|
38
38
|
].join('\n');
|
|
39
39
|
|
|
40
40
|
/** The most the instructions may take: Claude Code cuts each server's at 2,048 characters. */
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -250,6 +250,22 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
250
250
|
: {}),
|
|
251
251
|
...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
|
|
252
252
|
...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
|
|
253
|
+
// The default list has findings with a fix under way too; the sweep's count does not (31 against 33).
|
|
254
|
+
...(params.status === undefined &&
|
|
255
|
+
params.severity === undefined &&
|
|
256
|
+
params.kind === undefined &&
|
|
257
|
+
typeof result.totalCount === 'number' &&
|
|
258
|
+
sweep &&
|
|
259
|
+
sweep !== 'unread' &&
|
|
260
|
+
typeof (sweep as Row)['openFindings'] === 'number' &&
|
|
261
|
+
(sweep as Row)['openFindings'] !== result.totalCount
|
|
262
|
+
? {
|
|
263
|
+
totalNote:
|
|
264
|
+
'totalCount includes findings with a fix under way (remediating) and ignored findings whose ' +
|
|
265
|
+
'ignore has expired; ' +
|
|
266
|
+
'sweep.openFindings counts open ones only.',
|
|
267
|
+
}
|
|
268
|
+
: {}),
|
|
253
269
|
});
|
|
254
270
|
})
|
|
255
271
|
);
|
|
@@ -53,7 +53,7 @@ const GITHUB_REPOSITORY_FIELDS = [
|
|
|
53
53
|
|
|
54
54
|
const repositoryId = idInput(
|
|
55
55
|
'repository link',
|
|
56
|
-
'taiga_list_product_repositories gives it; a
|
|
56
|
+
'taiga_list_product_repositories gives it; a linkable repository id is not one'
|
|
57
57
|
);
|
|
58
58
|
|
|
59
59
|
/** Round-robin over lists: the first of each, then the second of each, and so on. */
|
|
@@ -66,6 +66,34 @@ export function interleave<T>(lists: T[][]): T[] {
|
|
|
66
66
|
return out;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A repository linked elsewhere names the product that holds it (PRO-2800): the API says "another product", and a
|
|
71
|
+
* reviewer spent a call finding which. One read, the products the key can see; null leaves the API's refusal.
|
|
72
|
+
*/
|
|
73
|
+
async function holderOf(client: TaigaClient, error: unknown, productId: string) {
|
|
74
|
+
if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
|
|
75
|
+
const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? '')?.[1];
|
|
76
|
+
if (!name) return null;
|
|
77
|
+
try {
|
|
78
|
+
// The refusal names the repository only, not its host, and the same name can live on github.com and on an
|
|
79
|
+
// enterprise host: these are the products with a repository of that name, which is as exact as it can say.
|
|
80
|
+
const holders = (await client.listProducts({ repository: name, limit: 20 })).data.filter((p) => p.id !== productId);
|
|
81
|
+
const said =
|
|
82
|
+
holders.length > 0
|
|
83
|
+
? `Products this key can see with a repository named ${name}: ${holders.map((p) => `${p.name} (${p.id})`).join(', ')}.`
|
|
84
|
+
: 'It is linked to a product this key cannot see.';
|
|
85
|
+
return toolError(
|
|
86
|
+
'CONFLICT',
|
|
87
|
+
`${name} is already linked to another product. ${said} A repository belongs to one product; unlink it there ` +
|
|
88
|
+
'first (taiga_unlink_repository) if this product should have it.',
|
|
89
|
+
{ holders: holders.map((p) => ({ productId: p.id, name: p.name })) }
|
|
90
|
+
);
|
|
91
|
+
} catch (inner) {
|
|
92
|
+
if (inner instanceof TaigaApiError || isNetworkFailure(inner)) return null;
|
|
93
|
+
throw inner;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
/**
|
|
70
98
|
* A product in setup says what it still needs after a repository write, as create_product and get_product do
|
|
71
99
|
* (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
|
|
@@ -132,8 +160,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
132
160
|
title: 'Find a repository to link',
|
|
133
161
|
description:
|
|
134
162
|
"List the repositories the organization's GitHub connection can reach, to link one with " +
|
|
135
|
-
"taiga_link_repository (it takes the row's id
|
|
136
|
-
'id, and not a link ID). query searches by name; account (login, owner, or ' +
|
|
163
|
+
"taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or " +
|
|
137
164
|
'installationId) reads one account, else rows come from every account in turn. linked: true means a ' +
|
|
138
165
|
'product already links it (taiga_get_product with its URL says which). No connection means GitHub is not ' +
|
|
139
166
|
'connected yet; someone who can install the app does that once, in the web.',
|
|
@@ -236,9 +263,11 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
236
263
|
'history is imported.',
|
|
237
264
|
inputSchema: z.object({
|
|
238
265
|
productId,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
266
|
+
// Named for the list it comes from: it is Taiga's record of a repository the connection reaches, and three
|
|
267
|
+
// reviews read githubRepositoryId as GitHub's numeric id (PRO-2800).
|
|
268
|
+
linkableRepositoryId: idInput(
|
|
269
|
+
'linkable repository',
|
|
270
|
+
"taiga_list_linkable_repositories gives it (a row's id; not GitHub's numeric id, not a link ID)"
|
|
242
271
|
),
|
|
243
272
|
replacesRepositoryId: repositoryId.optional().describe('The link this one replaces'),
|
|
244
273
|
}),
|
|
@@ -246,10 +275,17 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
246
275
|
},
|
|
247
276
|
(params) =>
|
|
248
277
|
runTool(async () => {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
278
|
+
let linked;
|
|
279
|
+
try {
|
|
280
|
+
linked = await client.linkRepository(params.productId, {
|
|
281
|
+
githubRepositoryId: params.linkableRepositoryId,
|
|
282
|
+
...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const holder = await holderOf(client, error, params.productId);
|
|
286
|
+
if (holder) return holder;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
253
289
|
return toolResult({
|
|
254
290
|
...pickFields([linked.data], LINK_FIELDS)[0],
|
|
255
291
|
...(await readinessAfter(client, params.productId)),
|
package/src/tools/sections.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
-
import { isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
|
|
16
|
+
import { CONTROL_ID_SOURCE, isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
|
|
17
17
|
import { toolError, toolResult } from './errors.js';
|
|
18
18
|
|
|
19
19
|
/** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
|
|
@@ -212,6 +212,34 @@ function controlSections(sections: Sections, ids: readonly string[]): Record<str
|
|
|
212
212
|
return found;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* A policy outline names the controls each section states (PRO-2800): a reviewer guessed secretsManagement from its
|
|
217
|
+
* name, which works for secrets and not for "what covers logging of secret access". Read off each section's text.
|
|
218
|
+
*/
|
|
219
|
+
function withControls(outline: { id: string; characters: number }[], sections: Sections, policyType: unknown) {
|
|
220
|
+
// The first cell of a control table row, as the API reads a policy's controls (policy-controls.ts CONTROL_ROW):
|
|
221
|
+
// not AES-256 in prose, nor "see SEC-5.1" in a section that only cites it.
|
|
222
|
+
// A reserved row (_Reserved_) is a retired id, not a control, as heldControlMap skips it.
|
|
223
|
+
const pattern = new RegExp(`^\\|\\s*(${CONTROL_ID_SOURCE})\\s*\\|\\s*(.*?)\\s*(?:\\||$)`, 'gm');
|
|
224
|
+
return outline.map((entry) => {
|
|
225
|
+
const text = sectionContent(sections, entry.id) ?? '';
|
|
226
|
+
const ids = [
|
|
227
|
+
...new Set(
|
|
228
|
+
[...text.matchAll(pattern)]
|
|
229
|
+
.filter((match) => !/^_?Reserved_?/i.test(match[2] ?? ''))
|
|
230
|
+
.map((match) => match[1]!)
|
|
231
|
+
// A base control belongs to the policy its prefix names: an SEC row in the data policy is a stale mention,
|
|
232
|
+
// as heldControlMap reads it. An overlay prefix stays where it is written.
|
|
233
|
+
.filter((id) => {
|
|
234
|
+
const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split('-')[0] ?? ''];
|
|
235
|
+
return owner === undefined || owner === policyType;
|
|
236
|
+
})
|
|
237
|
+
),
|
|
238
|
+
];
|
|
239
|
+
return ids.length > 0 ? { ...entry, controls: ids } : entry;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
/**
|
|
216
244
|
* The answer to a read that names sections: the ones the document has, the
|
|
217
245
|
* rest listed with their sizes, and the asked-for ids it lacks under
|
|
@@ -254,7 +282,10 @@ export function sectionReadResult(
|
|
|
254
282
|
const { data: _data, ...meta } = cut.doc;
|
|
255
283
|
return toolResult({
|
|
256
284
|
...meta,
|
|
257
|
-
outline:
|
|
285
|
+
outline:
|
|
286
|
+
options.controlIds && before
|
|
287
|
+
? withControls(cut.omittedSections, before, (doc as { type?: unknown }).type)
|
|
288
|
+
: cut.omittedSections,
|
|
258
289
|
...(unknownSections.length > 0 ? { unknownSections } : {}),
|
|
259
290
|
...controlNote,
|
|
260
291
|
});
|
|
Binary file
|
|
Binary file
|