ai-dev-requirements 0.2.1 → 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.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.2.1";
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,120 @@ 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
+ }
673
825
  function extractHtmlImageReferences(html) {
674
826
  return Array.from(html.matchAll(/<img\b[^>]*>/gi), (match) => {
675
827
  const tag = match[0];
@@ -1678,13 +1830,13 @@ var OnesAdapter = class extends BaseAdapter {
1678
1830
  filterGroup: [filter],
1679
1831
  search: null,
1680
1832
  pagination: {
1681
- limit: pageSize * page,
1833
+ limit: intent === "all_tasks" ? 1e3 : pageSize * page,
1682
1834
  preciseCount: false
1683
1835
  },
1684
1836
  limit: 1e3
1685
1837
  }, "group-task-data")).data?.buckets?.flatMap((b) => b.tasks ?? []) ?? [];
1686
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));
1687
- 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");
1688
1840
  if (assigneeUuid) tasks = tasks.filter((task) => task.assign?.uuid === assigneeUuid);
1689
1841
  if (intent === "keyword" && params.query) {
1690
1842
  const keyword = params.query.trim();
@@ -1702,6 +1854,142 @@ var OnesAdapter = class extends BaseAdapter {
1702
1854
  pageSize
1703
1855
  };
1704
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
+ }
1705
1993
  async addManhour(params) {
1706
1994
  const description = params.description.trim();
1707
1995
  if (!description) throw new Error("ONES: description is required");
@@ -2601,6 +2889,66 @@ function formatWorkItem(req) {
2601
2889
  return lines.join("\n");
2602
2890
  }
2603
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
2604
2952
  //#region ../../src/tools/list-sources.ts
2605
2953
  async function handleListSources(adapters, config) {
2606
2954
  const lines = ["# Configured Sources", ""];
@@ -2624,6 +2972,274 @@ async function handleListSources(adapters, config) {
2624
2972
  }] };
2625
2973
  }
2626
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
2627
3243
  //#region ../../src/tools/search-requirements.ts
2628
3244
  const SearchRequirementsSchema = z.object({
2629
3245
  query: z.string().describe("Search keywords"),
@@ -2720,6 +3336,11 @@ function createRequirementsServer(config, adapterOverrides) {
2720
3336
  adapters.set(source.type, adapter);
2721
3337
  }
2722
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
+ };
2723
3344
  const server = new McpServer({
2724
3345
  name: "ai-dev-requirements",
2725
3346
  version
@@ -2768,6 +3389,21 @@ function createRequirementsServer(config, adapterOverrides) {
2768
3389
  return toolError(err);
2769
3390
  }
2770
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
+ });
2771
3407
  server.registerTool("get_related_issues", {
2772
3408
  title: "Get Related Issues",
2773
3409
  description: "Get pending defects related to a requirement or task. Rejects a defect ID; use get_issue_detail instead.",
@@ -2829,6 +3465,57 @@ function createRequirementsServer(config, adapterOverrides) {
2829
3465
  return toolError(err);
2830
3466
  }
2831
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
+ });
2832
3519
  server.registerTool("add_manhour", {
2833
3520
  title: "Add Manhour",
2834
3521
  description: "Add a work-hour record to a ONES task, bug, or requirement. Supports task key, uuid, number, or displayId.",