ai-dev-requirements 0.2.0 → 0.3.0
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.cjs +792 -46
- package/dist/index.mjs +793 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
|
|
|
4
4
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
import { z } from "zod/v4";
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
7
|
-
import crypto from "node:crypto";
|
|
7
|
+
import crypto, { createHash, randomBytes } from "node:crypto";
|
|
8
8
|
import { lookup } from "node:dns/promises";
|
|
9
9
|
import { isIP } from "node:net";
|
|
10
10
|
//#region ../../src/config/loader.ts
|
|
@@ -165,7 +165,7 @@ function loadConfig(startDir) {
|
|
|
165
165
|
}
|
|
166
166
|
//#endregion
|
|
167
167
|
//#region package.json
|
|
168
|
-
var version = "0.
|
|
168
|
+
var version = "0.3.0";
|
|
169
169
|
//#endregion
|
|
170
170
|
//#region ../../src/utils/map-status.ts
|
|
171
171
|
const ONES_STATUS_MAP = {
|
|
@@ -234,6 +234,39 @@ function workItemKindLabel(kind) {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
//#endregion
|
|
237
|
+
//#region ../../src/utils/requirement-decomposition.ts
|
|
238
|
+
function canonicalize(value) {
|
|
239
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
240
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, nested]) => [key, canonicalize(nested)]));
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
function stableHash(value) {
|
|
244
|
+
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
|
245
|
+
}
|
|
246
|
+
function compareNullableDate$1(left, right) {
|
|
247
|
+
if (left === right) return 0;
|
|
248
|
+
if (left === null) return 1;
|
|
249
|
+
if (right === null) return -1;
|
|
250
|
+
return left.localeCompare(right);
|
|
251
|
+
}
|
|
252
|
+
function sortRequirementTasks(tasks) {
|
|
253
|
+
return [...tasks].sort((left, right) => compareNullableDate$1(left.planStartDate, right.planStartDate) || compareNullableDate$1(left.planEndDate, right.planEndDate) || left.displayId.localeCompare(right.displayId));
|
|
254
|
+
}
|
|
255
|
+
function buildRequirementDecompositionBaseline(requirement, tasks, metadata = {}) {
|
|
256
|
+
return {
|
|
257
|
+
requirementVersion: metadata.version ?? null,
|
|
258
|
+
requirementUpdatedAt: metadata.updatedAt ?? null,
|
|
259
|
+
requirementHash: stableHash(requirement),
|
|
260
|
+
relatedTasksHash: stableHash(tasks)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function buildRequirementDecompositionPlanHash(input) {
|
|
264
|
+
return stableHash(input);
|
|
265
|
+
}
|
|
266
|
+
function isSameRequirementBaseline(left, right) {
|
|
267
|
+
return left.requirementVersion === right.requirementVersion && left.requirementUpdatedAt === right.requirementUpdatedAt && left.requirementHash === right.requirementHash && left.relatedTasksHash === right.relatedTasksHash;
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
237
270
|
//#region ../../src/adapters/base.ts
|
|
238
271
|
/**
|
|
239
272
|
* Abstract base class for source adapters.
|
|
@@ -274,8 +307,12 @@ const TASK_DETAIL_QUERY = `
|
|
|
274
307
|
project { uuid name }
|
|
275
308
|
parent { uuid number issueType { uuid name } }
|
|
276
309
|
relatedTasks {
|
|
277
|
-
uuid number name
|
|
310
|
+
key uuid number name
|
|
311
|
+
description
|
|
312
|
+
descriptionText
|
|
313
|
+
desc_rich: description
|
|
278
314
|
issueType { uuid name }
|
|
315
|
+
subIssueType { uuid name detailType }
|
|
279
316
|
status { uuid name category }
|
|
280
317
|
assign { uuid name }
|
|
281
318
|
}
|
|
@@ -321,7 +358,8 @@ const SEARCH_TASKS_QUERY = `
|
|
|
321
358
|
status { uuid name category }
|
|
322
359
|
priority { value }
|
|
323
360
|
assign { uuid name }
|
|
324
|
-
project { uuid name }
|
|
361
|
+
project { uuid name identifier }
|
|
362
|
+
parent { uuid number issueType { uuid name } }
|
|
325
363
|
}
|
|
326
364
|
}
|
|
327
365
|
}
|
|
@@ -670,6 +708,135 @@ function htmlToPlainText(html) {
|
|
|
670
708
|
function getTaskDetailText(task) {
|
|
671
709
|
return task.descriptionText?.trim() || htmlToPlainText(task.desc_rich ?? task.description ?? "");
|
|
672
710
|
}
|
|
711
|
+
function firstString(record, keys) {
|
|
712
|
+
for (const key of keys) {
|
|
713
|
+
const value = record[key];
|
|
714
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
715
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
function taskInfoFieldValue(record, fieldUuid) {
|
|
720
|
+
const direct = record[fieldUuid];
|
|
721
|
+
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
|
722
|
+
const collections = [
|
|
723
|
+
record.field_values,
|
|
724
|
+
record.fieldValues,
|
|
725
|
+
record.fields
|
|
726
|
+
];
|
|
727
|
+
for (const collection of collections) if (Array.isArray(collection)) for (const entry of collection) {
|
|
728
|
+
if (!isRecord(entry)) continue;
|
|
729
|
+
if (firstString(entry, [
|
|
730
|
+
"field_uuid",
|
|
731
|
+
"fieldUuid",
|
|
732
|
+
"uuid"
|
|
733
|
+
]) !== fieldUuid) continue;
|
|
734
|
+
const value = firstString(entry, [
|
|
735
|
+
"date_value",
|
|
736
|
+
"dateValue",
|
|
737
|
+
"value",
|
|
738
|
+
"field_value",
|
|
739
|
+
"fieldValue"
|
|
740
|
+
]);
|
|
741
|
+
if (value) return value;
|
|
742
|
+
}
|
|
743
|
+
else if (isRecord(collection)) {
|
|
744
|
+
const entry = collection[fieldUuid];
|
|
745
|
+
if (typeof entry === "string" && entry.trim()) return entry.trim();
|
|
746
|
+
if (isRecord(entry)) {
|
|
747
|
+
const value = firstString(entry, [
|
|
748
|
+
"date_value",
|
|
749
|
+
"dateValue",
|
|
750
|
+
"value",
|
|
751
|
+
"field_value",
|
|
752
|
+
"fieldValue"
|
|
753
|
+
]);
|
|
754
|
+
if (value) return value;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
function taskInfoDate(record, kind) {
|
|
760
|
+
let value;
|
|
761
|
+
if (kind === "start") value = firstString(record, [
|
|
762
|
+
"planStartDate",
|
|
763
|
+
"plan_start_date",
|
|
764
|
+
"plan_start"
|
|
765
|
+
]) ?? taskInfoFieldValue(record, "field027");
|
|
766
|
+
else value = firstString(record, [
|
|
767
|
+
"planEndDate",
|
|
768
|
+
"plan_end_date",
|
|
769
|
+
"plan_end"
|
|
770
|
+
]) ?? taskInfoFieldValue(record, "field028");
|
|
771
|
+
if (!value) return null;
|
|
772
|
+
if (isValidOnesDate(value)) return value;
|
|
773
|
+
const unixSeconds = Number(value);
|
|
774
|
+
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
|
|
775
|
+
return (/* @__PURE__ */ new Date(unixSeconds * 1e3)).toISOString().slice(0, 10);
|
|
776
|
+
}
|
|
777
|
+
const ONES_MANHOUR_UNITS_PER_HOUR = 1e5;
|
|
778
|
+
function taskInfoHours(record, keys) {
|
|
779
|
+
for (const key of keys) {
|
|
780
|
+
const value = record[key];
|
|
781
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue;
|
|
782
|
+
return value / ONES_MANHOUR_UNITS_PER_HOUR;
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
function inferredParentDisplayId(task, info) {
|
|
787
|
+
const explicit = firstString(info, ["parent_display_id", "parentDisplayId"]);
|
|
788
|
+
if (explicit) return explicit;
|
|
789
|
+
return task.name.trim().match(/^([A-Z][A-Z0-9]*-\d+)\b/i)?.[1]?.toUpperCase() ?? null;
|
|
790
|
+
}
|
|
791
|
+
function compareNullableDate(left, right) {
|
|
792
|
+
if (left === right) return 0;
|
|
793
|
+
if (left === null) return 1;
|
|
794
|
+
if (right === null) return -1;
|
|
795
|
+
return left.localeCompare(right);
|
|
796
|
+
}
|
|
797
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
798
|
+
const results = [];
|
|
799
|
+
let cursor = 0;
|
|
800
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
801
|
+
while (cursor < items.length) {
|
|
802
|
+
const index = cursor;
|
|
803
|
+
cursor += 1;
|
|
804
|
+
results[index] = await mapper(items[index], index);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
await Promise.all(workers);
|
|
808
|
+
return results;
|
|
809
|
+
}
|
|
810
|
+
function taskInfoDetail(record, fallback) {
|
|
811
|
+
const text = firstString(record, ["descriptionText", "description_text"]);
|
|
812
|
+
if (text) return text;
|
|
813
|
+
const rich = firstString(record, [
|
|
814
|
+
"desc_rich",
|
|
815
|
+
"description",
|
|
816
|
+
"desc"
|
|
817
|
+
]);
|
|
818
|
+
return rich ? htmlToPlainText(rich) : getTaskDetailText(fallback);
|
|
819
|
+
}
|
|
820
|
+
function taskDisplayId(info, task, fallbackIdentifier) {
|
|
821
|
+
const explicit = firstString(info, ["displayId", "display_id"]);
|
|
822
|
+
if (explicit) return explicit;
|
|
823
|
+
return fallbackIdentifier ? `${fallbackIdentifier}-${task.number}` : `#${task.number}`;
|
|
824
|
+
}
|
|
825
|
+
function extractHtmlImageReferences(html) {
|
|
826
|
+
return Array.from(html.matchAll(/<img\b[^>]*>/gi), (match) => {
|
|
827
|
+
const tag = match[0];
|
|
828
|
+
const srcMatch = tag.match(/\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
829
|
+
const resourceMatch = tag.match(/\bdata-uuid\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
830
|
+
return {
|
|
831
|
+
tag,
|
|
832
|
+
src: (srcMatch?.[1] ?? srcMatch?.[2] ?? "").replace(/&/gi, "&").trim(),
|
|
833
|
+
resourceUuid: (resourceMatch?.[1] ?? resourceMatch?.[2] ?? "").trim()
|
|
834
|
+
};
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
function containsInlineTaskImages(task) {
|
|
838
|
+
return [task.description, task.desc_rich].some((value) => typeof value === "string" && /<img\b/i.test(value)) || /\[(?:image|图片)\]/i.test(task.descriptionText ?? "");
|
|
839
|
+
}
|
|
673
840
|
function isRecord(value) {
|
|
674
841
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
675
842
|
}
|
|
@@ -1108,7 +1275,11 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1108
1275
|
return response.json();
|
|
1109
1276
|
}
|
|
1110
1277
|
async fetchRelatedActivities(taskKey) {
|
|
1111
|
-
|
|
1278
|
+
try {
|
|
1279
|
+
return (await this.onesql(RELATED_ACTIVITIES_QUERY, { key: taskKey }, "Task")).data?.task?.relatedActivities ?? [];
|
|
1280
|
+
} catch {
|
|
1281
|
+
return [];
|
|
1282
|
+
}
|
|
1112
1283
|
}
|
|
1113
1284
|
async searchTaskByNumber(taskNumber) {
|
|
1114
1285
|
const session = await this.login();
|
|
@@ -1293,30 +1464,92 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1293
1464
|
return null;
|
|
1294
1465
|
}
|
|
1295
1466
|
}
|
|
1467
|
+
getAttachmentResourceUuid(image) {
|
|
1468
|
+
if (image.src) try {
|
|
1469
|
+
const source = new URL(image.src, this.config.apiBase);
|
|
1470
|
+
if (source.origin === new URL(this.config.apiBase).origin) {
|
|
1471
|
+
const match = source.pathname.match(/\/res\/attachment\/([^/]+)$/);
|
|
1472
|
+
const resourceUuid = match?.[1] ? decodeOnesPathIdentifier(match[1]) : null;
|
|
1473
|
+
if (resourceUuid) return resourceUuid;
|
|
1474
|
+
}
|
|
1475
|
+
} catch {}
|
|
1476
|
+
return image.resourceUuid;
|
|
1477
|
+
}
|
|
1296
1478
|
/**
|
|
1297
1479
|
* Replace stale image URLs in HTML with fresh signed URLs from the attachment API.
|
|
1298
|
-
*
|
|
1480
|
+
* Prefer the resource identifier from the attachment URL because ONES data-uuid
|
|
1481
|
+
* can identify the editor node instead of the underlying attachment.
|
|
1299
1482
|
*/
|
|
1300
|
-
async refreshImageUrls(html) {
|
|
1483
|
+
async refreshImageUrls(html, freshUrlCache = /* @__PURE__ */ new Map()) {
|
|
1301
1484
|
if (!html) return html;
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1485
|
+
const images = extractHtmlImageReferences(html).flatMap((image) => {
|
|
1486
|
+
const resourceUuid = this.getAttachmentResourceUuid(image);
|
|
1487
|
+
return resourceUuid ? [{
|
|
1488
|
+
image,
|
|
1489
|
+
resourceUuid
|
|
1490
|
+
}] : [];
|
|
1491
|
+
});
|
|
1492
|
+
if (images.length === 0) return html;
|
|
1493
|
+
const replacements = await Promise.all(images.map(async ({ image, resourceUuid }) => {
|
|
1494
|
+
let freshUrl = freshUrlCache.get(resourceUuid);
|
|
1495
|
+
if (!freshUrl) {
|
|
1496
|
+
freshUrl = this.getAttachmentUrl(resourceUuid);
|
|
1497
|
+
freshUrlCache.set(resourceUuid, freshUrl);
|
|
1498
|
+
}
|
|
1307
1499
|
return {
|
|
1308
|
-
fullMatch:
|
|
1309
|
-
|
|
1310
|
-
freshUrl
|
|
1500
|
+
fullMatch: image.tag,
|
|
1501
|
+
freshUrl: await freshUrl
|
|
1311
1502
|
};
|
|
1312
1503
|
}));
|
|
1313
1504
|
let result = html;
|
|
1314
|
-
for (const { fullMatch, freshUrl } of replacements)
|
|
1315
|
-
|
|
1505
|
+
for (const { fullMatch, freshUrl } of replacements) {
|
|
1506
|
+
if (!freshUrl) continue;
|
|
1507
|
+
const updatedImg = /\bsrc\s*=/i.test(fullMatch) ? fullMatch.replace(/\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/i, `src="${freshUrl}"`) : fullMatch.replace(/<img\b/i, `<img src="${freshUrl}"`);
|
|
1316
1508
|
result = result.replace(fullMatch, updatedImg);
|
|
1317
1509
|
}
|
|
1318
1510
|
return result;
|
|
1319
1511
|
}
|
|
1512
|
+
async getFreshTaskDescriptions(task) {
|
|
1513
|
+
const taskInfo = await this.fetchTaskInfo(task.uuid);
|
|
1514
|
+
const rawDescription = typeof taskInfo.desc === "string" ? taskInfo.desc : task.description ?? "";
|
|
1515
|
+
const rawDescriptionRich = typeof taskInfo.desc_rich === "string" ? taskInfo.desc_rich : task.desc_rich ?? task.description ?? "";
|
|
1516
|
+
const freshUrlCache = /* @__PURE__ */ new Map();
|
|
1517
|
+
const [description, descriptionRich] = await Promise.all([this.refreshImageUrls(rawDescription, freshUrlCache), this.refreshImageUrls(rawDescriptionRich, freshUrlCache)]);
|
|
1518
|
+
return {
|
|
1519
|
+
description,
|
|
1520
|
+
descriptionRich
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
async getTaskImageAttachments(task) {
|
|
1524
|
+
const { description, descriptionRich } = await this.getFreshTaskDescriptions(task);
|
|
1525
|
+
const images = [...extractHtmlImageReferences(descriptionRich), ...extractHtmlImageReferences(description)];
|
|
1526
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1527
|
+
const attachments = [];
|
|
1528
|
+
for (const image of images) {
|
|
1529
|
+
if (!image.src) continue;
|
|
1530
|
+
let url;
|
|
1531
|
+
try {
|
|
1532
|
+
url = new URL(image.src, this.config.apiBase).toString();
|
|
1533
|
+
} catch {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (this.classifyRemoteImageUrl(url) === "untrusted") continue;
|
|
1537
|
+
const identity = image.resourceUuid || url;
|
|
1538
|
+
if (seen.has(identity)) continue;
|
|
1539
|
+
seen.add(identity);
|
|
1540
|
+
const pathname = new URL(url).pathname;
|
|
1541
|
+
const pathName = attachmentNameFromPath(pathname);
|
|
1542
|
+
const name = pathName && pathName !== "/" ? pathName : `image-${attachments.length + 1}.png`;
|
|
1543
|
+
attachments.push({
|
|
1544
|
+
id: image.resourceUuid || `${task.uuid}-image-${attachments.length + 1}`,
|
|
1545
|
+
name,
|
|
1546
|
+
url,
|
|
1547
|
+
mimeType: mimeTypeFromFileName(pathname),
|
|
1548
|
+
size: 0
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
return attachments;
|
|
1552
|
+
}
|
|
1320
1553
|
/**
|
|
1321
1554
|
* Fetch wiki page content via REST API.
|
|
1322
1555
|
* Endpoint: /wiki/api/wiki/team/{teamUuid}/online_page/{wikiUuid}/content
|
|
@@ -1436,7 +1669,7 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1436
1669
|
title: `Wiki ${wikiUuid}`,
|
|
1437
1670
|
uuid: wikiUuid
|
|
1438
1671
|
});
|
|
1439
|
-
const wikiContents = await Promise.all([...wikiRefs.values()].map(async (wiki) => {
|
|
1672
|
+
const [wikiContents, taskImageAttachments] = await Promise.all([Promise.all([...wikiRefs.values()].map(async (wiki) => {
|
|
1440
1673
|
const rendered = await this.fetchWikiContent(wiki.uuid);
|
|
1441
1674
|
return {
|
|
1442
1675
|
title: wiki.title,
|
|
@@ -1444,7 +1677,7 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1444
1677
|
content: rendered.content,
|
|
1445
1678
|
attachments: rendered.attachments
|
|
1446
1679
|
};
|
|
1447
|
-
}));
|
|
1680
|
+
})), containsInlineTaskImages(task) ? this.getTaskImageAttachments(task) : Promise.resolve([])]);
|
|
1448
1681
|
const parts = [];
|
|
1449
1682
|
parts.push(`# #${task.number} ${task.name}`);
|
|
1450
1683
|
parts.push("");
|
|
@@ -1504,7 +1737,7 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1504
1737
|
parts.push(detailText);
|
|
1505
1738
|
}
|
|
1506
1739
|
const wikiAttachments = wikiContents.flatMap((wiki) => wiki.attachments);
|
|
1507
|
-
const req = toRequirement(task, parts.join("\n"), wikiAttachments);
|
|
1740
|
+
const req = toRequirement(task, parts.join("\n"), [...wikiAttachments, ...taskImageAttachments]);
|
|
1508
1741
|
req.raw = {
|
|
1509
1742
|
...req.raw,
|
|
1510
1743
|
relatedActivities,
|
|
@@ -1597,13 +1830,13 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1597
1830
|
filterGroup: [filter],
|
|
1598
1831
|
search: null,
|
|
1599
1832
|
pagination: {
|
|
1600
|
-
limit: pageSize * page,
|
|
1833
|
+
limit: intent === "all_tasks" ? 1e3 : pageSize * page,
|
|
1601
1834
|
preciseCount: false
|
|
1602
1835
|
},
|
|
1603
1836
|
limit: 1e3
|
|
1604
1837
|
}, "group-task-data")).data?.buckets?.flatMap((b) => b.tasks ?? []) ?? [];
|
|
1605
1838
|
if (intent === "all_bugs") tasks = tasks.filter((task) => classifyOnesWorkItem(task.issueType, task.subIssueType) === "defect").filter((task) => isOpenOrInProgressBug(task)).sort((a, b) => getBugStatusPriority(a) - getBugStatusPriority(b));
|
|
1606
|
-
if (intent === "all_tasks") tasks = tasks.filter((task) => classifyOnesWorkItem(task.issueType, task.subIssueType) === "task");
|
|
1839
|
+
if (intent === "all_tasks") tasks = tasks.filter((task) => classifyOnesWorkItem(task.issueType, task.subIssueType) === "task").filter((task) => task.status?.category === "to_do" || task.status?.category === "in_progress");
|
|
1607
1840
|
if (assigneeUuid) tasks = tasks.filter((task) => task.assign?.uuid === assigneeUuid);
|
|
1608
1841
|
if (intent === "keyword" && params.query) {
|
|
1609
1842
|
const keyword = params.query.trim();
|
|
@@ -1621,6 +1854,142 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1621
1854
|
pageSize
|
|
1622
1855
|
};
|
|
1623
1856
|
}
|
|
1857
|
+
async listPendingWorkItems() {
|
|
1858
|
+
const items = await mapWithConcurrency(((await this.graphql(SEARCH_TASKS_QUERY, {
|
|
1859
|
+
groupBy: { tasks: {} },
|
|
1860
|
+
groupOrderBy: null,
|
|
1861
|
+
orderBy: {
|
|
1862
|
+
position: "ASC",
|
|
1863
|
+
createTime: "DESC"
|
|
1864
|
+
},
|
|
1865
|
+
filterGroup: [{
|
|
1866
|
+
assign_in: ["${currentUser}"],
|
|
1867
|
+
status_notIn: DEFAULT_STATUS_NOT_IN
|
|
1868
|
+
}],
|
|
1869
|
+
search: null,
|
|
1870
|
+
pagination: {
|
|
1871
|
+
limit: 1e3,
|
|
1872
|
+
preciseCount: false
|
|
1873
|
+
},
|
|
1874
|
+
limit: 1e3
|
|
1875
|
+
}, "group-task-data")).data?.buckets?.flatMap((bucket) => bucket.tasks ?? []) ?? []).filter((task) => task.status?.category === "to_do" || task.status?.category === "in_progress").filter((task) => {
|
|
1876
|
+
const kind = classifyOnesWorkItem(task.issueType, task.subIssueType);
|
|
1877
|
+
return kind === "requirement" || kind === "task";
|
|
1878
|
+
}), 6, async (task) => {
|
|
1879
|
+
const info = await this.fetchTaskInfo(task.uuid);
|
|
1880
|
+
const partial = Object.keys(info).length === 0;
|
|
1881
|
+
const kind = classifyOnesWorkItem(task.issueType, task.subIssueType);
|
|
1882
|
+
const statusCategory = task.status.category === "in_progress" ? "in_progress" : "to_do";
|
|
1883
|
+
const fallbackIdentifier = task.project?.identifier?.toUpperCase() ?? null;
|
|
1884
|
+
return {
|
|
1885
|
+
uuid: task.uuid,
|
|
1886
|
+
displayId: taskDisplayId(info, task, fallbackIdentifier),
|
|
1887
|
+
kind: kind === "requirement" ? "requirement" : "task",
|
|
1888
|
+
title: firstString(info, ["summary", "name"]) ?? task.name,
|
|
1889
|
+
statusName: task.status.name,
|
|
1890
|
+
statusCategory,
|
|
1891
|
+
assigneeName: task.assign?.name ?? null,
|
|
1892
|
+
projectName: task.project?.name ?? null,
|
|
1893
|
+
parentUuid: firstString(info, ["parent_uuid", "parentUuid"]) ?? task.parent?.uuid ?? null,
|
|
1894
|
+
parentDisplayId: kind === "task" ? inferredParentDisplayId(task, info) : null,
|
|
1895
|
+
actualHours: taskInfoHours(info, [
|
|
1896
|
+
"total_manhour",
|
|
1897
|
+
"totalManhour",
|
|
1898
|
+
"actual_manhour"
|
|
1899
|
+
]),
|
|
1900
|
+
remainingHours: taskInfoHours(info, ["remaining_manhour", "remainingManhour"]),
|
|
1901
|
+
estimatedHours: taskInfoHours(info, [
|
|
1902
|
+
"assess_manhour",
|
|
1903
|
+
"assessManhour",
|
|
1904
|
+
"estimated_manhour"
|
|
1905
|
+
]),
|
|
1906
|
+
planStartDate: taskInfoDate(info, "start"),
|
|
1907
|
+
planEndDate: taskInfoDate(info, "end"),
|
|
1908
|
+
partial,
|
|
1909
|
+
warnings: partial ? ["ONES task detail GET returned no data"] : []
|
|
1910
|
+
};
|
|
1911
|
+
});
|
|
1912
|
+
items.sort((left, right) => compareNullableDate(left.planStartDate, right.planStartDate) || compareNullableDate(left.planEndDate, right.planEndDate) || left.displayId.localeCompare(right.displayId));
|
|
1913
|
+
return {
|
|
1914
|
+
items,
|
|
1915
|
+
total: items.length,
|
|
1916
|
+
partialCount: items.filter((item) => item.partial).length,
|
|
1917
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
async getRequirementDecompositionContext(params) {
|
|
1921
|
+
const workItem = await this.getRequirement({ id: params.requirementId });
|
|
1922
|
+
if (workItem.raw.workItemKind !== "requirement") {
|
|
1923
|
+
const kind = typeof workItem.raw.workItemKind === "string" ? workItem.raw.workItemKind : workItem.type;
|
|
1924
|
+
throw new Error(`ONES: "${params.requirementId}" is ${kind}, not a requirement. Only requirements can be decomposed.`);
|
|
1925
|
+
}
|
|
1926
|
+
const raw = workItem.raw;
|
|
1927
|
+
if (!Number.isInteger(raw.number)) throw new TypeError("ONES: Standalone wiki pages cannot be decomposed into requirement tasks");
|
|
1928
|
+
const parsedDisplayId = parseDisplayId(params.requirementId);
|
|
1929
|
+
const requirementInfo = await this.fetchTaskInfo(workItem.id);
|
|
1930
|
+
const projectIdentifier = parsedDisplayId?.identifier.toUpperCase() ?? firstString(requirementInfo, ["projectIdentifier", "project_identifier"]);
|
|
1931
|
+
const displayId = firstString(requirementInfo, ["displayId", "display_id"]) ?? (projectIdentifier ? `${projectIdentifier}-${raw.number}` : `#${raw.number}`);
|
|
1932
|
+
const relatedTasks = (raw.relatedTasks ?? []).filter((task) => classifyOnesWorkItem(task.issueType, task.subIssueType) === "task");
|
|
1933
|
+
const relatedInfos = await Promise.all(relatedTasks.map((task) => this.fetchTaskInfo(task.uuid)));
|
|
1934
|
+
const tasks = sortRequirementTasks(relatedTasks.map((task, index) => {
|
|
1935
|
+
const info = relatedInfos[index] ?? {};
|
|
1936
|
+
const statusCategory = task.status?.category ?? "unknown";
|
|
1937
|
+
return {
|
|
1938
|
+
uuid: task.uuid,
|
|
1939
|
+
displayId: taskDisplayId(info, task, projectIdentifier),
|
|
1940
|
+
name: task.name,
|
|
1941
|
+
detail: taskInfoDetail(info, task),
|
|
1942
|
+
statusName: task.status?.name ?? "Unknown",
|
|
1943
|
+
statusCategory,
|
|
1944
|
+
pending: statusCategory === "to_do" || statusCategory === "in_progress",
|
|
1945
|
+
assigneeName: task.assign?.name ?? null,
|
|
1946
|
+
assigneeUuid: task.assign?.uuid ?? null,
|
|
1947
|
+
planStartDate: taskInfoDate(info, "start"),
|
|
1948
|
+
planEndDate: taskInfoDate(info, "end")
|
|
1949
|
+
};
|
|
1950
|
+
}));
|
|
1951
|
+
const requirement = {
|
|
1952
|
+
workItemKind: "requirement",
|
|
1953
|
+
uuid: workItem.id,
|
|
1954
|
+
displayId,
|
|
1955
|
+
name: raw.name ?? workItem.title,
|
|
1956
|
+
detail: typeof workItem.raw.sourceDescription === "string" ? workItem.raw.sourceDescription : workItem.description,
|
|
1957
|
+
issueTypeName: raw.subIssueType?.name ?? raw.issueType?.name ?? "需求",
|
|
1958
|
+
statusName: raw.status?.name ?? workItem.status,
|
|
1959
|
+
statusCategory: raw.status?.category ?? workItem.status,
|
|
1960
|
+
projectUuid: raw.project?.uuid ?? null,
|
|
1961
|
+
projectName: raw.project?.name ?? null,
|
|
1962
|
+
assigneeUuid: raw.assign?.uuid ?? null,
|
|
1963
|
+
assigneeName: raw.assign?.name ?? workItem.assignee
|
|
1964
|
+
};
|
|
1965
|
+
const baseline = buildRequirementDecompositionBaseline(requirement, tasks, {
|
|
1966
|
+
version: firstString(requirementInfo, [
|
|
1967
|
+
"version",
|
|
1968
|
+
"version_uuid",
|
|
1969
|
+
"versionUuid"
|
|
1970
|
+
]),
|
|
1971
|
+
updatedAt: firstString(requirementInfo, [
|
|
1972
|
+
"updatedAt",
|
|
1973
|
+
"updated_at",
|
|
1974
|
+
"updateTime",
|
|
1975
|
+
"update_time"
|
|
1976
|
+
])
|
|
1977
|
+
});
|
|
1978
|
+
return {
|
|
1979
|
+
decompositionRelation: {
|
|
1980
|
+
verified: false,
|
|
1981
|
+
uuid: null,
|
|
1982
|
+
name: null
|
|
1983
|
+
},
|
|
1984
|
+
requirement,
|
|
1985
|
+
tasks,
|
|
1986
|
+
pendingTasks: tasks.filter((task) => task.pending),
|
|
1987
|
+
baseline
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
async createRequirementDecomposition(_params) {
|
|
1991
|
+
throw new Error("ONES: Requirement task creation is unavailable because the production create/relationship API contract has not been confirmed. No write request was sent.");
|
|
1992
|
+
}
|
|
1624
1993
|
async addManhour(params) {
|
|
1625
1994
|
const description = params.description.trim();
|
|
1626
1995
|
if (!description) throw new Error("ONES: description is required");
|
|
@@ -1712,35 +2081,13 @@ var OnesAdapter = class extends BaseAdapter {
|
|
|
1712
2081
|
}));
|
|
1713
2082
|
}
|
|
1714
2083
|
async getIssueDetail(params) {
|
|
1715
|
-
|
|
1716
|
-
const numMatch = params.issueId.match(/^#?(\d+)$/);
|
|
1717
|
-
if (numMatch) {
|
|
1718
|
-
const taskNumber = Number.parseInt(numMatch[1], 10);
|
|
1719
|
-
const found = ((await this.graphql(SEARCH_TASKS_QUERY, {
|
|
1720
|
-
groupBy: { tasks: {} },
|
|
1721
|
-
groupOrderBy: null,
|
|
1722
|
-
orderBy: { createTime: "DESC" },
|
|
1723
|
-
filterGroup: [{ number_in: [taskNumber] }],
|
|
1724
|
-
search: null,
|
|
1725
|
-
pagination: {
|
|
1726
|
-
limit: 10,
|
|
1727
|
-
preciseCount: false
|
|
1728
|
-
},
|
|
1729
|
-
limit: 10
|
|
1730
|
-
}, "group-task-data")).data?.buckets?.flatMap((b) => b.tasks ?? []) ?? []).find((t) => t.number === taskNumber);
|
|
1731
|
-
if (!found) throw new Error(`ONES: Issue #${taskNumber} not found in current team`);
|
|
1732
|
-
issueKey = `task-${found.uuid}`;
|
|
1733
|
-
} else issueKey = params.issueId.startsWith("task-") ? params.issueId : `task-${params.issueId}`;
|
|
2084
|
+
const { key: issueKey } = await this.resolveTaskRef(params.issueId);
|
|
1734
2085
|
const task = (await this.graphql(ISSUE_DETAIL_QUERY, { key: issueKey }, "Task")).data?.task;
|
|
1735
2086
|
if (!task) throw new Error(`ONES: Issue "${issueKey}" not found`);
|
|
1736
2087
|
const kind = classifyOnesWorkItem(task.issueType, task.subIssueType);
|
|
1737
2088
|
if (kind === "unknown") throw new Error(`ONES: Unable to classify "${params.issueId}" before get_issue_detail`);
|
|
1738
2089
|
if (kind === "requirement" || kind === "task") throw unsupportedWorkItemToolError(params.issueId, kind, "get_issue_detail", "get_work_item");
|
|
1739
|
-
const
|
|
1740
|
-
const rawDescription = taskInfo.desc ?? task.description ?? "";
|
|
1741
|
-
const rawDescRich = taskInfo.desc_rich ?? task.desc_rich ?? "";
|
|
1742
|
-
const freshDescription = await this.refreshImageUrls(rawDescription);
|
|
1743
|
-
const freshDescRich = await this.refreshImageUrls(rawDescRich);
|
|
2090
|
+
const { description: freshDescription, descriptionRich: freshDescRich } = await this.getFreshTaskDescriptions(task);
|
|
1744
2091
|
return {
|
|
1745
2092
|
key: task.key,
|
|
1746
2093
|
uuid: task.uuid,
|
|
@@ -2333,7 +2680,7 @@ async function downloadTrustedImages(urls, options) {
|
|
|
2333
2680
|
//#endregion
|
|
2334
2681
|
//#region ../../src/tools/get-issue-detail.ts
|
|
2335
2682
|
const GetIssueDetailSchema = z.object({
|
|
2336
|
-
issueId: z.string().describe("
|
|
2683
|
+
issueId: z.string().describe("ONES defect UUID, task key, number, or display ID (for example \"DEMO-2001\")"),
|
|
2337
2684
|
source: z.string().optional().describe("Source to fetch from. If omitted, uses the default source.")
|
|
2338
2685
|
});
|
|
2339
2686
|
/**
|
|
@@ -2542,6 +2889,66 @@ function formatWorkItem(req) {
|
|
|
2542
2889
|
return lines.join("\n");
|
|
2543
2890
|
}
|
|
2544
2891
|
//#endregion
|
|
2892
|
+
//#region ../../src/tools/list-pending-work-items.ts
|
|
2893
|
+
const ListPendingWorkItemsSchema = z.object({ source: z.string().optional().describe("Source to read. If omitted, uses the default source.") });
|
|
2894
|
+
function resolveAdapter$1(source, adapters, defaultSource) {
|
|
2895
|
+
const sourceType = source ?? defaultSource;
|
|
2896
|
+
if (!sourceType) throw new Error("No source specified and no default source configured");
|
|
2897
|
+
const adapter = adapters.get(sourceType);
|
|
2898
|
+
if (!adapter) throw new Error(`Source "${sourceType}" is not configured. Available: ${[...adapters.keys()].join(", ")}`);
|
|
2899
|
+
return adapter;
|
|
2900
|
+
}
|
|
2901
|
+
function sanitizeItem(item) {
|
|
2902
|
+
return {
|
|
2903
|
+
...item,
|
|
2904
|
+
displayId: sanitizeExternalInline(item.displayId),
|
|
2905
|
+
title: sanitizeExternalInline(item.title),
|
|
2906
|
+
statusName: sanitizeExternalInline(item.statusName),
|
|
2907
|
+
assigneeName: item.assigneeName ? sanitizeExternalInline(item.assigneeName) : null,
|
|
2908
|
+
projectName: item.projectName ? sanitizeExternalInline(item.projectName) : null,
|
|
2909
|
+
parentDisplayId: item.parentDisplayId ? sanitizeExternalInline(item.parentDisplayId) : null,
|
|
2910
|
+
warnings: item.warnings.map(sanitizeExternalInline)
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
function formatHours(value) {
|
|
2914
|
+
if (value === null) return "—";
|
|
2915
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)}h`;
|
|
2916
|
+
}
|
|
2917
|
+
function escapeTable(value) {
|
|
2918
|
+
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
2919
|
+
}
|
|
2920
|
+
function formatResult(result) {
|
|
2921
|
+
const lines = [
|
|
2922
|
+
"# Pending ONES Work Items",
|
|
2923
|
+
"",
|
|
2924
|
+
`- Total: ${result.total}`,
|
|
2925
|
+
`- Partial rows: ${result.partialCount}`,
|
|
2926
|
+
`- Fetched at: ${result.fetchedAt}`,
|
|
2927
|
+
"- Scope: current assignee; requirements and tasks; status is not started or in progress; defects excluded.",
|
|
2928
|
+
"",
|
|
2929
|
+
UNTRUSTED_SOURCE_NOTICE,
|
|
2930
|
+
"",
|
|
2931
|
+
"| Display ID | Type | Title | Status | Actual | Remaining | Estimate | Plan Start | Plan End |",
|
|
2932
|
+
"| --- | --- | --- | --- | ---: | ---: | ---: | --- | --- |"
|
|
2933
|
+
];
|
|
2934
|
+
for (const item of result.items) lines.push(`| ${escapeTable(item.displayId)} | ${item.kind} | ${escapeTable(item.title)} | ${escapeTable(item.statusName)} | ${formatHours(item.actualHours)} | ${formatHours(item.remainingHours)} | ${formatHours(item.estimatedHours)} | ${item.planStartDate ?? "—"} | ${item.planEndDate ?? "—"} |`);
|
|
2935
|
+
return lines.join("\n");
|
|
2936
|
+
}
|
|
2937
|
+
async function handleListPendingWorkItems(input, adapters, defaultSource) {
|
|
2938
|
+
const result = await resolveAdapter$1(input.source, adapters, defaultSource).listPendingWorkItems();
|
|
2939
|
+
const safeResult = {
|
|
2940
|
+
...result,
|
|
2941
|
+
items: result.items.map(sanitizeItem)
|
|
2942
|
+
};
|
|
2943
|
+
return {
|
|
2944
|
+
content: [{
|
|
2945
|
+
type: "text",
|
|
2946
|
+
text: formatResult(safeResult)
|
|
2947
|
+
}],
|
|
2948
|
+
structuredContent: safeResult
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
//#endregion
|
|
2545
2952
|
//#region ../../src/tools/list-sources.ts
|
|
2546
2953
|
async function handleListSources(adapters, config) {
|
|
2547
2954
|
const lines = ["# Configured Sources", ""];
|
|
@@ -2565,6 +2972,274 @@ async function handleListSources(adapters, config) {
|
|
|
2565
2972
|
}] };
|
|
2566
2973
|
}
|
|
2567
2974
|
//#endregion
|
|
2975
|
+
//#region ../../src/tools/requirement-decomposition.ts
|
|
2976
|
+
const APPROVAL_TTL_MS = 1800 * 1e3;
|
|
2977
|
+
const MAX_CREATE_OPERATIONS = 10;
|
|
2978
|
+
function isValidDate(value) {
|
|
2979
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
2980
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
2981
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
2982
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
2983
|
+
}
|
|
2984
|
+
const DateSchema$1 = z.string().refine(isValidDate, "Expected a valid YYYY-MM-DD date");
|
|
2985
|
+
function unicodeLength(value) {
|
|
2986
|
+
return Array.from(value).length;
|
|
2987
|
+
}
|
|
2988
|
+
const ShortContentSchema = z.string().trim().min(1).refine((value) => unicodeLength(value) <= 20, "shortContent must not exceed 20 Unicode characters");
|
|
2989
|
+
const RequirementTaskProposalSchema = z.object({
|
|
2990
|
+
shortContent: ShortContentSchema.describe("Concise task content without the requirement display ID; at most 20 Unicode characters."),
|
|
2991
|
+
detail: z.string().trim().min(1).describe("Concrete task detail and completion boundary."),
|
|
2992
|
+
assigneeUuid: z.string().trim().min(1).optional(),
|
|
2993
|
+
priorityUuid: z.string().trim().min(1).optional(),
|
|
2994
|
+
complexityUuid: z.string().trim().min(1).optional(),
|
|
2995
|
+
splitTypeUuid: z.string().trim().min(1).optional(),
|
|
2996
|
+
productUuid: z.string().trim().min(1).optional(),
|
|
2997
|
+
moduleUuid: z.string().trim().min(1).optional(),
|
|
2998
|
+
estimatedHours: z.number().positive().finite().optional(),
|
|
2999
|
+
planStartDate: DateSchema$1.optional(),
|
|
3000
|
+
planEndDate: DateSchema$1.optional()
|
|
3001
|
+
}).refine((value) => !value.planStartDate || !value.planEndDate || value.planStartDate <= value.planEndDate, { message: "planEndDate must be the same as or later than planStartDate" });
|
|
3002
|
+
const InspectRequirementDecompositionSchema = z.object({
|
|
3003
|
+
requirementId: z.string().trim().min(1).describe("ONES requirement UUID, number, or display ID."),
|
|
3004
|
+
source: z.string().optional().describe("Source to inspect. If omitted, uses the default source.")
|
|
3005
|
+
});
|
|
3006
|
+
const PrepareRequirementDecompositionSchema = z.object({
|
|
3007
|
+
requirementId: z.string().trim().min(1).describe("ONES requirement UUID, number, or display ID."),
|
|
3008
|
+
tasks: z.array(RequirementTaskProposalSchema).min(1).max(MAX_CREATE_OPERATIONS),
|
|
3009
|
+
source: z.string().optional().describe("Source to prepare against. If omitted, uses the default source.")
|
|
3010
|
+
});
|
|
3011
|
+
const ApplyRequirementDecompositionSchema = z.object({
|
|
3012
|
+
approvalToken: z.string().trim().min(1),
|
|
3013
|
+
planHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
3014
|
+
confirmed: z.literal(true).describe("Must be true only after the user confirms the exact prepared operations."),
|
|
3015
|
+
source: z.string().optional().describe("Source to write to. Must match the prepared plan source.")
|
|
3016
|
+
});
|
|
3017
|
+
var RequirementDecompositionApprovalStore = class {
|
|
3018
|
+
approvals = /* @__PURE__ */ new Map();
|
|
3019
|
+
now;
|
|
3020
|
+
ttlMs;
|
|
3021
|
+
constructor(options = {}) {
|
|
3022
|
+
this.now = options.now ?? Date.now;
|
|
3023
|
+
this.ttlMs = options.ttlMs ?? APPROVAL_TTL_MS;
|
|
3024
|
+
}
|
|
3025
|
+
create(record) {
|
|
3026
|
+
const now = this.now();
|
|
3027
|
+
for (const [token, approval] of this.approvals) {
|
|
3028
|
+
const expired = approval.expiresAt <= now;
|
|
3029
|
+
const superseded = approval.source === record.source && approval.requirementUuid === record.requirementUuid;
|
|
3030
|
+
if (expired || superseded) this.approvals.delete(token);
|
|
3031
|
+
}
|
|
3032
|
+
const token = randomBytes(24).toString("hex");
|
|
3033
|
+
const expiresAt = now + this.ttlMs;
|
|
3034
|
+
this.approvals.set(token, {
|
|
3035
|
+
...record,
|
|
3036
|
+
expiresAt
|
|
3037
|
+
});
|
|
3038
|
+
return {
|
|
3039
|
+
token,
|
|
3040
|
+
expiresAt
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
/** Atomically remove and return an approval before any asynchronous work. */
|
|
3044
|
+
take(token) {
|
|
3045
|
+
const record = this.approvals.get(token);
|
|
3046
|
+
if (!record) return null;
|
|
3047
|
+
this.approvals.delete(token);
|
|
3048
|
+
if (record.expiresAt <= this.now()) return null;
|
|
3049
|
+
return record;
|
|
3050
|
+
}
|
|
3051
|
+
};
|
|
3052
|
+
function resolveAdapter(source, adapters, defaultSource) {
|
|
3053
|
+
const sourceType = source ?? defaultSource;
|
|
3054
|
+
if (!sourceType) throw new Error("No source specified and no default source configured");
|
|
3055
|
+
const adapter = adapters.get(sourceType);
|
|
3056
|
+
if (!adapter) throw new Error(`Source "${sourceType}" is not configured. Available: ${[...adapters.keys()].join(", ")}`);
|
|
3057
|
+
return {
|
|
3058
|
+
sourceType,
|
|
3059
|
+
adapter
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
function sanitizedContext(context) {
|
|
3063
|
+
const requirement = {
|
|
3064
|
+
...context.requirement,
|
|
3065
|
+
displayId: sanitizeExternalInline(context.requirement.displayId),
|
|
3066
|
+
name: sanitizeExternalInline(context.requirement.name),
|
|
3067
|
+
detail: sanitizeExternalText(context.requirement.detail),
|
|
3068
|
+
issueTypeName: sanitizeExternalInline(context.requirement.issueTypeName),
|
|
3069
|
+
statusName: sanitizeExternalInline(context.requirement.statusName),
|
|
3070
|
+
statusCategory: sanitizeExternalInline(context.requirement.statusCategory),
|
|
3071
|
+
projectName: context.requirement.projectName ? sanitizeExternalInline(context.requirement.projectName) : null,
|
|
3072
|
+
assigneeName: context.requirement.assigneeName ? sanitizeExternalInline(context.requirement.assigneeName) : null
|
|
3073
|
+
};
|
|
3074
|
+
const sanitizeTask = (task) => ({
|
|
3075
|
+
...task,
|
|
3076
|
+
displayId: sanitizeExternalInline(task.displayId),
|
|
3077
|
+
name: sanitizeExternalInline(task.name),
|
|
3078
|
+
detail: sanitizeExternalText(task.detail),
|
|
3079
|
+
statusName: sanitizeExternalInline(task.statusName),
|
|
3080
|
+
statusCategory: sanitizeExternalInline(task.statusCategory),
|
|
3081
|
+
assigneeName: task.assigneeName ? sanitizeExternalInline(task.assigneeName) : null
|
|
3082
|
+
});
|
|
3083
|
+
const tasks = sortRequirementTasks(context.tasks.map(sanitizeTask));
|
|
3084
|
+
const pendingUuids = new Set(context.pendingTasks.map((task) => task.uuid));
|
|
3085
|
+
return {
|
|
3086
|
+
decompositionRelation: context.decompositionRelation,
|
|
3087
|
+
requirement,
|
|
3088
|
+
tasks,
|
|
3089
|
+
pendingTasks: tasks.filter((task) => pendingUuids.has(task.uuid)),
|
|
3090
|
+
baseline: context.baseline
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
function formatInspection(context) {
|
|
3094
|
+
const lines = [
|
|
3095
|
+
`# ${context.requirement.displayId} ${context.requirement.name}`,
|
|
3096
|
+
"",
|
|
3097
|
+
`- **Type**: ${context.requirement.issueTypeName}`,
|
|
3098
|
+
`- **Status**: ${context.requirement.statusName} (${context.requirement.statusCategory})`,
|
|
3099
|
+
`- **Decomposition relation verified**: ${context.decompositionRelation.verified ? "yes" : "no"}`,
|
|
3100
|
+
`- **Related task candidates**: ${context.tasks.length}`,
|
|
3101
|
+
`- **Pending related task candidates**: ${context.pendingTasks.length}`,
|
|
3102
|
+
"- **Implementation order**: use pending tasks only; they are sorted by planned start, planned end, then Display ID, with unset dates last.",
|
|
3103
|
+
"- **Change safety**: compare requirement detail with every task name/detail before coding; warn on meaningful divergence and block affected work on a major mismatch.",
|
|
3104
|
+
"",
|
|
3105
|
+
"## Untrusted ONES Requirement Detail",
|
|
3106
|
+
"",
|
|
3107
|
+
UNTRUSTED_SOURCE_NOTICE,
|
|
3108
|
+
"",
|
|
3109
|
+
context.requirement.detail || "(No requirement detail)",
|
|
3110
|
+
"",
|
|
3111
|
+
context.decompositionRelation.verified ? "## Existing Requirement Decomposition" : "## Related Task Candidates (relationship unverified)",
|
|
3112
|
+
""
|
|
3113
|
+
];
|
|
3114
|
+
if (context.tasks.length === 0) lines.push(context.decompositionRelation.verified ? "No existing requirement decomposition tasks." : "No related task candidates were returned; the decomposition relationship is still unverified.");
|
|
3115
|
+
else for (const task of context.tasks) {
|
|
3116
|
+
lines.push(`### ${task.displayId} ${task.name}`);
|
|
3117
|
+
lines.push(`- Status: ${task.statusName} (${task.statusCategory})`);
|
|
3118
|
+
lines.push(`- Plan: ${task.planStartDate ?? "unset"} → ${task.planEndDate ?? "unset"}`);
|
|
3119
|
+
lines.push(`- Assignee: ${task.assigneeName ?? "Unassigned"}`);
|
|
3120
|
+
lines.push("");
|
|
3121
|
+
lines.push(task.detail || "(No task detail)");
|
|
3122
|
+
lines.push("");
|
|
3123
|
+
}
|
|
3124
|
+
return lines.join("\n");
|
|
3125
|
+
}
|
|
3126
|
+
function normalizedShortContent(value) {
|
|
3127
|
+
return value.trim().replace(/\s+/g, " ");
|
|
3128
|
+
}
|
|
3129
|
+
function buildOperations(displayId, tasks) {
|
|
3130
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3131
|
+
return tasks.map((task) => {
|
|
3132
|
+
const shortContent = normalizedShortContent(task.shortContent);
|
|
3133
|
+
if (new RegExp(`^${displayId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(shortContent)) throw new Error("shortContent must not repeat the requirement display ID");
|
|
3134
|
+
const identity = shortContent.toLocaleLowerCase();
|
|
3135
|
+
if (seen.has(identity)) throw new Error(`Duplicate decomposition task shortContent: "${shortContent}"`);
|
|
3136
|
+
seen.add(identity);
|
|
3137
|
+
return {
|
|
3138
|
+
operation: "create",
|
|
3139
|
+
title: `${displayId} ${shortContent}`,
|
|
3140
|
+
shortContent,
|
|
3141
|
+
detail: task.detail,
|
|
3142
|
+
...task.assigneeUuid ? { assigneeUuid: task.assigneeUuid } : {},
|
|
3143
|
+
...task.priorityUuid ? { priorityUuid: task.priorityUuid } : {},
|
|
3144
|
+
...task.complexityUuid ? { complexityUuid: task.complexityUuid } : {},
|
|
3145
|
+
...task.splitTypeUuid ? { splitTypeUuid: task.splitTypeUuid } : {},
|
|
3146
|
+
...task.productUuid ? { productUuid: task.productUuid } : {},
|
|
3147
|
+
...task.moduleUuid ? { moduleUuid: task.moduleUuid } : {},
|
|
3148
|
+
...task.estimatedHours !== void 0 ? { estimatedHours: task.estimatedHours } : {},
|
|
3149
|
+
...task.planStartDate ? { planStartDate: task.planStartDate } : {},
|
|
3150
|
+
...task.planEndDate ? { planEndDate: task.planEndDate } : {}
|
|
3151
|
+
};
|
|
3152
|
+
});
|
|
3153
|
+
}
|
|
3154
|
+
async function handleInspectRequirementDecomposition(input, adapters, defaultSource) {
|
|
3155
|
+
const { adapter } = resolveAdapter(input.source, adapters, defaultSource);
|
|
3156
|
+
const context = sanitizedContext(await adapter.getRequirementDecompositionContext({ requirementId: input.requirementId }));
|
|
3157
|
+
return {
|
|
3158
|
+
content: [{
|
|
3159
|
+
type: "text",
|
|
3160
|
+
text: formatInspection(context)
|
|
3161
|
+
}],
|
|
3162
|
+
structuredContent: context
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
async function handlePrepareRequirementDecomposition(input, adapters, approvals, defaultSource) {
|
|
3166
|
+
const { sourceType, adapter } = resolveAdapter(input.source, adapters, defaultSource);
|
|
3167
|
+
const context = await adapter.getRequirementDecompositionContext({ requirementId: input.requirementId });
|
|
3168
|
+
if (context.requirement.workItemKind !== "requirement") throw new Error("Only requirements can be decomposed");
|
|
3169
|
+
if (!context.decompositionRelation.verified || !context.decompositionRelation.uuid) throw new Error("The \"requirement decomposition task\" relationship could not be verified from the read response. No plan or write was prepared.");
|
|
3170
|
+
if (context.requirement.statusCategory !== "to_do" && context.requirement.statusCategory !== "in_progress") throw new Error(`Requirement ${context.requirement.displayId} is not pending (${context.requirement.statusName})`);
|
|
3171
|
+
if (context.tasks.length > 0) throw new Error(`Requirement ${context.requirement.displayId} already has ${context.tasks.length} decomposition task(s). Inspect them; additions or edits require a separate explicit workflow.`);
|
|
3172
|
+
const operations = buildOperations(context.requirement.displayId, input.tasks);
|
|
3173
|
+
const planHash = buildRequirementDecompositionPlanHash({
|
|
3174
|
+
requirementUuid: context.requirement.uuid,
|
|
3175
|
+
decompositionRelation: context.decompositionRelation,
|
|
3176
|
+
baseline: context.baseline,
|
|
3177
|
+
operations
|
|
3178
|
+
});
|
|
3179
|
+
const approval = approvals.create({
|
|
3180
|
+
source: sourceType,
|
|
3181
|
+
requirementId: input.requirementId,
|
|
3182
|
+
requirementUuid: context.requirement.uuid,
|
|
3183
|
+
decompositionRelation: context.decompositionRelation,
|
|
3184
|
+
baseline: context.baseline,
|
|
3185
|
+
operations,
|
|
3186
|
+
planHash
|
|
3187
|
+
});
|
|
3188
|
+
const plan = {
|
|
3189
|
+
requirement: sanitizedContext(context).requirement,
|
|
3190
|
+
decompositionRelation: context.decompositionRelation,
|
|
3191
|
+
operations,
|
|
3192
|
+
baseline: context.baseline,
|
|
3193
|
+
planHash,
|
|
3194
|
+
approvalToken: approval.token,
|
|
3195
|
+
expiresAt: new Date(approval.expiresAt).toISOString()
|
|
3196
|
+
};
|
|
3197
|
+
return {
|
|
3198
|
+
content: [{
|
|
3199
|
+
type: "text",
|
|
3200
|
+
text: [
|
|
3201
|
+
`Prepared ${operations.length} create operation(s) for ${plan.requirement.displayId}.`,
|
|
3202
|
+
"No ONES create or edit request was sent.",
|
|
3203
|
+
"Show the exact operations to the user. Call apply_requirement_decomposition only after explicit confirmation."
|
|
3204
|
+
].join("\n")
|
|
3205
|
+
}],
|
|
3206
|
+
structuredContent: plan
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
async function handleApplyRequirementDecomposition(input, adapters, approvals, options) {
|
|
3210
|
+
if (input.confirmed !== true) throw new Error("Explicit confirmation is required before applying a decomposition");
|
|
3211
|
+
if (!options.writesEnabled) throw new Error("Requirement decomposition writes are disabled. Enable both ONES_ENABLE_WRITES=true and the source requirementDecompositionWrites option only in an approved production deployment.");
|
|
3212
|
+
const record = approvals.take(input.approvalToken);
|
|
3213
|
+
if (!record) throw new Error("Approval token is invalid, expired, or already used. Prepare the decomposition again.");
|
|
3214
|
+
if ((input.source ?? options.defaultSource) !== record.source) throw new Error("Approval token source does not match the requested source");
|
|
3215
|
+
if (input.planHash !== record.planHash) throw new Error("Plan hash does not match the approved decomposition");
|
|
3216
|
+
const { adapter } = resolveAdapter(record.source, adapters, options.defaultSource);
|
|
3217
|
+
const current = await adapter.getRequirementDecompositionContext({ requirementId: record.requirementId });
|
|
3218
|
+
if (current.requirement.uuid !== record.requirementUuid || !isSameRequirementBaseline(current.baseline, record.baseline)) throw new Error("Requirement or related tasks changed after preparation. Prepare and confirm a new decomposition.");
|
|
3219
|
+
if (!current.decompositionRelation.verified || current.decompositionRelation.uuid !== record.decompositionRelation.uuid) throw new Error("The requirement decomposition relationship changed or is no longer verified. Prepare and confirm again.");
|
|
3220
|
+
if (current.tasks.length > 0) throw new Error("Requirement now has decomposition tasks. No create request was sent.");
|
|
3221
|
+
if (buildRequirementDecompositionPlanHash({
|
|
3222
|
+
requirementUuid: record.requirementUuid,
|
|
3223
|
+
decompositionRelation: record.decompositionRelation,
|
|
3224
|
+
baseline: record.baseline,
|
|
3225
|
+
operations: record.operations
|
|
3226
|
+
}) !== record.planHash) throw new Error("Stored decomposition plan failed integrity validation");
|
|
3227
|
+
const result = await adapter.createRequirementDecomposition({
|
|
3228
|
+
requirementUuid: record.requirementUuid,
|
|
3229
|
+
decompositionRelation: record.decompositionRelation,
|
|
3230
|
+
baseline: record.baseline,
|
|
3231
|
+
planHash: record.planHash,
|
|
3232
|
+
operations: record.operations
|
|
3233
|
+
});
|
|
3234
|
+
return {
|
|
3235
|
+
content: [{
|
|
3236
|
+
type: "text",
|
|
3237
|
+
text: `Created ${result.createdTasks.length} requirement decomposition task(s).`
|
|
3238
|
+
}],
|
|
3239
|
+
structuredContent: result
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
//#endregion
|
|
2568
3243
|
//#region ../../src/tools/search-requirements.ts
|
|
2569
3244
|
const SearchRequirementsSchema = z.object({
|
|
2570
3245
|
query: z.string().describe("Search keywords"),
|
|
@@ -2661,6 +3336,11 @@ function createRequirementsServer(config, adapterOverrides) {
|
|
|
2661
3336
|
adapters.set(source.type, adapter);
|
|
2662
3337
|
}
|
|
2663
3338
|
const defaultSource = config.config.defaultSource;
|
|
3339
|
+
const decompositionApprovals = new RequirementDecompositionApprovalStore();
|
|
3340
|
+
const decompositionWritesEnabled = (sourceType) => {
|
|
3341
|
+
if (process.env.ONES_ENABLE_WRITES !== "true" || !sourceType) return false;
|
|
3342
|
+
return config.sources.find((candidate) => candidate.type === sourceType)?.config.options?.requirementDecompositionWrites === true;
|
|
3343
|
+
};
|
|
2664
3344
|
const server = new McpServer({
|
|
2665
3345
|
name: "ai-dev-requirements",
|
|
2666
3346
|
version
|
|
@@ -2709,6 +3389,21 @@ function createRequirementsServer(config, adapterOverrides) {
|
|
|
2709
3389
|
return toolError(err);
|
|
2710
3390
|
}
|
|
2711
3391
|
});
|
|
3392
|
+
server.registerTool("list_pending_work_items", {
|
|
3393
|
+
title: "List Pending Work Items",
|
|
3394
|
+
description: "List the current assignee's not-started and in-progress ONES requirements and tasks with actual, remaining, and estimated hours plus planned dates. Defects are excluded. Read-only.",
|
|
3395
|
+
inputSchema: ListPendingWorkItemsSchema,
|
|
3396
|
+
annotations: {
|
|
3397
|
+
readOnlyHint: true,
|
|
3398
|
+
openWorldHint: true
|
|
3399
|
+
}
|
|
3400
|
+
}, async (params) => {
|
|
3401
|
+
try {
|
|
3402
|
+
return await handleListPendingWorkItems(params, adapters, defaultSource);
|
|
3403
|
+
} catch (err) {
|
|
3404
|
+
return toolError(err);
|
|
3405
|
+
}
|
|
3406
|
+
});
|
|
2712
3407
|
server.registerTool("get_related_issues", {
|
|
2713
3408
|
title: "Get Related Issues",
|
|
2714
3409
|
description: "Get pending defects related to a requirement or task. Rejects a defect ID; use get_issue_detail instead.",
|
|
@@ -2770,6 +3465,57 @@ function createRequirementsServer(config, adapterOverrides) {
|
|
|
2770
3465
|
return toolError(err);
|
|
2771
3466
|
}
|
|
2772
3467
|
});
|
|
3468
|
+
server.registerTool("inspect_requirement_decomposition", {
|
|
3469
|
+
title: "Inspect Requirement Decomposition",
|
|
3470
|
+
description: "Read a requirement and related task candidates with task detail, status, sorted plan dates, and explicit decomposition-relation verification. When the relation is unverified, candidates are not claimed to be decomposition tasks. Rejects tasks and defects. Never creates or edits ONES data.",
|
|
3471
|
+
inputSchema: InspectRequirementDecompositionSchema,
|
|
3472
|
+
annotations: {
|
|
3473
|
+
readOnlyHint: true,
|
|
3474
|
+
openWorldHint: true
|
|
3475
|
+
}
|
|
3476
|
+
}, async (params) => {
|
|
3477
|
+
try {
|
|
3478
|
+
return await handleInspectRequirementDecomposition(params, adapters, defaultSource);
|
|
3479
|
+
} catch (err) {
|
|
3480
|
+
return toolError(err);
|
|
3481
|
+
}
|
|
3482
|
+
});
|
|
3483
|
+
server.registerTool("prepare_requirement_decomposition", {
|
|
3484
|
+
title: "Prepare Requirement Decomposition",
|
|
3485
|
+
description: "Validate a structured decomposition for a pending requirement with no existing decomposition tasks, then return the exact create operations and a one-time approval token. Does not write to ONES.",
|
|
3486
|
+
inputSchema: PrepareRequirementDecompositionSchema,
|
|
3487
|
+
annotations: {
|
|
3488
|
+
readOnlyHint: true,
|
|
3489
|
+
openWorldHint: true
|
|
3490
|
+
}
|
|
3491
|
+
}, async (params) => {
|
|
3492
|
+
try {
|
|
3493
|
+
return await handlePrepareRequirementDecomposition(params, adapters, decompositionApprovals, defaultSource);
|
|
3494
|
+
} catch (err) {
|
|
3495
|
+
return toolError(err);
|
|
3496
|
+
}
|
|
3497
|
+
});
|
|
3498
|
+
server.registerTool("apply_requirement_decomposition", {
|
|
3499
|
+
title: "Apply Requirement Decomposition",
|
|
3500
|
+
description: "Create the exact previously prepared requirement tasks only after explicit user confirmation. Rechecks requirement/task hashes and uses a one-time token. Disabled by default.",
|
|
3501
|
+
inputSchema: ApplyRequirementDecompositionSchema,
|
|
3502
|
+
annotations: {
|
|
3503
|
+
readOnlyHint: false,
|
|
3504
|
+
destructiveHint: false,
|
|
3505
|
+
idempotentHint: false,
|
|
3506
|
+
openWorldHint: true
|
|
3507
|
+
}
|
|
3508
|
+
}, async (params) => {
|
|
3509
|
+
try {
|
|
3510
|
+
const sourceType = params.source ?? defaultSource;
|
|
3511
|
+
return await handleApplyRequirementDecomposition(params, adapters, decompositionApprovals, {
|
|
3512
|
+
defaultSource,
|
|
3513
|
+
writesEnabled: decompositionWritesEnabled(sourceType)
|
|
3514
|
+
});
|
|
3515
|
+
} catch (err) {
|
|
3516
|
+
return toolError(err);
|
|
3517
|
+
}
|
|
3518
|
+
});
|
|
2773
3519
|
server.registerTool("add_manhour", {
|
|
2774
3520
|
title: "Add Manhour",
|
|
2775
3521
|
description: "Add a work-hour record to a ONES task, bug, or requirement. Supports task key, uuid, number, or displayId.",
|