@mcoda/core 0.1.33 → 0.1.35
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/api/AgentsApi.d.ts +4 -1
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +4 -1
- package/dist/services/docs/DocsService.d.ts +37 -0
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +537 -2
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -1
- package/dist/services/docs/review/gates/OpenQuestionsGate.js +13 -2
- package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts.map +1 -1
- package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.js +12 -1
- package/dist/services/planning/CreateTasksService.d.ts +30 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +1269 -178
- package/dist/services/planning/SdsCoverageModel.d.ts +27 -0
- package/dist/services/planning/SdsCoverageModel.d.ts.map +1 -0
- package/dist/services/planning/SdsCoverageModel.js +138 -0
- package/dist/services/planning/SdsPreflightService.d.ts +2 -0
- package/dist/services/planning/SdsPreflightService.d.ts.map +1 -1
- package/dist/services/planning/SdsPreflightService.js +125 -31
- package/dist/services/planning/SdsStructureSignals.d.ts +24 -0
- package/dist/services/planning/SdsStructureSignals.d.ts.map +1 -0
- package/dist/services/planning/SdsStructureSignals.js +402 -0
- package/dist/services/planning/TaskSufficiencyService.d.ts +1 -0
- package/dist/services/planning/TaskSufficiencyService.d.ts.map +1 -1
- package/dist/services/planning/TaskSufficiencyService.js +218 -285
- package/package.json +6 -6
|
@@ -12,6 +12,8 @@ import { classifyTask } from "../backlog/TaskOrderingHeuristics.js";
|
|
|
12
12
|
import { TaskOrderingService } from "../backlog/TaskOrderingService.js";
|
|
13
13
|
import { QaTestCommandBuilder } from "../execution/QaTestCommandBuilder.js";
|
|
14
14
|
import { createEpicKeyGenerator, createStoryKeyGenerator, createTaskKeyGenerator, } from "./KeyHelpers.js";
|
|
15
|
+
import { collectSdsCoverageSignalsFromDocs, evaluateSdsCoverage, normalizeCoverageText, } from "./SdsCoverageModel.js";
|
|
16
|
+
import { collectSdsImplementationSignals, extractStructuredPaths, filterImplementationStructuredPaths, headingLooksImplementationRelevant, isStructuredFilePath, normalizeHeadingCandidate, normalizeStructuredPathToken, stripManagedSdsPreflightBlock, } from "./SdsStructureSignals.js";
|
|
15
17
|
import { TaskSufficiencyService } from "./TaskSufficiencyService.js";
|
|
16
18
|
import { SdsPreflightService } from "./SdsPreflightService.js";
|
|
17
19
|
const formatBullets = (items, fallback) => {
|
|
@@ -162,6 +164,7 @@ const DOC_CONTEXT_SEGMENTS_PER_DOC = 8;
|
|
|
162
164
|
const DOC_CONTEXT_FALLBACK_CHUNK_LENGTH = 480;
|
|
163
165
|
const SDS_COVERAGE_HINT_HEADING_LIMIT = 24;
|
|
164
166
|
const SDS_COVERAGE_REPORT_SECTION_LIMIT = 80;
|
|
167
|
+
const SDS_COVERAGE_REPORT_FOLDER_LIMIT = 240;
|
|
165
168
|
const OPENAPI_HINT_OPERATIONS_LIMIT = 30;
|
|
166
169
|
const DOCDEX_HANDLE = /^docdex:/i;
|
|
167
170
|
const DOCDEX_LOCAL_HANDLE = /^docdex:local[-:/]/i;
|
|
@@ -170,8 +173,9 @@ const RELATIVE_DOC_PATH_PATTERN = /^(?:\.{1,2}\/)+[A-Za-z0-9._/-]+(?:\.[A-Za-z0-
|
|
|
170
173
|
const FUZZY_DOC_CANDIDATE_LIMIT = 64;
|
|
171
174
|
const DEPENDENCY_SCAN_LINE_LIMIT = 1400;
|
|
172
175
|
const STARTUP_WAVE_SCAN_LINE_LIMIT = 4000;
|
|
173
|
-
const VALID_AREAS = new Set(["web", "adm", "bck", "ops", "infra", "mobile"]);
|
|
174
176
|
const VALID_TASK_TYPES = new Set(["feature", "bug", "chore", "spike"]);
|
|
177
|
+
const VALID_EPIC_SERVICE_POLICIES = new Set(["auto-remediate", "fail"]);
|
|
178
|
+
const CROSS_SERVICE_TAG = "cross_service";
|
|
175
179
|
const inferDocType = (filePath) => {
|
|
176
180
|
const name = path.basename(filePath).toLowerCase();
|
|
177
181
|
if (name.includes("openapi") || name.includes("swagger"))
|
|
@@ -187,16 +191,12 @@ const inferDocType = (filePath) => {
|
|
|
187
191
|
const normalizeArea = (value) => {
|
|
188
192
|
if (typeof value !== "string")
|
|
189
193
|
return undefined;
|
|
190
|
-
const
|
|
194
|
+
const normalized = value
|
|
191
195
|
.toLowerCase()
|
|
192
|
-
.
|
|
193
|
-
.
|
|
194
|
-
.
|
|
195
|
-
|
|
196
|
-
if (VALID_AREAS.has(token))
|
|
197
|
-
return token;
|
|
198
|
-
}
|
|
199
|
-
return undefined;
|
|
196
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
197
|
+
.replace(/^-+|-+$/g, "")
|
|
198
|
+
.replace(/-{2,}/g, "-");
|
|
199
|
+
return normalized.length > 0 ? normalized.slice(0, 24) : undefined;
|
|
200
200
|
};
|
|
201
201
|
const normalizeTaskType = (value) => {
|
|
202
202
|
if (typeof value !== "string")
|
|
@@ -212,6 +212,17 @@ const normalizeTaskType = (value) => {
|
|
|
212
212
|
}
|
|
213
213
|
return undefined;
|
|
214
214
|
};
|
|
215
|
+
const normalizeEpicServicePolicy = (value) => {
|
|
216
|
+
if (typeof value !== "string")
|
|
217
|
+
return undefined;
|
|
218
|
+
const normalized = value.trim().toLowerCase();
|
|
219
|
+
if (!VALID_EPIC_SERVICE_POLICIES.has(normalized))
|
|
220
|
+
return undefined;
|
|
221
|
+
return normalized;
|
|
222
|
+
};
|
|
223
|
+
const normalizeEpicTags = (value) => uniqueStrings(normalizeStringArray(value)
|
|
224
|
+
.map((item) => item.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, ""))
|
|
225
|
+
.filter(Boolean));
|
|
215
226
|
const normalizeRelatedDocs = (value) => {
|
|
216
227
|
if (!Array.isArray(value))
|
|
217
228
|
return [];
|
|
@@ -239,40 +250,6 @@ const normalizeRelatedDocs = (value) => {
|
|
|
239
250
|
}
|
|
240
251
|
return normalized;
|
|
241
252
|
};
|
|
242
|
-
const extractMarkdownHeadings = (value, limit) => {
|
|
243
|
-
if (!value)
|
|
244
|
-
return [];
|
|
245
|
-
const lines = value.split(/\r?\n/);
|
|
246
|
-
const headings = [];
|
|
247
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
248
|
-
const line = lines[index]?.trim() ?? "";
|
|
249
|
-
if (!line)
|
|
250
|
-
continue;
|
|
251
|
-
const hashHeading = line.match(/^#{1,6}\s+(.+)$/);
|
|
252
|
-
if (hashHeading) {
|
|
253
|
-
headings.push(hashHeading[1].trim());
|
|
254
|
-
}
|
|
255
|
-
else if (index + 1 < lines.length &&
|
|
256
|
-
/^[=-]{3,}\s*$/.test((lines[index + 1] ?? "").trim()) &&
|
|
257
|
-
!line.startsWith("-") &&
|
|
258
|
-
!line.startsWith("*")) {
|
|
259
|
-
headings.push(line);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
const numberedHeading = line.match(/^(\d+(?:\.\d+)+)\s+(.+)$/);
|
|
263
|
-
if (numberedHeading) {
|
|
264
|
-
const headingText = `${numberedHeading[1]} ${numberedHeading[2]}`.trim();
|
|
265
|
-
if (/[a-z]/i.test(headingText))
|
|
266
|
-
headings.push(headingText);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
if (headings.length >= limit)
|
|
270
|
-
break;
|
|
271
|
-
}
|
|
272
|
-
return uniqueStrings(headings
|
|
273
|
-
.map((entry) => entry.replace(/[`*_]/g, "").trim())
|
|
274
|
-
.filter(Boolean));
|
|
275
|
-
};
|
|
276
253
|
const pickDistributedIndices = (length, limit) => {
|
|
277
254
|
if (length <= 0 || limit <= 0)
|
|
278
255
|
return [];
|
|
@@ -561,8 +538,8 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
|
|
|
561
538
|
? "- Task-specific tests are added/updated and green in the task validation loop."
|
|
562
539
|
: "- Verification evidence is captured in task logs/checklists for this scope.",
|
|
563
540
|
relatedDocs?.length
|
|
564
|
-
? "- Related
|
|
565
|
-
: "- Documentation impact is reviewed and no additional
|
|
541
|
+
? "- Related interfaces/docs are consistent with delivered behavior."
|
|
542
|
+
: "- Documentation impact is reviewed and no additional interface docs are required.",
|
|
566
543
|
qa?.blockers?.length ? "- Remaining QA blockers are explicit and actionable." : "- QA blockers are resolved or not present.",
|
|
567
544
|
];
|
|
568
545
|
const defaultImplementationPlan = [
|
|
@@ -573,7 +550,7 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
|
|
|
573
550
|
];
|
|
574
551
|
const defaultRisks = dependencies.length
|
|
575
552
|
? [`Delivery depends on upstream tasks: ${dependencies.join(", ")}.`]
|
|
576
|
-
: ["Keep implementation aligned to
|
|
553
|
+
: ["Keep implementation aligned to documented interfaces and dependency expectations to avoid drift."];
|
|
577
554
|
return [
|
|
578
555
|
`* **Task Key**: ${taskKey}`,
|
|
579
556
|
"* **Objective**",
|
|
@@ -667,6 +644,51 @@ const SERVICE_PATH_CONTAINER_SEGMENTS = new Set([
|
|
|
667
644
|
"lib",
|
|
668
645
|
"src",
|
|
669
646
|
]);
|
|
647
|
+
const SOURCE_LIKE_PATH_SEGMENTS = new Set([
|
|
648
|
+
"api",
|
|
649
|
+
"app",
|
|
650
|
+
"apps",
|
|
651
|
+
"bin",
|
|
652
|
+
"cmd",
|
|
653
|
+
"components",
|
|
654
|
+
"controllers",
|
|
655
|
+
"handlers",
|
|
656
|
+
"internal",
|
|
657
|
+
"lib",
|
|
658
|
+
"libs",
|
|
659
|
+
"pages",
|
|
660
|
+
"routes",
|
|
661
|
+
"screens",
|
|
662
|
+
"server",
|
|
663
|
+
"servers",
|
|
664
|
+
"spec",
|
|
665
|
+
"specs",
|
|
666
|
+
"src",
|
|
667
|
+
"test",
|
|
668
|
+
"tests",
|
|
669
|
+
"ui",
|
|
670
|
+
"web",
|
|
671
|
+
]);
|
|
672
|
+
const GENERIC_CONTAINER_PATH_SEGMENTS = new Set([
|
|
673
|
+
"adapters",
|
|
674
|
+
"apps",
|
|
675
|
+
"clients",
|
|
676
|
+
"consoles",
|
|
677
|
+
"domains",
|
|
678
|
+
"engines",
|
|
679
|
+
"features",
|
|
680
|
+
"modules",
|
|
681
|
+
"packages",
|
|
682
|
+
"platforms",
|
|
683
|
+
"plugins",
|
|
684
|
+
"products",
|
|
685
|
+
"servers",
|
|
686
|
+
"services",
|
|
687
|
+
"systems",
|
|
688
|
+
"tools",
|
|
689
|
+
"workers",
|
|
690
|
+
]);
|
|
691
|
+
const NON_RUNTIME_STRUCTURE_ROOT_SEGMENTS = new Set(["docs", "fixtures", "runbooks", "policies", "policy"]);
|
|
670
692
|
const SERVICE_NAME_STOPWORDS = new Set([
|
|
671
693
|
"the",
|
|
672
694
|
"a",
|
|
@@ -724,10 +746,92 @@ const SERVICE_NAME_INVALID = new Set([
|
|
|
724
746
|
"repository",
|
|
725
747
|
"codebase",
|
|
726
748
|
]);
|
|
749
|
+
const SERVICE_TEXT_INVALID_STARTERS = new Set([
|
|
750
|
+
"active",
|
|
751
|
+
"are",
|
|
752
|
+
"artifact",
|
|
753
|
+
"artifacts",
|
|
754
|
+
"be",
|
|
755
|
+
"been",
|
|
756
|
+
"being",
|
|
757
|
+
"block",
|
|
758
|
+
"blocks",
|
|
759
|
+
"build",
|
|
760
|
+
"builder",
|
|
761
|
+
"built",
|
|
762
|
+
"canonical",
|
|
763
|
+
"chain",
|
|
764
|
+
"configured",
|
|
765
|
+
"dedicated",
|
|
766
|
+
"deployment",
|
|
767
|
+
"discovered",
|
|
768
|
+
"failure",
|
|
769
|
+
"first",
|
|
770
|
+
"is",
|
|
771
|
+
"last",
|
|
772
|
+
"listing",
|
|
773
|
+
"mode",
|
|
774
|
+
"modes",
|
|
775
|
+
"never",
|
|
776
|
+
"no",
|
|
777
|
+
"not",
|
|
778
|
+
"ordered",
|
|
779
|
+
"owned",
|
|
780
|
+
"private",
|
|
781
|
+
"public",
|
|
782
|
+
"resolved",
|
|
783
|
+
"runtime",
|
|
784
|
+
"second",
|
|
785
|
+
"startup",
|
|
786
|
+
"third",
|
|
787
|
+
"validation",
|
|
788
|
+
"wave",
|
|
789
|
+
"waves",
|
|
790
|
+
"was",
|
|
791
|
+
"were",
|
|
792
|
+
]);
|
|
793
|
+
const NON_RUNTIME_SERVICE_SINGLETONS = new Set([
|
|
794
|
+
"artifact",
|
|
795
|
+
"artifacts",
|
|
796
|
+
"compose",
|
|
797
|
+
"config",
|
|
798
|
+
"configs",
|
|
799
|
+
"doc",
|
|
800
|
+
"docs",
|
|
801
|
+
"interface",
|
|
802
|
+
"interfaces",
|
|
803
|
+
"key",
|
|
804
|
+
"keys",
|
|
805
|
+
"libraries",
|
|
806
|
+
"library",
|
|
807
|
+
"pdr",
|
|
808
|
+
"read",
|
|
809
|
+
"rfp",
|
|
810
|
+
"sds",
|
|
811
|
+
"script",
|
|
812
|
+
"scripts",
|
|
813
|
+
"src",
|
|
814
|
+
"systemd",
|
|
815
|
+
"test",
|
|
816
|
+
"tests",
|
|
817
|
+
"types",
|
|
818
|
+
"write",
|
|
819
|
+
]);
|
|
820
|
+
const NON_RUNTIME_PATH_SERVICE_TOKENS = new Set([
|
|
821
|
+
"artifact",
|
|
822
|
+
"artifacts",
|
|
823
|
+
"manifest",
|
|
824
|
+
"manifests",
|
|
825
|
+
"schema",
|
|
826
|
+
"schemas",
|
|
827
|
+
"taxonomy",
|
|
828
|
+
"taxonomies",
|
|
829
|
+
]);
|
|
727
830
|
const SERVICE_LABEL_PATTERN = /\b([A-Za-z][A-Za-z0-9]*(?:[ _/-]+[A-Za-z][A-Za-z0-9]*){0,3})\s+(service|api|backend|frontend|worker|gateway|database|db|ui|client|server|adapter)\b/gi;
|
|
728
831
|
const SERVICE_ARROW_PATTERN = /([A-Za-z][A-Za-z0-9 _/-]{1,80})\s*(?:->|=>|→)\s*([A-Za-z][A-Za-z0-9 _/-]{1,80})/g;
|
|
729
832
|
const SERVICE_HANDLE_PATTERN = /\b((?:svc|ui|worker)-[a-z0-9-*]+)\b/gi;
|
|
730
833
|
const WAVE_LABEL_PATTERN = /\bwave\s*([0-9]{1,2})\b/i;
|
|
834
|
+
const TOPOLOGY_HEADING_PATTERN = /\b(service|services|component|components|module|modules|interface|interfaces|runtime|runtimes|worker|workers|client|clients|gateway|gateways|server|servers|engine|engines|pipeline|pipelines|registry|registries|adapter|adapters|processor|processors|daemon|daemons|ops|operations|deployment|deployments|topology)\b/i;
|
|
731
835
|
const nextUniqueLocalId = (prefix, existing) => {
|
|
732
836
|
let index = 1;
|
|
733
837
|
let candidate = `${prefix}-${index}`;
|
|
@@ -750,16 +854,28 @@ const looksLikeSdsDoc = (doc) => {
|
|
|
750
854
|
.slice(0, 5000);
|
|
751
855
|
return STRICT_SDS_CONTENT_PATTERN.test(sample);
|
|
752
856
|
};
|
|
857
|
+
const looksLikePathishDocId = (value) => {
|
|
858
|
+
if (!value)
|
|
859
|
+
return false;
|
|
860
|
+
if (DOCDEX_LOCAL_HANDLE.test(value))
|
|
861
|
+
return false;
|
|
862
|
+
return (value.includes("/") ||
|
|
863
|
+
value.includes("\\") ||
|
|
864
|
+
FILE_EXTENSION_PATTERN.test(value) ||
|
|
865
|
+
STRICT_SDS_PATH_PATTERN.test(value.replace(/\\/g, "/").toLowerCase()));
|
|
866
|
+
};
|
|
753
867
|
const EPIC_SCHEMA_SNIPPET = `{
|
|
754
868
|
"epics": [
|
|
755
869
|
{
|
|
756
870
|
"localId": "e1",
|
|
757
|
-
"area": "
|
|
871
|
+
"area": "documented-area-label",
|
|
758
872
|
"title": "Epic title",
|
|
759
873
|
"description": "Epic description using the epic template",
|
|
760
874
|
"acceptanceCriteria": ["criterion"],
|
|
761
875
|
"relatedDocs": ["docdex:..."],
|
|
762
|
-
"priorityHint": 50
|
|
876
|
+
"priorityHint": 50,
|
|
877
|
+
"serviceIds": ["backend-api"],
|
|
878
|
+
"tags": ["cross_service"]
|
|
763
879
|
}
|
|
764
880
|
]
|
|
765
881
|
}`;
|
|
@@ -916,7 +1032,7 @@ export class CreateTasksService {
|
|
|
916
1032
|
if (!documents.some((doc) => looksLikeSdsDoc(doc))) {
|
|
917
1033
|
throw new Error("create-tasks requires at least one SDS document. Add an SDS file (for example docs/sds.md) or pass SDS paths as input.");
|
|
918
1034
|
}
|
|
919
|
-
return this.sortDocsForPlanning(documents);
|
|
1035
|
+
return this.sortDocsForPlanning(this.dedupePlanningDocs(documents.map((doc) => this.sanitizeDocForPlanning(doc))));
|
|
920
1036
|
}
|
|
921
1037
|
normalizeDocInputForSet(input) {
|
|
922
1038
|
if (input.startsWith("docdex:"))
|
|
@@ -938,8 +1054,19 @@ export class CreateTasksService {
|
|
|
938
1054
|
}
|
|
939
1055
|
return merged;
|
|
940
1056
|
}
|
|
1057
|
+
canonicalizeDocPathKey(value) {
|
|
1058
|
+
const trimmed = `${value ?? ""}`.trim();
|
|
1059
|
+
if (!trimmed || DOCDEX_LOCAL_HANDLE.test(trimmed))
|
|
1060
|
+
return undefined;
|
|
1061
|
+
if (path.isAbsolute(trimmed))
|
|
1062
|
+
return path.resolve(trimmed).toLowerCase();
|
|
1063
|
+
if (looksLikePathishDocId(trimmed)) {
|
|
1064
|
+
return path.resolve(this.workspace.workspaceRoot, trimmed).toLowerCase();
|
|
1065
|
+
}
|
|
1066
|
+
return undefined;
|
|
1067
|
+
}
|
|
941
1068
|
docIdentity(doc) {
|
|
942
|
-
const pathKey =
|
|
1069
|
+
const pathKey = this.canonicalizeDocPathKey(doc.path) ?? this.canonicalizeDocPathKey(doc.id);
|
|
943
1070
|
const idKey = `${doc.id ?? ""}`.trim().toLowerCase();
|
|
944
1071
|
if (pathKey)
|
|
945
1072
|
return `path:${pathKey}`;
|
|
@@ -963,6 +1090,61 @@ export class CreateTasksService {
|
|
|
963
1090
|
}
|
|
964
1091
|
return merged;
|
|
965
1092
|
}
|
|
1093
|
+
sanitizeDocForPlanning(doc) {
|
|
1094
|
+
const content = stripManagedSdsPreflightBlock(doc.content);
|
|
1095
|
+
const segments = content !== doc.content
|
|
1096
|
+
? []
|
|
1097
|
+
: (doc.segments ?? [])
|
|
1098
|
+
.map((segment) => {
|
|
1099
|
+
const sanitizedContent = stripManagedSdsPreflightBlock(segment.content ?? undefined);
|
|
1100
|
+
return {
|
|
1101
|
+
...segment,
|
|
1102
|
+
content: sanitizedContent ?? segment.content,
|
|
1103
|
+
};
|
|
1104
|
+
})
|
|
1105
|
+
.filter((segment) => `${segment.content ?? ""}`.trim().length > 0 || `${segment.heading ?? ""}`.trim().length > 0);
|
|
1106
|
+
const sanitized = {
|
|
1107
|
+
...doc,
|
|
1108
|
+
content: content ?? doc.content,
|
|
1109
|
+
segments,
|
|
1110
|
+
};
|
|
1111
|
+
if (looksLikeSdsDoc(sanitized) && `${sanitized.docType ?? ""}`.toUpperCase() !== "SDS") {
|
|
1112
|
+
sanitized.docType = "SDS";
|
|
1113
|
+
}
|
|
1114
|
+
return sanitized;
|
|
1115
|
+
}
|
|
1116
|
+
scorePlanningDoc(doc) {
|
|
1117
|
+
const segmentCount = doc.segments?.length ?? 0;
|
|
1118
|
+
const contentLength = `${doc.content ?? ""}`.length;
|
|
1119
|
+
return ((looksLikeSdsDoc(doc) ? 5000 : 0) +
|
|
1120
|
+
(doc.path ? 400 : 0) +
|
|
1121
|
+
segmentCount * 20 +
|
|
1122
|
+
Math.min(300, contentLength));
|
|
1123
|
+
}
|
|
1124
|
+
mergePlanningDocPair(current, incoming) {
|
|
1125
|
+
const [primary, secondary] = this.scorePlanningDoc(incoming) > this.scorePlanningDoc(current) ? [incoming, current] : [current, incoming];
|
|
1126
|
+
const merged = {
|
|
1127
|
+
...secondary,
|
|
1128
|
+
...primary,
|
|
1129
|
+
path: primary.path ?? secondary.path,
|
|
1130
|
+
title: primary.title ?? secondary.title,
|
|
1131
|
+
content: primary.content ?? secondary.content,
|
|
1132
|
+
segments: (primary.segments?.length ?? 0) > 0 ? primary.segments : secondary.segments,
|
|
1133
|
+
};
|
|
1134
|
+
if (looksLikeSdsDoc(merged) && `${merged.docType ?? ""}`.toUpperCase() !== "SDS") {
|
|
1135
|
+
merged.docType = "SDS";
|
|
1136
|
+
}
|
|
1137
|
+
return merged;
|
|
1138
|
+
}
|
|
1139
|
+
dedupePlanningDocs(docs) {
|
|
1140
|
+
const merged = new Map();
|
|
1141
|
+
for (const doc of docs) {
|
|
1142
|
+
const identity = this.docIdentity(doc);
|
|
1143
|
+
const existing = merged.get(identity);
|
|
1144
|
+
merged.set(identity, existing ? this.mergePlanningDocPair(existing, doc) : doc);
|
|
1145
|
+
}
|
|
1146
|
+
return Array.from(merged.values());
|
|
1147
|
+
}
|
|
966
1148
|
sortDocsForPlanning(docs) {
|
|
967
1149
|
return [...docs].sort((a, b) => {
|
|
968
1150
|
const aIsSds = looksLikeSdsDoc(a) ? 0 : 1;
|
|
@@ -1143,47 +1325,29 @@ export class CreateTasksService {
|
|
|
1143
1325
|
.map((entry) => entry.path);
|
|
1144
1326
|
}
|
|
1145
1327
|
normalizeStructurePathToken(value) {
|
|
1146
|
-
const normalized = value
|
|
1147
|
-
.replace(/\\/g, "/")
|
|
1148
|
-
.replace(/^[./]+/, "")
|
|
1149
|
-
.replace(/^\/+/, "")
|
|
1150
|
-
.trim();
|
|
1328
|
+
const normalized = normalizeStructuredPathToken(value);
|
|
1151
1329
|
if (!normalized)
|
|
1152
1330
|
return undefined;
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (!normalized.includes("/"))
|
|
1156
|
-
return undefined;
|
|
1157
|
-
if (normalized.includes("://"))
|
|
1158
|
-
return undefined;
|
|
1159
|
-
if (/[\u0000-\u001f]/.test(normalized))
|
|
1331
|
+
const root = normalized.split("/")[0]?.toLowerCase();
|
|
1332
|
+
if (root && DOC_SCAN_IGNORE_DIRS.has(root))
|
|
1160
1333
|
return undefined;
|
|
1161
|
-
|
|
1162
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
1163
|
-
if (parts.length < 2 && !(hadTrailingSlash && parts.length === 1))
|
|
1164
|
-
return undefined;
|
|
1165
|
-
if (parts.some((part) => part === "." || part === ".."))
|
|
1166
|
-
return undefined;
|
|
1167
|
-
if (parts.length === 1 && !TOP_LEVEL_STRUCTURE_PATTERN.test(parts[0]))
|
|
1168
|
-
return undefined;
|
|
1169
|
-
if (DOC_SCAN_IGNORE_DIRS.has(parts[0].toLowerCase()))
|
|
1170
|
-
return undefined;
|
|
1171
|
-
return parts.join("/");
|
|
1334
|
+
return normalized;
|
|
1172
1335
|
}
|
|
1173
1336
|
extractStructureTargets(docs) {
|
|
1174
1337
|
const directories = new Set();
|
|
1175
1338
|
const files = new Set();
|
|
1176
1339
|
for (const doc of docs) {
|
|
1340
|
+
const relativeDocPath = doc.path ? path.relative(this.workspace.workspaceRoot, doc.path).replace(/\\/g, "/") : undefined;
|
|
1341
|
+
const localDocPath = relativeDocPath && !relativeDocPath.startsWith("..") && !path.isAbsolute(relativeDocPath)
|
|
1342
|
+
? relativeDocPath
|
|
1343
|
+
: undefined;
|
|
1177
1344
|
const segments = (doc.segments ?? []).map((segment) => segment.content).filter(Boolean).join("\n");
|
|
1178
|
-
const corpus = [
|
|
1179
|
-
for (const
|
|
1180
|
-
const token = match[2];
|
|
1181
|
-
if (!token)
|
|
1182
|
-
continue;
|
|
1345
|
+
const corpus = [localDocPath, doc.content, segments].filter(Boolean).join("\n");
|
|
1346
|
+
for (const token of filterImplementationStructuredPaths(extractStructuredPaths(corpus, 256))) {
|
|
1183
1347
|
const normalized = this.normalizeStructurePathToken(token);
|
|
1184
1348
|
if (!normalized)
|
|
1185
1349
|
continue;
|
|
1186
|
-
if (
|
|
1350
|
+
if (isStructuredFilePath(path.basename(normalized))) {
|
|
1187
1351
|
files.add(normalized);
|
|
1188
1352
|
const parent = path.dirname(normalized).replace(/\\/g, "/");
|
|
1189
1353
|
if (parent && parent !== ".")
|
|
@@ -1223,6 +1387,61 @@ export class CreateTasksService {
|
|
|
1223
1387
|
return undefined;
|
|
1224
1388
|
return candidate.length >= 2 ? candidate : undefined;
|
|
1225
1389
|
}
|
|
1390
|
+
normalizeTextServiceName(value) {
|
|
1391
|
+
const candidate = this.normalizeServiceName(value);
|
|
1392
|
+
if (!candidate)
|
|
1393
|
+
return undefined;
|
|
1394
|
+
const tokens = candidate.split(" ").filter(Boolean);
|
|
1395
|
+
if (tokens.length === 0 || tokens.length > 3)
|
|
1396
|
+
return undefined;
|
|
1397
|
+
const first = tokens[0] ?? "";
|
|
1398
|
+
if (SERVICE_TEXT_INVALID_STARTERS.has(first))
|
|
1399
|
+
return undefined;
|
|
1400
|
+
if (tokens.length === 1) {
|
|
1401
|
+
if (first.length < 3)
|
|
1402
|
+
return undefined;
|
|
1403
|
+
if (SERVICE_NAME_INVALID.has(first) || NON_RUNTIME_SERVICE_SINGLETONS.has(first))
|
|
1404
|
+
return undefined;
|
|
1405
|
+
if (SERVICE_NAME_STOPWORDS.has(first))
|
|
1406
|
+
return undefined;
|
|
1407
|
+
}
|
|
1408
|
+
return candidate;
|
|
1409
|
+
}
|
|
1410
|
+
isLikelyServiceContainerSegment(parts, index) {
|
|
1411
|
+
const segment = parts[index];
|
|
1412
|
+
if (!segment)
|
|
1413
|
+
return false;
|
|
1414
|
+
if (SERVICE_PATH_CONTAINER_SEGMENTS.has(segment))
|
|
1415
|
+
return true;
|
|
1416
|
+
if (index !== 0)
|
|
1417
|
+
return false;
|
|
1418
|
+
const next = parts[index + 1];
|
|
1419
|
+
if (!next)
|
|
1420
|
+
return false;
|
|
1421
|
+
const following = parts[index + 2];
|
|
1422
|
+
const nextLooksSpecific = !SERVICE_PATH_CONTAINER_SEGMENTS.has(next) &&
|
|
1423
|
+
!NON_RUNTIME_STRUCTURE_ROOT_SEGMENTS.has(next) &&
|
|
1424
|
+
!SOURCE_LIKE_PATH_SEGMENTS.has(next) &&
|
|
1425
|
+
!isStructuredFilePath(next);
|
|
1426
|
+
if (!nextLooksSpecific)
|
|
1427
|
+
return false;
|
|
1428
|
+
if (GENERIC_CONTAINER_PATH_SEGMENTS.has(segment)) {
|
|
1429
|
+
if (!following)
|
|
1430
|
+
return true;
|
|
1431
|
+
return SOURCE_LIKE_PATH_SEGMENTS.has(following) || isStructuredFilePath(following);
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
normalizePathDerivedServiceName(value) {
|
|
1436
|
+
const candidate = this.normalizeServiceName(value);
|
|
1437
|
+
if (!candidate)
|
|
1438
|
+
return undefined;
|
|
1439
|
+
if (NON_RUNTIME_SERVICE_SINGLETONS.has(candidate))
|
|
1440
|
+
return undefined;
|
|
1441
|
+
if (candidate.split(" ").some((token) => NON_RUNTIME_PATH_SERVICE_TOKENS.has(token)))
|
|
1442
|
+
return undefined;
|
|
1443
|
+
return candidate;
|
|
1444
|
+
}
|
|
1226
1445
|
deriveServiceFromPathToken(pathToken) {
|
|
1227
1446
|
const parts = pathToken
|
|
1228
1447
|
.replace(/\\/g, "/")
|
|
@@ -1231,11 +1450,18 @@ export class CreateTasksService {
|
|
|
1231
1450
|
.filter(Boolean);
|
|
1232
1451
|
if (!parts.length)
|
|
1233
1452
|
return undefined;
|
|
1453
|
+
if (NON_RUNTIME_STRUCTURE_ROOT_SEGMENTS.has(parts[0] ?? ""))
|
|
1454
|
+
return undefined;
|
|
1455
|
+
if (parts.length === 1 && isStructuredFilePath(parts[0] ?? ""))
|
|
1456
|
+
return undefined;
|
|
1234
1457
|
let idx = 0;
|
|
1235
|
-
while (idx < parts.length - 1 &&
|
|
1458
|
+
while (idx < parts.length - 1 && this.isLikelyServiceContainerSegment(parts, idx)) {
|
|
1236
1459
|
idx += 1;
|
|
1237
1460
|
}
|
|
1238
|
-
|
|
1461
|
+
const candidate = parts[idx] ?? parts[0];
|
|
1462
|
+
if (isStructuredFilePath(candidate))
|
|
1463
|
+
return undefined;
|
|
1464
|
+
return this.normalizePathDerivedServiceName(candidate);
|
|
1239
1465
|
}
|
|
1240
1466
|
addServiceAlias(aliases, rawValue) {
|
|
1241
1467
|
const canonical = this.normalizeServiceName(rawValue);
|
|
@@ -1251,6 +1477,10 @@ export class CreateTasksService {
|
|
|
1251
1477
|
.trim();
|
|
1252
1478
|
if (alias)
|
|
1253
1479
|
existing.add(alias);
|
|
1480
|
+
if (alias.endsWith("s") && alias.length > 3)
|
|
1481
|
+
existing.add(alias.slice(0, -1));
|
|
1482
|
+
if (!alias.endsWith("s") && alias.length > 2)
|
|
1483
|
+
existing.add(`${alias}s`);
|
|
1254
1484
|
aliases.set(canonical, existing);
|
|
1255
1485
|
return canonical;
|
|
1256
1486
|
}
|
|
@@ -1260,7 +1490,7 @@ export class CreateTasksService {
|
|
|
1260
1490
|
const mentions = new Set();
|
|
1261
1491
|
for (const match of text.matchAll(SERVICE_LABEL_PATTERN)) {
|
|
1262
1492
|
const phrase = `${match[1] ?? ""} ${match[2] ?? ""}`.trim();
|
|
1263
|
-
const normalized = this.
|
|
1493
|
+
const normalized = this.normalizeTextServiceName(phrase);
|
|
1264
1494
|
if (normalized)
|
|
1265
1495
|
mentions.add(normalized);
|
|
1266
1496
|
}
|
|
@@ -1274,7 +1504,18 @@ export class CreateTasksService {
|
|
|
1274
1504
|
}
|
|
1275
1505
|
return Array.from(mentions);
|
|
1276
1506
|
}
|
|
1277
|
-
|
|
1507
|
+
deriveServiceMentionFromPathPhrase(phrase) {
|
|
1508
|
+
for (const match of phrase.matchAll(DOC_PATH_TOKEN_PATTERN)) {
|
|
1509
|
+
const token = match[2];
|
|
1510
|
+
if (!token)
|
|
1511
|
+
continue;
|
|
1512
|
+
const derived = this.deriveServiceFromPathToken(token);
|
|
1513
|
+
if (derived)
|
|
1514
|
+
return derived;
|
|
1515
|
+
}
|
|
1516
|
+
return undefined;
|
|
1517
|
+
}
|
|
1518
|
+
resolveServiceMentionFromPhrase(phrase, aliases, options = {}) {
|
|
1278
1519
|
const normalizedPhrase = phrase
|
|
1279
1520
|
.toLowerCase()
|
|
1280
1521
|
.replace(/[._/-]+/g, " ")
|
|
@@ -1297,6 +1538,11 @@ export class CreateTasksService {
|
|
|
1297
1538
|
}
|
|
1298
1539
|
if (best)
|
|
1299
1540
|
return best.key;
|
|
1541
|
+
const pathDerived = this.deriveServiceMentionFromPathPhrase(phrase);
|
|
1542
|
+
if (pathDerived)
|
|
1543
|
+
return pathDerived;
|
|
1544
|
+
if (!options.allowAliasRegistration)
|
|
1545
|
+
return undefined;
|
|
1300
1546
|
const mention = this.extractServiceMentionsFromText(phrase)[0];
|
|
1301
1547
|
if (!mention)
|
|
1302
1548
|
return undefined;
|
|
@@ -1380,10 +1626,17 @@ export class CreateTasksService {
|
|
|
1380
1626
|
resolved.add(canonical);
|
|
1381
1627
|
}
|
|
1382
1628
|
if (resolved.size === 0) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1629
|
+
const normalizedCell = this.normalizeServiceLookupKey(cell);
|
|
1630
|
+
const haystack = normalizedCell ? ` ${normalizedCell} ` : "";
|
|
1631
|
+
for (const [service, names] of aliases.entries()) {
|
|
1632
|
+
for (const alias of names) {
|
|
1633
|
+
if (!alias || alias.length < 2)
|
|
1634
|
+
continue;
|
|
1635
|
+
if (!haystack.includes(` ${alias} `))
|
|
1636
|
+
continue;
|
|
1637
|
+
resolved.add(service);
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1387
1640
|
}
|
|
1388
1641
|
}
|
|
1389
1642
|
return Array.from(resolved);
|
|
@@ -1426,6 +1679,31 @@ export class CreateTasksService {
|
|
|
1426
1679
|
for (const service of resolveServicesFromCell(cells[0]))
|
|
1427
1680
|
registerWave(service, waveIndex);
|
|
1428
1681
|
}
|
|
1682
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1683
|
+
const line = lines[index];
|
|
1684
|
+
const waveMatch = line.match(WAVE_LABEL_PATTERN);
|
|
1685
|
+
if (!waveMatch)
|
|
1686
|
+
continue;
|
|
1687
|
+
const waveIndex = Number.parseInt(waveMatch[1] ?? "", 10);
|
|
1688
|
+
if (!Number.isFinite(waveIndex))
|
|
1689
|
+
continue;
|
|
1690
|
+
const contextLines = [line];
|
|
1691
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
1692
|
+
const next = lines[cursor];
|
|
1693
|
+
if (WAVE_LABEL_PATTERN.test(next))
|
|
1694
|
+
break;
|
|
1695
|
+
if (/^#{1,6}\s+/.test(next))
|
|
1696
|
+
break;
|
|
1697
|
+
if (/^(?:[-*]|\d+[.)])\s+/.test(next))
|
|
1698
|
+
break;
|
|
1699
|
+
contextLines.push(next);
|
|
1700
|
+
if (contextLines.length >= 4)
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
for (const service of resolveServicesFromCell(contextLines.join(" "))) {
|
|
1704
|
+
registerWave(service, waveIndex);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1429
1707
|
const startupWaves = Array.from(startupWavesMap.entries())
|
|
1430
1708
|
.sort((a, b) => a[0] - b[0])
|
|
1431
1709
|
.map(([wave, services]) => ({ wave, services: Array.from(services).sort((a, b) => a.localeCompare(b)) }));
|
|
@@ -1514,16 +1792,25 @@ export class CreateTasksService {
|
|
|
1514
1792
|
...plan.stories.map((story) => `${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`),
|
|
1515
1793
|
...plan.tasks.map((task) => `${task.title}\n${task.description ?? ""}`),
|
|
1516
1794
|
].join("\n");
|
|
1795
|
+
for (const epic of plan.epics) {
|
|
1796
|
+
for (const serviceId of normalizeStringArray(epic.serviceIds)) {
|
|
1797
|
+
register(serviceId);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1517
1800
|
const structureTargets = this.extractStructureTargets(docs);
|
|
1518
|
-
|
|
1801
|
+
const structureTokens = [...structureTargets.directories, ...structureTargets.files];
|
|
1802
|
+
for (const token of structureTokens) {
|
|
1803
|
+
if (!token.includes("/") &&
|
|
1804
|
+
!isStructuredFilePath(path.basename(token)) &&
|
|
1805
|
+
structureTokens.some((candidate) => candidate !== token && candidate.startsWith(`${token}/`))) {
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1519
1808
|
register(this.deriveServiceFromPathToken(token));
|
|
1520
1809
|
}
|
|
1521
1810
|
for (const match of docsText.matchAll(SERVICE_HANDLE_PATTERN))
|
|
1522
1811
|
register(match[1]);
|
|
1523
1812
|
for (const match of planText.matchAll(SERVICE_HANDLE_PATTERN))
|
|
1524
1813
|
register(match[1]);
|
|
1525
|
-
for (const mention of this.extractServiceMentionsFromText(docsText))
|
|
1526
|
-
register(mention);
|
|
1527
1814
|
for (const mention of this.extractServiceMentionsFromText(planText))
|
|
1528
1815
|
register(mention);
|
|
1529
1816
|
const corpus = [docsText, planText].filter(Boolean);
|
|
@@ -1550,11 +1837,395 @@ export class CreateTasksService {
|
|
|
1550
1837
|
foundationalDependencies: waveHints.foundationalDependencies,
|
|
1551
1838
|
};
|
|
1552
1839
|
}
|
|
1840
|
+
summarizeTopologySignals(docs) {
|
|
1841
|
+
const structureTargets = this.extractStructureTargets(docs);
|
|
1842
|
+
const structureServices = uniqueStrings([...structureTargets.directories, ...structureTargets.files]
|
|
1843
|
+
.map((token) => this.deriveServiceFromPathToken(token))
|
|
1844
|
+
.filter((value) => Boolean(value))).slice(0, 24);
|
|
1845
|
+
const topologyHeadings = this.extractSdsSectionCandidates(docs, 64)
|
|
1846
|
+
.filter((heading) => TOPOLOGY_HEADING_PATTERN.test(heading))
|
|
1847
|
+
.slice(0, 24);
|
|
1848
|
+
const docsText = docs
|
|
1849
|
+
.map((doc) => [doc.title, doc.path, doc.content, ...(doc.segments ?? []).map((segment) => segment.content)].filter(Boolean).join("\n"))
|
|
1850
|
+
.join("\n");
|
|
1851
|
+
const dependencyPairs = uniqueStrings(this.collectDependencyStatements(docsText).map((statement) => `${statement.dependent} -> ${statement.dependency}`)).slice(0, 16);
|
|
1852
|
+
const waveMentions = docsText
|
|
1853
|
+
.split(/\r?\n/)
|
|
1854
|
+
.map((line) => line.trim())
|
|
1855
|
+
.filter(Boolean)
|
|
1856
|
+
.filter((line) => WAVE_LABEL_PATTERN.test(line))
|
|
1857
|
+
.slice(0, 16);
|
|
1858
|
+
return {
|
|
1859
|
+
structureServices,
|
|
1860
|
+
topologyHeadings,
|
|
1861
|
+
dependencyPairs,
|
|
1862
|
+
waveMentions,
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
validateTopologyExtraction(projectKey, docs, graph) {
|
|
1866
|
+
const topologySignals = this.summarizeTopologySignals(docs);
|
|
1867
|
+
const hasServiceSignals = topologySignals.structureServices.length > 0 ||
|
|
1868
|
+
topologySignals.topologyHeadings.length > 0 ||
|
|
1869
|
+
topologySignals.dependencyPairs.length > 0;
|
|
1870
|
+
if (hasServiceSignals && graph.services.length === 0) {
|
|
1871
|
+
const signalSummary = uniqueStrings([
|
|
1872
|
+
...topologySignals.structureServices.map((service) => `structure:${service}`),
|
|
1873
|
+
...topologySignals.topologyHeadings.map((heading) => `heading:${heading}`),
|
|
1874
|
+
...topologySignals.dependencyPairs.map((pair) => `dependency:${pair}`),
|
|
1875
|
+
])
|
|
1876
|
+
.slice(0, 8)
|
|
1877
|
+
.join("; ");
|
|
1878
|
+
throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". SDS includes runtime topology signals but no services were resolved. Signals: ${signalSummary || "unavailable"}`);
|
|
1879
|
+
}
|
|
1880
|
+
if (topologySignals.waveMentions.length > 0 && graph.startupWaves.length === 0) {
|
|
1881
|
+
throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". SDS includes startup wave signals but no startup waves were resolved. Signals: ${topologySignals.waveMentions.slice(0, 6).join("; ")}`);
|
|
1882
|
+
}
|
|
1883
|
+
return topologySignals;
|
|
1884
|
+
}
|
|
1885
|
+
derivePlanningArtifacts(projectKey, docs, plan) {
|
|
1886
|
+
const discoveryGraph = this.buildServiceDependencyGraph(plan, docs);
|
|
1887
|
+
const topologySignals = this.validateTopologyExtraction(projectKey, docs, discoveryGraph);
|
|
1888
|
+
const serviceCatalog = this.buildServiceCatalogArtifact(projectKey, docs, discoveryGraph);
|
|
1889
|
+
const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
|
|
1890
|
+
const projectBuildPlan = this.buildProjectPlanArtifact(projectKey, docs, discoveryGraph, projectBuildMethod);
|
|
1891
|
+
return {
|
|
1892
|
+
discoveryGraph,
|
|
1893
|
+
topologySignals,
|
|
1894
|
+
serviceCatalog,
|
|
1895
|
+
projectBuildMethod,
|
|
1896
|
+
projectBuildPlan,
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
normalizeServiceId(value) {
|
|
1900
|
+
const normalizedName = this.normalizeServiceName(value);
|
|
1901
|
+
if (!normalizedName)
|
|
1902
|
+
return undefined;
|
|
1903
|
+
const slug = normalizedName
|
|
1904
|
+
.replace(/\s+/g, "-")
|
|
1905
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
1906
|
+
.replace(/-+/g, "-")
|
|
1907
|
+
.replace(/^-+|-+$/g, "");
|
|
1908
|
+
if (!slug)
|
|
1909
|
+
return undefined;
|
|
1910
|
+
return /^[a-z]/.test(slug) ? slug : `svc-${slug}`;
|
|
1911
|
+
}
|
|
1912
|
+
normalizeServiceLookupKey(value) {
|
|
1913
|
+
return value
|
|
1914
|
+
.toLowerCase()
|
|
1915
|
+
.replace(/[`"'()[\]{}]/g, " ")
|
|
1916
|
+
.replace(/[._/-]+/g, " ")
|
|
1917
|
+
.replace(/[^a-z0-9\s]+/g, " ")
|
|
1918
|
+
.replace(/\s+/g, " ")
|
|
1919
|
+
.trim();
|
|
1920
|
+
}
|
|
1921
|
+
createUniqueServiceId(baseId, used) {
|
|
1922
|
+
if (!used.has(baseId)) {
|
|
1923
|
+
used.add(baseId);
|
|
1924
|
+
return baseId;
|
|
1925
|
+
}
|
|
1926
|
+
let suffix = 2;
|
|
1927
|
+
while (used.has(`${baseId}-${suffix}`))
|
|
1928
|
+
suffix += 1;
|
|
1929
|
+
const next = `${baseId}-${suffix}`;
|
|
1930
|
+
used.add(next);
|
|
1931
|
+
return next;
|
|
1932
|
+
}
|
|
1933
|
+
buildServiceCatalogArtifact(projectKey, docs, graph) {
|
|
1934
|
+
const serviceNames = new Set(graph.services);
|
|
1935
|
+
for (const [dependent, dependencies] of graph.dependencies.entries()) {
|
|
1936
|
+
serviceNames.add(dependent);
|
|
1937
|
+
for (const dependency of dependencies)
|
|
1938
|
+
serviceNames.add(dependency);
|
|
1939
|
+
}
|
|
1940
|
+
for (const foundation of graph.foundationalDependencies) {
|
|
1941
|
+
const normalized = this.normalizeServiceName(foundation);
|
|
1942
|
+
if (normalized)
|
|
1943
|
+
serviceNames.add(normalized);
|
|
1944
|
+
}
|
|
1945
|
+
const orderedNames = [
|
|
1946
|
+
...graph.services,
|
|
1947
|
+
...Array.from(serviceNames)
|
|
1948
|
+
.filter((name) => !graph.services.includes(name))
|
|
1949
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
1950
|
+
];
|
|
1951
|
+
const usedServiceIds = new Set();
|
|
1952
|
+
const serviceIdByName = new Map();
|
|
1953
|
+
for (const name of orderedNames) {
|
|
1954
|
+
const baseId = this.normalizeServiceId(name);
|
|
1955
|
+
if (!baseId)
|
|
1956
|
+
continue;
|
|
1957
|
+
serviceIdByName.set(name, this.createUniqueServiceId(baseId, usedServiceIds));
|
|
1958
|
+
}
|
|
1959
|
+
const services = [];
|
|
1960
|
+
for (const name of orderedNames) {
|
|
1961
|
+
const id = serviceIdByName.get(name);
|
|
1962
|
+
if (!id)
|
|
1963
|
+
continue;
|
|
1964
|
+
const aliases = uniqueStrings([
|
|
1965
|
+
name,
|
|
1966
|
+
...(graph.aliases.get(name) ? Array.from(graph.aliases.get(name) ?? []) : []),
|
|
1967
|
+
]).sort((a, b) => a.localeCompare(b));
|
|
1968
|
+
const dependencyNames = Array.from(graph.dependencies.get(name) ?? []);
|
|
1969
|
+
const dependsOnServiceIds = uniqueStrings(dependencyNames
|
|
1970
|
+
.map((dependency) => serviceIdByName.get(dependency))
|
|
1971
|
+
.filter((value) => Boolean(value)));
|
|
1972
|
+
const startupWave = graph.waveRank.get(name);
|
|
1973
|
+
const wave = typeof startupWave === "number" && Number.isFinite(startupWave) ? startupWave : undefined;
|
|
1974
|
+
services.push({
|
|
1975
|
+
id,
|
|
1976
|
+
name,
|
|
1977
|
+
aliases,
|
|
1978
|
+
startupWave: wave,
|
|
1979
|
+
dependsOnServiceIds,
|
|
1980
|
+
isFoundational: graph.foundationalDependencies.some((foundation) => this.normalizeServiceName(foundation) === name || this.normalizeServiceId(foundation) === id) || dependsOnServiceIds.length === 0,
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
if (services.length === 0) {
|
|
1984
|
+
const fallbackServiceId = this.normalizeServiceId(`${projectKey} core`) ?? `${projectKey}-core`;
|
|
1985
|
+
services.push({
|
|
1986
|
+
id: fallbackServiceId,
|
|
1987
|
+
name: `${projectKey} core`,
|
|
1988
|
+
aliases: uniqueStrings([`${projectKey} core`, projectKey, "core"]),
|
|
1989
|
+
dependsOnServiceIds: [],
|
|
1990
|
+
isFoundational: true,
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
const sourceDocs = docs
|
|
1994
|
+
.map((doc) => doc.path ?? (doc.id ? `docdex:${doc.id}` : doc.title ?? "doc"))
|
|
1995
|
+
.filter((value) => Boolean(value))
|
|
1996
|
+
.filter((value, index, items) => items.indexOf(value) === index)
|
|
1997
|
+
.slice(0, 24);
|
|
1998
|
+
return {
|
|
1999
|
+
projectKey,
|
|
2000
|
+
generatedAt: new Date().toISOString(),
|
|
2001
|
+
sourceDocs,
|
|
2002
|
+
services,
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
buildServiceCatalogPromptSummary(catalog) {
|
|
2006
|
+
if (!catalog.services.length) {
|
|
2007
|
+
return "- No services detected. Infer services from SDS and ensure every epic includes at least one service id.";
|
|
2008
|
+
}
|
|
2009
|
+
const allIds = catalog.services.map((service) => service.id);
|
|
2010
|
+
const idChunks = [];
|
|
2011
|
+
for (let index = 0; index < allIds.length; index += 12) {
|
|
2012
|
+
idChunks.push(allIds.slice(index, index + 12).join(", "));
|
|
2013
|
+
}
|
|
2014
|
+
const detailLimit = Math.min(catalog.services.length, 40);
|
|
2015
|
+
const detailLines = catalog.services.slice(0, detailLimit).map((service) => {
|
|
2016
|
+
const deps = service.dependsOnServiceIds.length > 0 ? service.dependsOnServiceIds.join(", ") : "none";
|
|
2017
|
+
const wave = typeof service.startupWave === "number" ? `wave=${service.startupWave}` : "wave=unspecified";
|
|
2018
|
+
return `- ${service.id} (${wave}; deps: ${deps}; aliases: ${service.aliases.slice(0, 5).join(", ")})`;
|
|
2019
|
+
});
|
|
2020
|
+
if (catalog.services.length > detailLimit) {
|
|
2021
|
+
detailLines.push(`- ${catalog.services.length - detailLimit} additional services omitted from detailed lines (still listed in allowed serviceIds).`);
|
|
2022
|
+
}
|
|
2023
|
+
return [`- Allowed serviceIds (${allIds.length}):`, ...idChunks.map((chunk) => ` ${chunk}`), "- Service details:", ...detailLines].join("\n");
|
|
2024
|
+
}
|
|
2025
|
+
alignEpicsToServiceCatalog(epics, catalog, policy) {
|
|
2026
|
+
const warnings = [];
|
|
2027
|
+
const validServiceIds = new Set(catalog.services.map((service) => service.id));
|
|
2028
|
+
const serviceOrder = new Map(catalog.services.map((service, index) => [service.id, index]));
|
|
2029
|
+
const aliasToIds = new Map();
|
|
2030
|
+
const idLookup = new Map();
|
|
2031
|
+
const registerAlias = (rawValue, serviceId) => {
|
|
2032
|
+
const normalized = this.normalizeServiceLookupKey(rawValue);
|
|
2033
|
+
if (!normalized)
|
|
2034
|
+
return;
|
|
2035
|
+
const bucket = aliasToIds.get(normalized) ?? new Set();
|
|
2036
|
+
bucket.add(serviceId);
|
|
2037
|
+
aliasToIds.set(normalized, bucket);
|
|
2038
|
+
};
|
|
2039
|
+
for (const service of catalog.services) {
|
|
2040
|
+
const normalizedId = this.normalizeServiceLookupKey(service.id);
|
|
2041
|
+
if (normalizedId)
|
|
2042
|
+
idLookup.set(normalizedId, service.id);
|
|
2043
|
+
registerAlias(service.id, service.id);
|
|
2044
|
+
registerAlias(service.name, service.id);
|
|
2045
|
+
for (const alias of service.aliases) {
|
|
2046
|
+
registerAlias(alias, service.id);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
const containsLookupPhrase = (haystack, phrase) => {
|
|
2050
|
+
if (!haystack || !phrase)
|
|
2051
|
+
return false;
|
|
2052
|
+
if (!phrase.includes(" ") && phrase.length < 4)
|
|
2053
|
+
return false;
|
|
2054
|
+
return ` ${haystack} `.includes(` ${phrase} `);
|
|
2055
|
+
};
|
|
2056
|
+
const mapCandidateToServiceId = (value) => {
|
|
2057
|
+
const normalized = this.normalizeServiceLookupKey(value);
|
|
2058
|
+
if (!normalized)
|
|
2059
|
+
return {};
|
|
2060
|
+
const directId = idLookup.get(normalized);
|
|
2061
|
+
if (directId)
|
|
2062
|
+
return { resolvedId: directId };
|
|
2063
|
+
const aliasMatches = aliasToIds.get(normalized);
|
|
2064
|
+
if (aliasMatches && aliasMatches.size === 1) {
|
|
2065
|
+
return { resolvedId: Array.from(aliasMatches)[0] };
|
|
2066
|
+
}
|
|
2067
|
+
if (aliasMatches && aliasMatches.size > 1) {
|
|
2068
|
+
return {
|
|
2069
|
+
ambiguousIds: Array.from(aliasMatches).sort((a, b) => (serviceOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (serviceOrder.get(b) ?? Number.MAX_SAFE_INTEGER)),
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
const idFromName = this.normalizeServiceId(normalized);
|
|
2073
|
+
if (!idFromName)
|
|
2074
|
+
return {};
|
|
2075
|
+
if (validServiceIds.has(idFromName))
|
|
2076
|
+
return { resolvedId: idFromName };
|
|
2077
|
+
const candidates = Array.from(validServiceIds)
|
|
2078
|
+
.filter((id) => id === idFromName || id.startsWith(`${idFromName}-`))
|
|
2079
|
+
.sort((a, b) => (serviceOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (serviceOrder.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
2080
|
+
if (candidates.length === 1)
|
|
2081
|
+
return { resolvedId: candidates[0] };
|
|
2082
|
+
if (candidates.length > 1)
|
|
2083
|
+
return { ambiguousIds: candidates };
|
|
2084
|
+
return {};
|
|
2085
|
+
};
|
|
2086
|
+
const inferServiceIdsFromEpicText = (epic) => {
|
|
2087
|
+
const text = this.normalizeServiceLookupKey([epic.title, epic.description ?? "", ...(epic.acceptanceCriteria ?? [])]
|
|
2088
|
+
.filter(Boolean)
|
|
2089
|
+
.join("\n"));
|
|
2090
|
+
if (!text)
|
|
2091
|
+
return [];
|
|
2092
|
+
const scored = new Map();
|
|
2093
|
+
for (const service of catalog.services) {
|
|
2094
|
+
let score = 0;
|
|
2095
|
+
const idToken = this.normalizeServiceLookupKey(service.id);
|
|
2096
|
+
if (idToken && containsLookupPhrase(text, idToken)) {
|
|
2097
|
+
score = Math.max(score, 120 + idToken.length);
|
|
2098
|
+
}
|
|
2099
|
+
const nameToken = this.normalizeServiceLookupKey(service.name);
|
|
2100
|
+
if (nameToken && containsLookupPhrase(text, nameToken)) {
|
|
2101
|
+
score = Math.max(score, 90 + nameToken.length);
|
|
2102
|
+
}
|
|
2103
|
+
for (const alias of service.aliases) {
|
|
2104
|
+
const aliasToken = this.normalizeServiceLookupKey(alias);
|
|
2105
|
+
if (!aliasToken || aliasToken === idToken || aliasToken === nameToken)
|
|
2106
|
+
continue;
|
|
2107
|
+
if (containsLookupPhrase(text, aliasToken)) {
|
|
2108
|
+
score = Math.max(score, 60 + aliasToken.length);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (score > 0)
|
|
2112
|
+
scored.set(service.id, score);
|
|
2113
|
+
}
|
|
2114
|
+
return Array.from(scored.entries())
|
|
2115
|
+
.sort((a, b) => b[1] - a[1] || (serviceOrder.get(a[0]) ?? 0) - (serviceOrder.get(b[0]) ?? 0))
|
|
2116
|
+
.map(([id]) => id)
|
|
2117
|
+
.slice(0, 4);
|
|
2118
|
+
};
|
|
2119
|
+
const pickFallbackServiceIds = (epic, count) => {
|
|
2120
|
+
const text = this.normalizeServiceLookupKey([epic.title, epic.description ?? "", ...(epic.acceptanceCriteria ?? [])]
|
|
2121
|
+
.filter(Boolean)
|
|
2122
|
+
.join("\n"));
|
|
2123
|
+
const ranked = catalog.services
|
|
2124
|
+
.map((service) => {
|
|
2125
|
+
let score = 0;
|
|
2126
|
+
if (service.isFoundational)
|
|
2127
|
+
score += 100;
|
|
2128
|
+
if (typeof service.startupWave === "number")
|
|
2129
|
+
score += Math.max(0, 40 - service.startupWave * 2);
|
|
2130
|
+
if (service.dependsOnServiceIds.length === 0)
|
|
2131
|
+
score += 20;
|
|
2132
|
+
const tokens = uniqueStrings([service.id, service.name, ...service.aliases])
|
|
2133
|
+
.map((value) => this.normalizeServiceLookupKey(value))
|
|
2134
|
+
.filter(Boolean);
|
|
2135
|
+
for (const token of tokens) {
|
|
2136
|
+
if (containsLookupPhrase(text, token)) {
|
|
2137
|
+
score += 25 + token.length;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
return { id: service.id, score };
|
|
2141
|
+
})
|
|
2142
|
+
.sort((a, b) => b.score - a.score ||
|
|
2143
|
+
(serviceOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (serviceOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
|
|
2144
|
+
return ranked.slice(0, Math.max(1, count)).map((entry) => entry.id);
|
|
2145
|
+
};
|
|
2146
|
+
const normalizedEpics = epics.map((epic, index) => {
|
|
2147
|
+
const explicitServiceIds = normalizeStringArray(epic.serviceIds);
|
|
2148
|
+
const resolvedServiceIds = [];
|
|
2149
|
+
const unresolvedServiceIds = [];
|
|
2150
|
+
const ambiguousServiceIds = [];
|
|
2151
|
+
for (const candidate of explicitServiceIds) {
|
|
2152
|
+
const mapped = mapCandidateToServiceId(candidate);
|
|
2153
|
+
if (mapped.resolvedId) {
|
|
2154
|
+
resolvedServiceIds.push(mapped.resolvedId);
|
|
2155
|
+
}
|
|
2156
|
+
else if ((mapped.ambiguousIds?.length ?? 0) > 1) {
|
|
2157
|
+
ambiguousServiceIds.push({ candidate, options: mapped.ambiguousIds ?? [] });
|
|
2158
|
+
unresolvedServiceIds.push(candidate);
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
unresolvedServiceIds.push(candidate);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
const inferredServiceIds = inferServiceIdsFromEpicText(epic);
|
|
2165
|
+
const targetServiceCount = Math.max(1, Math.min(3, explicitServiceIds.length || 1));
|
|
2166
|
+
if (policy === "auto-remediate") {
|
|
2167
|
+
for (const inferred of inferredServiceIds) {
|
|
2168
|
+
if (resolvedServiceIds.includes(inferred))
|
|
2169
|
+
continue;
|
|
2170
|
+
resolvedServiceIds.push(inferred);
|
|
2171
|
+
if (resolvedServiceIds.length >= targetServiceCount)
|
|
2172
|
+
break;
|
|
2173
|
+
}
|
|
2174
|
+
if (resolvedServiceIds.length === 0 && catalog.services.length > 0) {
|
|
2175
|
+
resolvedServiceIds.push(...pickFallbackServiceIds(epic, 1));
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
else if (resolvedServiceIds.length === 0 && inferredServiceIds.length > 0) {
|
|
2179
|
+
resolvedServiceIds.push(...inferredServiceIds.slice(0, Math.max(1, targetServiceCount)));
|
|
2180
|
+
}
|
|
2181
|
+
const dedupedServiceIds = uniqueStrings(resolvedServiceIds)
|
|
2182
|
+
.filter((serviceId) => validServiceIds.has(serviceId))
|
|
2183
|
+
.sort((a, b) => (serviceOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (serviceOrder.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
2184
|
+
if (dedupedServiceIds.length === 0) {
|
|
2185
|
+
throw new Error(`Epic ${epic.localId ?? index + 1} (${epic.title}) has no valid phase-0 service references. Allowed service ids: ${Array.from(validServiceIds).join(", ")}`);
|
|
2186
|
+
}
|
|
2187
|
+
if (unresolvedServiceIds.length > 0 || ambiguousServiceIds.length > 0) {
|
|
2188
|
+
const unresolvedLabel = unresolvedServiceIds.length > 0 ? unresolvedServiceIds.join(", ") : "(none)";
|
|
2189
|
+
const issueLabel = unresolvedServiceIds.length > 0 ? "unknown service ids" : "ambiguous service ids";
|
|
2190
|
+
const ambiguity = ambiguousServiceIds.length > 0
|
|
2191
|
+
? ` Ambiguous mappings: ${ambiguousServiceIds
|
|
2192
|
+
.map((item) => `${item.candidate} -> [${item.options.join(", ")}]`)
|
|
2193
|
+
.join("; ")}.`
|
|
2194
|
+
: "";
|
|
2195
|
+
const message = `Epic ${epic.localId ?? index + 1} (${epic.title}) referenced ${issueLabel}: ${unresolvedLabel}.${ambiguity}`;
|
|
2196
|
+
if (policy === "fail") {
|
|
2197
|
+
throw new Error(`${message} Allowed service ids: ${Array.from(validServiceIds).join(", ")}`);
|
|
2198
|
+
}
|
|
2199
|
+
warnings.push(`${message} Auto-remediated to: ${dedupedServiceIds.join(", ")}`);
|
|
2200
|
+
}
|
|
2201
|
+
const tags = normalizeEpicTags(epic.tags);
|
|
2202
|
+
if (dedupedServiceIds.length > 1 && !tags.includes(CROSS_SERVICE_TAG)) {
|
|
2203
|
+
tags.push(CROSS_SERVICE_TAG);
|
|
2204
|
+
}
|
|
2205
|
+
if (dedupedServiceIds.length <= 1 && tags.includes(CROSS_SERVICE_TAG)) {
|
|
2206
|
+
warnings.push(`Epic ${epic.localId ?? index + 1} (${epic.title}) has tag ${CROSS_SERVICE_TAG} but only one service id (${dedupedServiceIds.join(", ")}). Keeping tag as explicit cross-cutting marker.`);
|
|
2207
|
+
}
|
|
2208
|
+
return {
|
|
2209
|
+
...epic,
|
|
2210
|
+
serviceIds: dedupedServiceIds,
|
|
2211
|
+
tags: uniqueStrings(tags),
|
|
2212
|
+
};
|
|
2213
|
+
});
|
|
2214
|
+
return { epics: normalizedEpics, warnings };
|
|
2215
|
+
}
|
|
1553
2216
|
buildProjectConstructionMethod(docs, graph) {
|
|
1554
2217
|
const toLabel = (value) => value.replace(/\s+/g, "-");
|
|
1555
2218
|
const structureTargets = this.extractStructureTargets(docs);
|
|
1556
|
-
const
|
|
1557
|
-
|
|
2219
|
+
const sourceDocPaths = new Set(docs
|
|
2220
|
+
.map((doc) => (doc.path ? path.relative(this.workspace.workspaceRoot, doc.path).replace(/\\/g, "/") : undefined))
|
|
2221
|
+
.filter((value) => Boolean(value)));
|
|
2222
|
+
const sourceDocDirectories = new Set(Array.from(sourceDocPaths)
|
|
2223
|
+
.map((docPath) => path.posix.dirname(docPath))
|
|
2224
|
+
.filter((dir) => dir && dir !== "."));
|
|
2225
|
+
const buildDirectories = structureTargets.directories.filter((dir) => !sourceDocDirectories.has(dir));
|
|
2226
|
+
const buildFiles = structureTargets.files.filter((file) => !sourceDocPaths.has(file));
|
|
2227
|
+
const topDirectories = (buildDirectories.length > 0 ? buildDirectories : structureTargets.directories).slice(0, 10);
|
|
2228
|
+
const topFiles = (buildFiles.length > 0 ? buildFiles : structureTargets.files).slice(0, 10);
|
|
1558
2229
|
const startupWaveLines = graph.startupWaves
|
|
1559
2230
|
.slice(0, 8)
|
|
1560
2231
|
.map((wave) => `- Wave ${wave.wave}: ${wave.services.map(toLabel).join(", ")}`);
|
|
@@ -1579,7 +2250,9 @@ export class CreateTasksService {
|
|
|
1579
2250
|
...(graph.foundationalDependencies.length > 0
|
|
1580
2251
|
? graph.foundationalDependencies.map((dependency) => ` - foundation: ${dependency}`)
|
|
1581
2252
|
: [" - foundation: infer runtime prerequisites from SDS deployment sections"]),
|
|
1582
|
-
...(startupWaveLines.length > 0
|
|
2253
|
+
...(startupWaveLines.length > 0
|
|
2254
|
+
? startupWaveLines
|
|
2255
|
+
: [" - startup waves: infer from documented dependency constraints"]),
|
|
1583
2256
|
"3) Implement services by dependency direction and startup wave.",
|
|
1584
2257
|
` - service order: ${serviceOrderLine}`,
|
|
1585
2258
|
...(dependencyPairs.length > 0
|
|
@@ -1593,6 +2266,7 @@ export class CreateTasksService {
|
|
|
1593
2266
|
const sourceDocs = docs
|
|
1594
2267
|
.map((doc) => doc.path ?? (doc.id ? `docdex:${doc.id}` : doc.title ?? "doc"))
|
|
1595
2268
|
.filter((value) => Boolean(value))
|
|
2269
|
+
.filter((value, index, items) => items.indexOf(value) === index)
|
|
1596
2270
|
.slice(0, 24);
|
|
1597
2271
|
return {
|
|
1598
2272
|
projectKey,
|
|
@@ -1600,6 +2274,7 @@ export class CreateTasksService {
|
|
|
1600
2274
|
sourceDocs,
|
|
1601
2275
|
startupWaves: graph.startupWaves.slice(0, 12),
|
|
1602
2276
|
services: graph.services.slice(0, 40),
|
|
2277
|
+
serviceIds: graph.services.map((service) => this.normalizeServiceId(service) ?? service.replace(/\s+/g, "-")).slice(0, 40),
|
|
1603
2278
|
foundationalDependencies: graph.foundationalDependencies.slice(0, 16),
|
|
1604
2279
|
buildMethod,
|
|
1605
2280
|
};
|
|
@@ -1675,14 +2350,36 @@ export class CreateTasksService {
|
|
|
1675
2350
|
return [service, wave * 10000 + order];
|
|
1676
2351
|
}));
|
|
1677
2352
|
const resolveEntityService = (text) => this.resolveServiceMentionFromPhrase(text, graph.aliases);
|
|
2353
|
+
const resolveServiceFromIds = (serviceIds) => {
|
|
2354
|
+
for (const serviceId of normalizeStringArray(serviceIds)) {
|
|
2355
|
+
const resolved = resolveEntityService(serviceId) ?? this.addServiceAlias(graph.aliases, serviceId);
|
|
2356
|
+
if (resolved)
|
|
2357
|
+
return resolved;
|
|
2358
|
+
}
|
|
2359
|
+
return undefined;
|
|
2360
|
+
};
|
|
1678
2361
|
const epics = plan.epics.map((epic) => ({ ...epic }));
|
|
1679
2362
|
const stories = plan.stories.map((story) => ({ ...story }));
|
|
1680
2363
|
const tasks = plan.tasks.map((task) => ({ ...task, dependsOnKeys: uniqueStrings(task.dependsOnKeys ?? []) }));
|
|
1681
2364
|
const storyByScope = new Map(stories.map((story) => [this.scopeStory(story), story]));
|
|
2365
|
+
const epicServiceByLocalId = new Map();
|
|
2366
|
+
const storyServiceByScope = new Map();
|
|
1682
2367
|
const taskServiceByScope = new Map();
|
|
2368
|
+
for (const epic of epics) {
|
|
2369
|
+
const serviceFromIds = resolveServiceFromIds(epic.serviceIds);
|
|
2370
|
+
const serviceFromText = resolveEntityService(`${epic.title}\n${epic.description ?? ""}`);
|
|
2371
|
+
epicServiceByLocalId.set(epic.localId, serviceFromIds ?? serviceFromText);
|
|
2372
|
+
}
|
|
2373
|
+
for (const story of stories) {
|
|
2374
|
+
const storyScope = this.scopeStory(story);
|
|
2375
|
+
const inherited = epicServiceByLocalId.get(story.epicLocalId);
|
|
2376
|
+
const serviceFromText = resolveEntityService(`${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`);
|
|
2377
|
+
storyServiceByScope.set(storyScope, serviceFromText ?? inherited);
|
|
2378
|
+
}
|
|
1683
2379
|
for (const task of tasks) {
|
|
2380
|
+
const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
|
|
1684
2381
|
const text = `${task.title ?? ""}\n${task.description ?? ""}`;
|
|
1685
|
-
taskServiceByScope.set(this.scopeTask(task), resolveEntityService(text));
|
|
2382
|
+
taskServiceByScope.set(this.scopeTask(task), resolveEntityService(text) ?? storyServiceByScope.get(storyScope));
|
|
1686
2383
|
}
|
|
1687
2384
|
const tasksByStory = new Map();
|
|
1688
2385
|
for (const task of tasks) {
|
|
@@ -1728,7 +2425,7 @@ export class CreateTasksService {
|
|
|
1728
2425
|
const taskRanks = storyTasks
|
|
1729
2426
|
.map((task) => serviceRank.get(taskServiceByScope.get(this.scopeTask(task)) ?? ""))
|
|
1730
2427
|
.filter((value) => typeof value === "number");
|
|
1731
|
-
const storyTextRank = serviceRank.get(
|
|
2428
|
+
const storyTextRank = serviceRank.get(storyServiceByScope.get(storyScope) ?? "");
|
|
1732
2429
|
const rank = taskRanks.length > 0 ? Math.min(...taskRanks) : storyTextRank ?? Number.MAX_SAFE_INTEGER;
|
|
1733
2430
|
storyRankByScope.set(storyScope, rank);
|
|
1734
2431
|
}
|
|
@@ -1738,7 +2435,7 @@ export class CreateTasksService {
|
|
|
1738
2435
|
const storyRanks = epicStories
|
|
1739
2436
|
.map((story) => storyRankByScope.get(this.scopeStory(story)))
|
|
1740
2437
|
.filter((value) => typeof value === "number");
|
|
1741
|
-
const epicTextRank = serviceRank.get(
|
|
2438
|
+
const epicTextRank = serviceRank.get(epicServiceByLocalId.get(epic.localId) ?? "");
|
|
1742
2439
|
const rank = storyRanks.length > 0 ? Math.min(...storyRanks) : epicTextRank ?? Number.MAX_SAFE_INTEGER;
|
|
1743
2440
|
epicRankByLocalId.set(epic.localId, rank);
|
|
1744
2441
|
}
|
|
@@ -1843,7 +2540,7 @@ export class CreateTasksService {
|
|
|
1843
2540
|
.slice(0, 12);
|
|
1844
2541
|
const bootstrapEpic = {
|
|
1845
2542
|
localId: epicLocalId,
|
|
1846
|
-
area: normalizeArea(projectKey) ?? "
|
|
2543
|
+
area: normalizeArea(projectKey) ?? "core",
|
|
1847
2544
|
title: "Codebase Foundation and Structure Setup",
|
|
1848
2545
|
description: "Create the SDS-defined codebase scaffold first (folders/files/service boundaries) before feature implementation tasks.",
|
|
1849
2546
|
acceptanceCriteria: [
|
|
@@ -2205,17 +2902,32 @@ export class CreateTasksService {
|
|
|
2205
2902
|
for (const doc of docs) {
|
|
2206
2903
|
if (!looksLikeSdsDoc(doc))
|
|
2207
2904
|
continue;
|
|
2905
|
+
const scanLimit = Math.max(limit * 4, limit + 12);
|
|
2906
|
+
const contentHeadings = collectSdsImplementationSignals(doc.content ?? "", {
|
|
2907
|
+
headingLimit: scanLimit,
|
|
2908
|
+
folderLimit: 0,
|
|
2909
|
+
}).sectionHeadings;
|
|
2208
2910
|
const segmentHeadings = (doc.segments ?? [])
|
|
2209
|
-
.map((segment) => segment.heading?.trim())
|
|
2911
|
+
.map((segment) => normalizeHeadingCandidate(segment.heading?.trim() ?? ""))
|
|
2210
2912
|
.filter((heading) => Boolean(heading));
|
|
2211
2913
|
const segmentContentHeadings = (doc.segments ?? [])
|
|
2212
|
-
.flatMap((segment) =>
|
|
2213
|
-
.
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2914
|
+
.flatMap((segment) => collectSdsImplementationSignals(segment.content ?? "", {
|
|
2915
|
+
headingLimit: Math.max(12, Math.ceil(scanLimit / 2)),
|
|
2916
|
+
folderLimit: 0,
|
|
2917
|
+
}).sectionHeadings)
|
|
2918
|
+
.slice(0, scanLimit);
|
|
2919
|
+
for (const heading of uniqueStrings([...contentHeadings, ...segmentHeadings, ...segmentContentHeadings])) {
|
|
2920
|
+
const normalized = normalizeHeadingCandidate(heading);
|
|
2217
2921
|
if (!normalized)
|
|
2218
2922
|
continue;
|
|
2923
|
+
if (!headingLooksImplementationRelevant(normalized))
|
|
2924
|
+
continue;
|
|
2925
|
+
if (/^software design specification$/i.test(normalized))
|
|
2926
|
+
continue;
|
|
2927
|
+
if (/^(?:\d+(?:\.\d+)*\.?\s*)?roles$/i.test(normalized))
|
|
2928
|
+
continue;
|
|
2929
|
+
if (sections.includes(normalized))
|
|
2930
|
+
continue;
|
|
2219
2931
|
sections.push(normalized);
|
|
2220
2932
|
if (sections.length >= limit)
|
|
2221
2933
|
break;
|
|
@@ -2295,8 +3007,8 @@ export class CreateTasksService {
|
|
|
2295
3007
|
}
|
|
2296
3008
|
return { docSummary: blocks.join("\n\n") || "(no docs)", warnings };
|
|
2297
3009
|
}
|
|
2298
|
-
buildPrompt(projectKey,
|
|
2299
|
-
const
|
|
3010
|
+
buildPrompt(projectKey, docSummary, projectBuildMethod, serviceCatalog, options) {
|
|
3011
|
+
const serviceCatalogSummary = this.buildServiceCatalogPromptSummary(serviceCatalog);
|
|
2300
3012
|
const limits = [
|
|
2301
3013
|
options.maxEpics ? `Limit epics to ${options.maxEpics}.` : "",
|
|
2302
3014
|
options.maxStoriesPerEpic ? `Limit stories per epic to ${options.maxStoriesPerEpic}.` : "",
|
|
@@ -2317,8 +3029,14 @@ export class CreateTasksService {
|
|
|
2317
3029
|
"- Keep epics actionable and implementation-oriented; avoid glossary/admin-only epics.",
|
|
2318
3030
|
"- Prefer dependency-first sequencing: foundational setup epics before dependent feature epics.",
|
|
2319
3031
|
"- Keep output derived from docs; do not assume stacks unless docs state them.",
|
|
3032
|
+
"- Use canonical documented names for modules, services, interfaces, commands, schemas, and files exactly as they appear in Docs and the project construction method.",
|
|
3033
|
+
"- Do not rename explicit documented targets or replace them with invented alternatives.",
|
|
3034
|
+
"- serviceIds is required and must contain one or more ids from the phase-0 service catalog below.",
|
|
3035
|
+
`- If an epic spans multiple services, include tag \"${CROSS_SERVICE_TAG}\" in tags.`,
|
|
2320
3036
|
"Project construction method to follow:",
|
|
2321
3037
|
projectBuildMethod,
|
|
3038
|
+
"Phase 0 service catalog (allowed serviceIds):",
|
|
3039
|
+
serviceCatalogSummary,
|
|
2322
3040
|
limits || "Use reasonable scope without over-generating epics.",
|
|
2323
3041
|
"Docs available:",
|
|
2324
3042
|
docSummary || "- (no docs provided; propose sensible epics).",
|
|
@@ -2363,9 +3081,9 @@ export class CreateTasksService {
|
|
|
2363
3081
|
},
|
|
2364
3082
|
{
|
|
2365
3083
|
localId: "task-2",
|
|
2366
|
-
title: "Integrate core
|
|
3084
|
+
title: "Integrate core dependencies and interfaces",
|
|
2367
3085
|
type: "feature",
|
|
2368
|
-
description: "Wire key
|
|
3086
|
+
description: "Wire key dependencies, interfaces, and integration paths so core behavior can execute end-to-end.",
|
|
2369
3087
|
estimatedStoryPoints: 3,
|
|
2370
3088
|
priorityHint: 20,
|
|
2371
3089
|
dependsOnKeys: ["task-1"],
|
|
@@ -2618,6 +3336,8 @@ export class CreateTasksService {
|
|
|
2618
3336
|
acceptanceCriteria: Array.isArray(epic.acceptanceCriteria) ? epic.acceptanceCriteria : [],
|
|
2619
3337
|
relatedDocs: normalizeRelatedDocs(epic.relatedDocs),
|
|
2620
3338
|
priorityHint: typeof epic.priorityHint === "number" ? epic.priorityHint : undefined,
|
|
3339
|
+
serviceIds: normalizeStringArray(epic.serviceIds),
|
|
3340
|
+
tags: normalizeEpicTags(epic.tags),
|
|
2621
3341
|
stories: [],
|
|
2622
3342
|
}))
|
|
2623
3343
|
.filter((e) => e.title);
|
|
@@ -2634,8 +3354,11 @@ export class CreateTasksService {
|
|
|
2634
3354
|
"- Use docdex handles when citing docs.",
|
|
2635
3355
|
"- Keep stories direct and implementation-oriented; avoid placeholder-only narrative sections.",
|
|
2636
3356
|
"- Keep story sequencing aligned with the project construction method.",
|
|
3357
|
+
"- Preserve canonical documented names for modules, services, interfaces, commands, schemas, and files exactly as written.",
|
|
2637
3358
|
`Epic context (key=${epic.key ?? epic.localId ?? "TBD"}):`,
|
|
2638
3359
|
epic.description ?? "(no description provided)",
|
|
3360
|
+
`Epic serviceIds: ${(epic.serviceIds ?? []).join(", ") || "(not provided)"}`,
|
|
3361
|
+
`Epic tags: ${(epic.tags ?? []).join(", ") || "(none)"}`,
|
|
2639
3362
|
"Project construction method:",
|
|
2640
3363
|
projectBuildMethod,
|
|
2641
3364
|
`Docs: ${docSummary || "none"}`,
|
|
@@ -2690,6 +3413,7 @@ export class CreateTasksService {
|
|
|
2690
3413
|
"- Order tasks from foundational prerequisites to dependents based on documented dependency direction and startup constraints.",
|
|
2691
3414
|
"- Avoid placeholder wording (TBD, TODO, to be defined, generic follow-up phrases).",
|
|
2692
3415
|
"- Avoid documentation-only or glossary-only tasks unless story acceptance explicitly requires them.",
|
|
3416
|
+
"- Preserve canonical documented names for modules, services, interfaces, commands, schemas, and files exactly as written.",
|
|
2693
3417
|
"- Use docdex handles when citing docs.",
|
|
2694
3418
|
"- If OPENAPI_HINTS are present in Docs, align tasks with hinted service/capability/stage/test_requirements.",
|
|
2695
3419
|
"- If SDS_COVERAGE_HINTS are present in Docs, cover the relevant SDS sections in implementation tasks.",
|
|
@@ -2789,11 +3513,11 @@ export class CreateTasksService {
|
|
|
2789
3513
|
},
|
|
2790
3514
|
{
|
|
2791
3515
|
localId: "t-fallback-2",
|
|
2792
|
-
title: `Integrate
|
|
3516
|
+
title: `Integrate dependencies for ${story.title}`,
|
|
2793
3517
|
type: "feature",
|
|
2794
3518
|
description: [
|
|
2795
|
-
`Integrate dependent
|
|
2796
|
-
"Align internal/external interfaces, data
|
|
3519
|
+
`Integrate dependent interfaces and runtime dependencies for "${story.title}" after core scope implementation.`,
|
|
3520
|
+
"Align internal/external interfaces, data shapes, and dependency wiring with the documented context.",
|
|
2797
3521
|
"Record dependency rationale and compatibility constraints in the task output.",
|
|
2798
3522
|
].join("\n"),
|
|
2799
3523
|
estimatedStoryPoints: 3,
|
|
@@ -2893,65 +3617,349 @@ export class CreateTasksService {
|
|
|
2893
3617
|
}
|
|
2894
3618
|
return { epics: planEpics, stories: planStories, tasks: planTasks };
|
|
2895
3619
|
}
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
const normalize = (value) => value
|
|
2899
|
-
.toLowerCase()
|
|
2900
|
-
.replace(/[`*_]/g, "")
|
|
2901
|
-
.replace(/[^a-z0-9\s/-]+/g, " ")
|
|
2902
|
-
.replace(/\s+/g, " ")
|
|
2903
|
-
.trim();
|
|
2904
|
-
const planCorpus = normalize([
|
|
3620
|
+
buildCoverageCorpus(plan) {
|
|
3621
|
+
return normalizeCoverageText([
|
|
2905
3622
|
...plan.epics.map((epic) => `${epic.title} ${epic.description ?? ""} ${(epic.acceptanceCriteria ?? []).join(" ")}`),
|
|
2906
3623
|
...plan.stories.map((story) => `${story.title} ${story.userStory ?? ""} ${story.description ?? ""} ${(story.acceptanceCriteria ?? []).join(" ")}`),
|
|
2907
3624
|
...plan.tasks.map((task) => `${task.title} ${task.description ?? ""}`),
|
|
2908
3625
|
].join("\n"));
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
.
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
3626
|
+
}
|
|
3627
|
+
collectCoverageAnchorsFromBacklog(backlog) {
|
|
3628
|
+
const anchors = new Set();
|
|
3629
|
+
for (const task of backlog.tasks) {
|
|
3630
|
+
const sufficiencyAudit = task.metadata?.sufficiencyAudit;
|
|
3631
|
+
const anchor = typeof sufficiencyAudit?.anchor === "string" ? sufficiencyAudit.anchor.trim() : "";
|
|
3632
|
+
if (anchor)
|
|
3633
|
+
anchors.add(anchor);
|
|
3634
|
+
if (Array.isArray(sufficiencyAudit?.anchors)) {
|
|
3635
|
+
for (const value of sufficiencyAudit.anchors) {
|
|
3636
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
3637
|
+
continue;
|
|
3638
|
+
anchors.add(value.trim());
|
|
3639
|
+
}
|
|
2923
3640
|
}
|
|
2924
|
-
|
|
2925
|
-
|
|
3641
|
+
}
|
|
3642
|
+
return anchors;
|
|
3643
|
+
}
|
|
3644
|
+
assertCoverageConsistency(projectKey, report, expected) {
|
|
3645
|
+
const sort = (values) => [...values].sort((left, right) => left.localeCompare(right));
|
|
3646
|
+
const sameSectionGaps = JSON.stringify(sort(report.missingSectionHeadings)) === JSON.stringify(sort(expected.missingSectionHeadings));
|
|
3647
|
+
const sameFolderGaps = JSON.stringify(sort(report.missingFolderEntries)) === JSON.stringify(sort(expected.missingFolderEntries));
|
|
3648
|
+
if (report.totalSignals !== expected.totalSignals ||
|
|
3649
|
+
report.coverageRatio !== expected.coverageRatio ||
|
|
3650
|
+
!sameSectionGaps ||
|
|
3651
|
+
!sameFolderGaps) {
|
|
3652
|
+
throw new Error(`create-tasks produced inconsistent coverage artifacts for project "${projectKey}". coverage-report.json diverged from task sufficiency coverage.`);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
async loadExpectedCoverageFromSufficiencyReport(reportPath) {
|
|
3656
|
+
if (!reportPath)
|
|
3657
|
+
return undefined;
|
|
3658
|
+
try {
|
|
3659
|
+
const raw = await fs.readFile(reportPath, "utf8");
|
|
3660
|
+
const parsed = JSON.parse(raw);
|
|
3661
|
+
const finalCoverage = parsed.finalCoverage;
|
|
3662
|
+
if (!finalCoverage)
|
|
3663
|
+
return undefined;
|
|
3664
|
+
if (typeof finalCoverage.coverageRatio !== "number" ||
|
|
3665
|
+
typeof finalCoverage.totalSignals !== "number" ||
|
|
3666
|
+
!Array.isArray(finalCoverage.missingSectionHeadings) ||
|
|
3667
|
+
!Array.isArray(finalCoverage.missingFolderEntries)) {
|
|
3668
|
+
return undefined;
|
|
2926
3669
|
}
|
|
3670
|
+
return {
|
|
3671
|
+
coverageRatio: finalCoverage.coverageRatio,
|
|
3672
|
+
totalSignals: finalCoverage.totalSignals,
|
|
3673
|
+
missingSectionHeadings: finalCoverage.missingSectionHeadings.filter((value) => typeof value === "string"),
|
|
3674
|
+
missingFolderEntries: finalCoverage.missingFolderEntries.filter((value) => typeof value === "string"),
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
catch {
|
|
3678
|
+
return undefined;
|
|
2927
3679
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
3680
|
+
}
|
|
3681
|
+
buildSdsCoverageReport(projectKey, docs, plan, existingAnchors = new Set()) {
|
|
3682
|
+
const coverageSignals = collectSdsCoverageSignalsFromDocs(docs, {
|
|
3683
|
+
headingLimit: SDS_COVERAGE_REPORT_SECTION_LIMIT,
|
|
3684
|
+
folderLimit: SDS_COVERAGE_REPORT_FOLDER_LIMIT,
|
|
3685
|
+
});
|
|
3686
|
+
const coverage = evaluateSdsCoverage(this.buildCoverageCorpus(plan), {
|
|
3687
|
+
sectionHeadings: coverageSignals.sectionHeadings,
|
|
3688
|
+
folderEntries: coverageSignals.folderEntries,
|
|
3689
|
+
}, existingAnchors);
|
|
3690
|
+
const matchedSections = coverageSignals.sectionHeadings.filter((heading) => !coverage.missingSectionHeadings.includes(heading));
|
|
3691
|
+
const matchedFolderEntries = coverageSignals.folderEntries.filter((entry) => !coverage.missingFolderEntries.includes(entry));
|
|
2930
3692
|
return {
|
|
2931
3693
|
projectKey,
|
|
2932
3694
|
generatedAt: new Date().toISOString(),
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
3695
|
+
totalSignals: coverage.totalSignals,
|
|
3696
|
+
totalSections: coverageSignals.sectionHeadings.length,
|
|
3697
|
+
totalFolderEntries: coverageSignals.folderEntries.length,
|
|
3698
|
+
rawSectionSignals: coverageSignals.rawSectionHeadings.length,
|
|
3699
|
+
rawFolderSignals: coverageSignals.rawFolderEntries.length,
|
|
3700
|
+
skippedHeadingSignals: coverageSignals.skippedHeadingSignals,
|
|
3701
|
+
skippedFolderSignals: coverageSignals.skippedFolderSignals,
|
|
3702
|
+
matched: matchedSections,
|
|
3703
|
+
unmatched: coverage.missingSectionHeadings,
|
|
3704
|
+
matchedSections,
|
|
3705
|
+
missingSectionHeadings: coverage.missingSectionHeadings,
|
|
3706
|
+
matchedFolderEntries,
|
|
3707
|
+
missingFolderEntries: coverage.missingFolderEntries,
|
|
3708
|
+
existingAnchorsCount: existingAnchors.size,
|
|
3709
|
+
coverageRatio: coverage.coverageRatio,
|
|
3710
|
+
notes: coverage.totalSignals === 0
|
|
3711
|
+
? ["No actionable SDS implementation signals detected; coverage defaults to 1.0."]
|
|
3712
|
+
: ["Coverage uses the same heading and folder signal model as task-sufficiency-audit."],
|
|
2940
3713
|
};
|
|
2941
3714
|
}
|
|
2942
|
-
async
|
|
3715
|
+
async acquirePlanArtifactLock(baseDir, options) {
|
|
3716
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
3717
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 80;
|
|
3718
|
+
const staleLockMs = options?.staleLockMs ?? 120000;
|
|
3719
|
+
const lockPath = path.join(baseDir, ".plan-artifacts.lock");
|
|
3720
|
+
const startedAtMs = Date.now();
|
|
3721
|
+
while (true) {
|
|
3722
|
+
try {
|
|
3723
|
+
const handle = await fs.open(lockPath, "wx");
|
|
3724
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }), "utf8");
|
|
3725
|
+
return async () => {
|
|
3726
|
+
try {
|
|
3727
|
+
await handle.close();
|
|
3728
|
+
}
|
|
3729
|
+
catch { }
|
|
3730
|
+
await fs.rm(lockPath, { force: true });
|
|
3731
|
+
};
|
|
3732
|
+
}
|
|
3733
|
+
catch (error) {
|
|
3734
|
+
const code = error.code;
|
|
3735
|
+
if (code !== "EEXIST")
|
|
3736
|
+
throw error;
|
|
3737
|
+
try {
|
|
3738
|
+
const stat = await fs.stat(lockPath);
|
|
3739
|
+
if (Date.now() - stat.mtimeMs > staleLockMs) {
|
|
3740
|
+
await fs.rm(lockPath, { force: true });
|
|
3741
|
+
continue;
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
catch {
|
|
3745
|
+
continue;
|
|
3746
|
+
}
|
|
3747
|
+
if (Date.now() - startedAtMs >= timeoutMs) {
|
|
3748
|
+
throw new Error(`Timed out acquiring plan artifact lock for ${baseDir}`);
|
|
3749
|
+
}
|
|
3750
|
+
await delay(pollIntervalMs);
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
async writeJsonArtifactAtomic(targetPath, data) {
|
|
3755
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
3756
|
+
const payload = JSON.stringify(data, null, 2);
|
|
3757
|
+
await fs.writeFile(tempPath, payload, "utf8");
|
|
3758
|
+
try {
|
|
3759
|
+
await fs.rename(tempPath, targetPath);
|
|
3760
|
+
}
|
|
3761
|
+
catch (error) {
|
|
3762
|
+
const code = error.code;
|
|
3763
|
+
if (code === "EEXIST" || code === "EPERM") {
|
|
3764
|
+
await fs.rm(targetPath, { force: true });
|
|
3765
|
+
await fs.rename(tempPath, targetPath);
|
|
3766
|
+
}
|
|
3767
|
+
else {
|
|
3768
|
+
await fs.rm(tempPath, { force: true });
|
|
3769
|
+
throw error;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
splitPersistedAcceptanceCriteria(value) {
|
|
3774
|
+
if (!value)
|
|
3775
|
+
return [];
|
|
3776
|
+
return uniqueStrings(value
|
|
3777
|
+
.split(/\r?\n/)
|
|
3778
|
+
.map((line) => line.replace(/^[-*]\s+/, "").trim())
|
|
3779
|
+
.filter(Boolean));
|
|
3780
|
+
}
|
|
3781
|
+
async loadPersistedBacklog(projectId) {
|
|
3782
|
+
const repoLike = this.workspaceRepo;
|
|
3783
|
+
if (typeof repoLike.getDb !== "function") {
|
|
3784
|
+
const epics = Array.isArray(repoLike.epics)
|
|
3785
|
+
? repoLike.epics.filter((row) => row.projectId === projectId)
|
|
3786
|
+
: [];
|
|
3787
|
+
const stories = Array.isArray(repoLike.stories)
|
|
3788
|
+
? repoLike.stories.filter((row) => row.projectId === projectId)
|
|
3789
|
+
: [];
|
|
3790
|
+
const tasks = Array.isArray(repoLike.tasks)
|
|
3791
|
+
? repoLike.tasks.filter((row) => row.projectId === projectId)
|
|
3792
|
+
: [];
|
|
3793
|
+
const taskIds = new Set(tasks.map((task) => task.id));
|
|
3794
|
+
const dependencies = Array.isArray(repoLike.deps)
|
|
3795
|
+
? repoLike.deps.filter((row) => taskIds.has(row.taskId))
|
|
3796
|
+
: [];
|
|
3797
|
+
return { epics, stories, tasks, dependencies };
|
|
3798
|
+
}
|
|
3799
|
+
const db = repoLike.getDb();
|
|
3800
|
+
const epicRows = await db.all(`SELECT id, project_id, key, title, description, story_points_total, priority, metadata_json, created_at, updated_at
|
|
3801
|
+
FROM epics
|
|
3802
|
+
WHERE project_id = ?
|
|
3803
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, projectId);
|
|
3804
|
+
const storyRows = await db.all(`SELECT id, project_id, epic_id, key, title, description, acceptance_criteria, story_points_total, priority, metadata_json, created_at, updated_at
|
|
3805
|
+
FROM user_stories
|
|
3806
|
+
WHERE project_id = ?
|
|
3807
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, projectId);
|
|
3808
|
+
const taskRows = await db.all(`SELECT id, project_id, epic_id, user_story_id, key, title, description, type, status, story_points, priority,
|
|
3809
|
+
assigned_agent_id, assignee_human, vcs_branch, vcs_base_branch, vcs_last_commit_sha, metadata_json,
|
|
3810
|
+
openapi_version_at_creation, created_at, updated_at
|
|
3811
|
+
FROM tasks
|
|
3812
|
+
WHERE project_id = ?
|
|
3813
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, projectId);
|
|
3814
|
+
const epics = epicRows.map((row) => ({
|
|
3815
|
+
id: row.id,
|
|
3816
|
+
projectId: row.project_id,
|
|
3817
|
+
key: row.key,
|
|
3818
|
+
title: row.title,
|
|
3819
|
+
description: row.description,
|
|
3820
|
+
storyPointsTotal: row.story_points_total ?? null,
|
|
3821
|
+
priority: row.priority ?? null,
|
|
3822
|
+
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
|
|
3823
|
+
createdAt: row.created_at,
|
|
3824
|
+
updatedAt: row.updated_at,
|
|
3825
|
+
}));
|
|
3826
|
+
const stories = storyRows.map((row) => ({
|
|
3827
|
+
id: row.id,
|
|
3828
|
+
projectId: row.project_id,
|
|
3829
|
+
epicId: row.epic_id,
|
|
3830
|
+
key: row.key,
|
|
3831
|
+
title: row.title,
|
|
3832
|
+
description: row.description,
|
|
3833
|
+
acceptanceCriteria: row.acceptance_criteria ?? null,
|
|
3834
|
+
storyPointsTotal: row.story_points_total ?? null,
|
|
3835
|
+
priority: row.priority ?? null,
|
|
3836
|
+
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
|
|
3837
|
+
createdAt: row.created_at,
|
|
3838
|
+
updatedAt: row.updated_at,
|
|
3839
|
+
}));
|
|
3840
|
+
const tasks = taskRows.map((row) => ({
|
|
3841
|
+
id: row.id,
|
|
3842
|
+
projectId: row.project_id,
|
|
3843
|
+
epicId: row.epic_id,
|
|
3844
|
+
userStoryId: row.user_story_id,
|
|
3845
|
+
key: row.key,
|
|
3846
|
+
title: row.title,
|
|
3847
|
+
description: row.description,
|
|
3848
|
+
type: row.type ?? null,
|
|
3849
|
+
status: row.status,
|
|
3850
|
+
storyPoints: row.story_points ?? null,
|
|
3851
|
+
priority: row.priority ?? null,
|
|
3852
|
+
assignedAgentId: row.assigned_agent_id ?? null,
|
|
3853
|
+
assigneeHuman: row.assignee_human ?? null,
|
|
3854
|
+
vcsBranch: row.vcs_branch ?? null,
|
|
3855
|
+
vcsBaseBranch: row.vcs_base_branch ?? null,
|
|
3856
|
+
vcsLastCommitSha: row.vcs_last_commit_sha ?? null,
|
|
3857
|
+
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
|
|
3858
|
+
openapiVersionAtCreation: row.openapi_version_at_creation ?? null,
|
|
3859
|
+
createdAt: row.created_at,
|
|
3860
|
+
updatedAt: row.updated_at,
|
|
3861
|
+
}));
|
|
3862
|
+
const dependencies = typeof repoLike.getTaskDependencies === "function"
|
|
3863
|
+
? await repoLike.getTaskDependencies(tasks.map((task) => task.id))
|
|
3864
|
+
: [];
|
|
3865
|
+
return { epics, stories, tasks, dependencies };
|
|
3866
|
+
}
|
|
3867
|
+
buildPlanFromPersistedBacklog(backlog) {
|
|
3868
|
+
const storyById = new Map(backlog.stories.map((story) => [story.id, story]));
|
|
3869
|
+
const epicById = new Map(backlog.epics.map((epic) => [epic.id, epic]));
|
|
3870
|
+
const taskById = new Map(backlog.tasks.map((task) => [task.id, task]));
|
|
3871
|
+
const dependencyKeysByTaskId = new Map();
|
|
3872
|
+
for (const dependency of backlog.dependencies) {
|
|
3873
|
+
const current = dependencyKeysByTaskId.get(dependency.taskId) ?? [];
|
|
3874
|
+
const dependsOn = taskById.get(dependency.dependsOnTaskId)?.key;
|
|
3875
|
+
if (dependsOn && !current.includes(dependsOn))
|
|
3876
|
+
current.push(dependsOn);
|
|
3877
|
+
dependencyKeysByTaskId.set(dependency.taskId, current);
|
|
3878
|
+
}
|
|
3879
|
+
return {
|
|
3880
|
+
epics: backlog.epics.map((epic) => {
|
|
3881
|
+
const metadata = (epic.metadata ?? {});
|
|
3882
|
+
return {
|
|
3883
|
+
localId: epic.key,
|
|
3884
|
+
area: epic.key.split("-")[0]?.toLowerCase() || "proj",
|
|
3885
|
+
title: epic.title,
|
|
3886
|
+
description: epic.description,
|
|
3887
|
+
acceptanceCriteria: [],
|
|
3888
|
+
relatedDocs: normalizeRelatedDocs(metadata.doc_links),
|
|
3889
|
+
priorityHint: epic.priority ?? undefined,
|
|
3890
|
+
serviceIds: normalizeStringArray(metadata.service_ids),
|
|
3891
|
+
tags: normalizeStringArray(metadata.tags),
|
|
3892
|
+
stories: [],
|
|
3893
|
+
};
|
|
3894
|
+
}),
|
|
3895
|
+
stories: backlog.stories.map((story) => {
|
|
3896
|
+
const metadata = (story.metadata ?? {});
|
|
3897
|
+
return {
|
|
3898
|
+
localId: story.key,
|
|
3899
|
+
epicLocalId: epicById.get(story.epicId)?.key ?? story.epicId,
|
|
3900
|
+
title: story.title,
|
|
3901
|
+
userStory: undefined,
|
|
3902
|
+
description: story.description,
|
|
3903
|
+
acceptanceCriteria: this.splitPersistedAcceptanceCriteria(story.acceptanceCriteria),
|
|
3904
|
+
relatedDocs: normalizeRelatedDocs(metadata.doc_links),
|
|
3905
|
+
priorityHint: story.priority ?? undefined,
|
|
3906
|
+
tasks: [],
|
|
3907
|
+
};
|
|
3908
|
+
}),
|
|
3909
|
+
tasks: backlog.tasks.map((task) => {
|
|
3910
|
+
const metadata = (task.metadata ?? {});
|
|
3911
|
+
const testRequirements = (metadata.test_requirements ?? {});
|
|
3912
|
+
return {
|
|
3913
|
+
localId: task.key,
|
|
3914
|
+
epicLocalId: epicById.get(task.epicId)?.key ?? task.epicId,
|
|
3915
|
+
storyLocalId: storyById.get(task.userStoryId)?.key ?? task.userStoryId,
|
|
3916
|
+
title: task.title,
|
|
3917
|
+
type: task.type ?? "feature",
|
|
3918
|
+
description: task.description,
|
|
3919
|
+
estimatedStoryPoints: task.storyPoints ?? undefined,
|
|
3920
|
+
priorityHint: task.priority ?? undefined,
|
|
3921
|
+
dependsOnKeys: dependencyKeysByTaskId.get(task.id) ?? [],
|
|
3922
|
+
relatedDocs: normalizeRelatedDocs(metadata.doc_links),
|
|
3923
|
+
unitTests: normalizeStringArray(testRequirements.unit),
|
|
3924
|
+
componentTests: normalizeStringArray(testRequirements.component),
|
|
3925
|
+
integrationTests: normalizeStringArray(testRequirements.integration),
|
|
3926
|
+
apiTests: normalizeStringArray(testRequirements.api),
|
|
3927
|
+
qa: isPlainObject(metadata.qa) ? metadata.qa : undefined,
|
|
3928
|
+
};
|
|
3929
|
+
}),
|
|
3930
|
+
};
|
|
3931
|
+
}
|
|
3932
|
+
async writePlanArtifacts(projectKey, plan, docSummary, docs, buildPlan, serviceCatalog, options) {
|
|
2943
3933
|
const baseDir = path.join(this.workspace.mcodaDir, "tasks", projectKey);
|
|
2944
3934
|
await fs.mkdir(baseDir, { recursive: true });
|
|
2945
|
-
const
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
3935
|
+
const releaseLock = await this.acquirePlanArtifactLock(baseDir);
|
|
3936
|
+
try {
|
|
3937
|
+
const write = async (file, data) => {
|
|
3938
|
+
const target = path.join(baseDir, file);
|
|
3939
|
+
await this.writeJsonArtifactAtomic(target, data);
|
|
3940
|
+
};
|
|
3941
|
+
await write("plan.json", {
|
|
3942
|
+
projectKey,
|
|
3943
|
+
generatedAt: new Date().toISOString(),
|
|
3944
|
+
docSummary,
|
|
3945
|
+
buildPlan,
|
|
3946
|
+
serviceCatalog,
|
|
3947
|
+
...plan,
|
|
3948
|
+
});
|
|
3949
|
+
await write("build-plan.json", buildPlan);
|
|
3950
|
+
await write("services.json", serviceCatalog);
|
|
3951
|
+
await write("epics.json", plan.epics);
|
|
3952
|
+
await write("stories.json", plan.stories);
|
|
3953
|
+
await write("tasks.json", plan.tasks);
|
|
3954
|
+
const coverageReport = this.buildSdsCoverageReport(projectKey, docs, plan, options?.existingCoverageAnchors ?? new Set());
|
|
3955
|
+
if (options?.expectedCoverage) {
|
|
3956
|
+
this.assertCoverageConsistency(projectKey, coverageReport, options.expectedCoverage);
|
|
3957
|
+
}
|
|
3958
|
+
await write("coverage-report.json", coverageReport);
|
|
3959
|
+
}
|
|
3960
|
+
finally {
|
|
3961
|
+
await releaseLock();
|
|
3962
|
+
}
|
|
2955
3963
|
return { folder: baseDir };
|
|
2956
3964
|
}
|
|
2957
3965
|
async persistPlanToDb(projectId, projectKey, plan, jobId, commandRunId, options) {
|
|
@@ -2969,7 +3977,13 @@ export class CreateTasksService {
|
|
|
2969
3977
|
description: buildEpicDescription(key, epic.title || `Epic ${key}`, epic.description, epic.acceptanceCriteria, epic.relatedDocs),
|
|
2970
3978
|
storyPointsTotal: null,
|
|
2971
3979
|
priority: epic.priorityHint ?? (epicInserts.length + 1),
|
|
2972
|
-
metadata: epic.relatedDocs
|
|
3980
|
+
metadata: epic.relatedDocs || (epic.serviceIds?.length ?? 0) > 0 || (epic.tags?.length ?? 0) > 0
|
|
3981
|
+
? {
|
|
3982
|
+
...(epic.relatedDocs ? { doc_links: epic.relatedDocs } : {}),
|
|
3983
|
+
...(epic.serviceIds && epic.serviceIds.length > 0 ? { service_ids: epic.serviceIds } : {}),
|
|
3984
|
+
...(epic.tags && epic.tags.length > 0 ? { tags: epic.tags } : {}),
|
|
3985
|
+
}
|
|
3986
|
+
: undefined,
|
|
2973
3987
|
});
|
|
2974
3988
|
epicMeta.push({ key, node: epic });
|
|
2975
3989
|
}
|
|
@@ -3187,6 +4201,7 @@ export class CreateTasksService {
|
|
|
3187
4201
|
}
|
|
3188
4202
|
async createTasks(options) {
|
|
3189
4203
|
const agentStream = options.agentStream !== false;
|
|
4204
|
+
const unknownEpicServicePolicy = normalizeEpicServicePolicy(options.unknownEpicServicePolicy) ?? "auto-remediate";
|
|
3190
4205
|
const commandRun = await this.jobService.startCommandRun("create-tasks", options.projectKey);
|
|
3191
4206
|
const job = await this.jobService.startJob("create_tasks", commandRun.id, options.projectKey, {
|
|
3192
4207
|
commandName: "create-tasks",
|
|
@@ -3196,6 +4211,7 @@ export class CreateTasksService {
|
|
|
3196
4211
|
agent: options.agentName,
|
|
3197
4212
|
agentStream,
|
|
3198
4213
|
sdsPreflightCommit: options.sdsPreflightCommit === true,
|
|
4214
|
+
unknownEpicServicePolicy,
|
|
3199
4215
|
},
|
|
3200
4216
|
});
|
|
3201
4217
|
let lastError;
|
|
@@ -3208,6 +4224,8 @@ export class CreateTasksService {
|
|
|
3208
4224
|
});
|
|
3209
4225
|
let sdsPreflight;
|
|
3210
4226
|
let sdsPreflightError;
|
|
4227
|
+
let sdsPreflightBlockingReasons = [];
|
|
4228
|
+
let continueAfterSdsPreflightWarnings = false;
|
|
3211
4229
|
if (this.sdsPreflightFactory) {
|
|
3212
4230
|
let sdsPreflightCloseError;
|
|
3213
4231
|
try {
|
|
@@ -3219,7 +4237,7 @@ export class CreateTasksService {
|
|
|
3219
4237
|
inputPaths: options.inputs,
|
|
3220
4238
|
sdsPaths: options.inputs,
|
|
3221
4239
|
writeArtifacts: true,
|
|
3222
|
-
applyToSds: true,
|
|
4240
|
+
applyToSds: options.sdsPreflightApplyToSds === true,
|
|
3223
4241
|
commitAppliedChanges: options.sdsPreflightCommit === true,
|
|
3224
4242
|
commitMessage: options.sdsPreflightCommitMessage,
|
|
3225
4243
|
});
|
|
@@ -3279,12 +4297,15 @@ export class CreateTasksService {
|
|
|
3279
4297
|
}
|
|
3280
4298
|
if (blockingReasons.length > 0) {
|
|
3281
4299
|
sdsPreflightError = blockingReasons.join(" ");
|
|
4300
|
+
sdsPreflightBlockingReasons = [...blockingReasons];
|
|
4301
|
+
continueAfterSdsPreflightWarnings = true;
|
|
4302
|
+
await this.jobService.appendLog(job.id, `SDS preflight reported planning warnings but create-tasks will continue with remediation context: ${blockingReasons.join(" ")} Report: ${sdsPreflight.reportPath}\n`);
|
|
3282
4303
|
}
|
|
3283
4304
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3284
4305
|
stage: "sds_preflight",
|
|
3285
4306
|
timestamp: new Date().toISOString(),
|
|
3286
4307
|
details: {
|
|
3287
|
-
status: blockingReasons.length > 0 ? "
|
|
4308
|
+
status: blockingReasons.length > 0 ? "continued_with_warnings" : "succeeded",
|
|
3288
4309
|
error: sdsPreflightError,
|
|
3289
4310
|
readyForPlanning: sdsPreflight.readyForPlanning,
|
|
3290
4311
|
qualityStatus: sdsPreflight.qualityStatus,
|
|
@@ -3299,27 +4320,37 @@ export class CreateTasksService {
|
|
|
3299
4320
|
appliedToSds: sdsPreflight.appliedToSds,
|
|
3300
4321
|
appliedSdsCount: sdsPreflight.appliedSdsPaths.length,
|
|
3301
4322
|
commitHash: sdsPreflight.commitHash,
|
|
4323
|
+
blockingReasons,
|
|
4324
|
+
continuedWithWarnings: continueAfterSdsPreflightWarnings,
|
|
3302
4325
|
warnings: preflightWarnings,
|
|
3303
4326
|
},
|
|
3304
4327
|
});
|
|
3305
|
-
if (blockingReasons.length > 0) {
|
|
3306
|
-
throw new Error(`create-tasks blocked by SDS preflight. ${blockingReasons.join(" ")} Report: ${sdsPreflight.reportPath}`);
|
|
3307
|
-
}
|
|
3308
4328
|
}
|
|
3309
|
-
const
|
|
4329
|
+
const preflightGeneratedDocInputs = sdsPreflight && (!sdsPreflight.appliedToSds || continueAfterSdsPreflightWarnings)
|
|
4330
|
+
? sdsPreflight.generatedDocPaths
|
|
4331
|
+
: [];
|
|
4332
|
+
const preflightDocInputs = this.mergeDocInputs(options.inputs, sdsPreflight ? [...sdsPreflight.sourceSdsPaths, ...preflightGeneratedDocInputs] : []);
|
|
3310
4333
|
const docs = await this.prepareDocs(preflightDocInputs);
|
|
3311
4334
|
const { docSummary, warnings: indexedDocWarnings } = this.buildDocContext(docs);
|
|
3312
4335
|
const docWarnings = uniqueStrings([...(sdsPreflight?.warnings ?? []), ...indexedDocWarnings]);
|
|
3313
|
-
const
|
|
3314
|
-
const projectBuildMethod =
|
|
3315
|
-
const
|
|
3316
|
-
const { prompt } = this.buildPrompt(options.projectKey, docs, projectBuildMethod, options);
|
|
4336
|
+
const initialArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, { epics: [], stories: [], tasks: [] });
|
|
4337
|
+
const { discoveryGraph, topologySignals, serviceCatalog, projectBuildMethod, projectBuildPlan } = initialArtifacts;
|
|
4338
|
+
const { prompt } = this.buildPrompt(options.projectKey, docSummary, projectBuildMethod, serviceCatalog, options);
|
|
3317
4339
|
const qaPreflight = await this.buildQaPreflight();
|
|
3318
4340
|
const qaOverrides = this.buildQaOverrides(options);
|
|
3319
4341
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3320
4342
|
stage: "docs_indexed",
|
|
3321
4343
|
timestamp: new Date().toISOString(),
|
|
3322
|
-
details: {
|
|
4344
|
+
details: {
|
|
4345
|
+
count: docs.length,
|
|
4346
|
+
warnings: docWarnings,
|
|
4347
|
+
startupWaves: discoveryGraph.startupWaves.slice(0, 8),
|
|
4348
|
+
topologySignals: {
|
|
4349
|
+
structureServices: topologySignals.structureServices.slice(0, 8),
|
|
4350
|
+
topologyHeadings: topologySignals.topologyHeadings.slice(0, 8),
|
|
4351
|
+
waveMentions: topologySignals.waveMentions.slice(0, 4),
|
|
4352
|
+
},
|
|
4353
|
+
},
|
|
3323
4354
|
});
|
|
3324
4355
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3325
4356
|
stage: "build_plan_defined",
|
|
@@ -3330,6 +4361,18 @@ export class CreateTasksService {
|
|
|
3330
4361
|
startupWaves: projectBuildPlan.startupWaves.length,
|
|
3331
4362
|
},
|
|
3332
4363
|
});
|
|
4364
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
4365
|
+
stage: "phase0_services_defined",
|
|
4366
|
+
timestamp: new Date().toISOString(),
|
|
4367
|
+
details: {
|
|
4368
|
+
services: serviceCatalog.services.length,
|
|
4369
|
+
foundational: serviceCatalog.services.filter((service) => service.isFoundational).length,
|
|
4370
|
+
startupWaves: uniqueStrings(serviceCatalog.services
|
|
4371
|
+
.map((service) => (typeof service.startupWave === "number" ? String(service.startupWave) : ""))
|
|
4372
|
+
.filter(Boolean)).length,
|
|
4373
|
+
sourceDocs: serviceCatalog.sourceDocs.length,
|
|
4374
|
+
},
|
|
4375
|
+
});
|
|
3333
4376
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3334
4377
|
stage: "qa_preflight",
|
|
3335
4378
|
timestamp: new Date().toISOString(),
|
|
@@ -3342,7 +4385,12 @@ export class CreateTasksService {
|
|
|
3342
4385
|
try {
|
|
3343
4386
|
agent = await this.resolveAgent(options.agentName);
|
|
3344
4387
|
const { output: epicOutput } = await this.invokeAgentWithRetry(agent, prompt, "epics", agentStream, job.id, commandRun.id, { docWarnings });
|
|
3345
|
-
const
|
|
4388
|
+
const parsedEpics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
|
|
4389
|
+
const normalizedEpics = this.alignEpicsToServiceCatalog(parsedEpics, serviceCatalog, unknownEpicServicePolicy);
|
|
4390
|
+
for (const warning of normalizedEpics.warnings) {
|
|
4391
|
+
await this.jobService.appendLog(job.id, `[create-tasks] ${warning}\n`);
|
|
4392
|
+
}
|
|
4393
|
+
const epics = normalizedEpics.epics;
|
|
3346
4394
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3347
4395
|
stage: "epics_generated",
|
|
3348
4396
|
timestamp: new Date().toISOString(),
|
|
@@ -3359,6 +4407,10 @@ export class CreateTasksService {
|
|
|
3359
4407
|
}
|
|
3360
4408
|
catch (error) {
|
|
3361
4409
|
fallbackReason = error.message ?? String(error);
|
|
4410
|
+
if (unknownEpicServicePolicy === "fail" &&
|
|
4411
|
+
/unknown service ids|phase-0 service references/i.test(fallbackReason)) {
|
|
4412
|
+
throw error;
|
|
4413
|
+
}
|
|
3362
4414
|
planSource = "fallback";
|
|
3363
4415
|
await this.jobService.appendLog(job.id, `Agent planning failed, using deterministic fallback plan: ${fallbackReason}\n`);
|
|
3364
4416
|
plan = this.materializePlanFromSeed(this.fallbackPlan(options.projectKey, docs), {
|
|
@@ -3372,6 +4424,18 @@ export class CreateTasksService {
|
|
|
3372
4424
|
details: { epics: plan.epics.length, source: planSource, reason: fallbackReason },
|
|
3373
4425
|
});
|
|
3374
4426
|
}
|
|
4427
|
+
const normalizedPlanEpics = this.alignEpicsToServiceCatalog(plan.epics, serviceCatalog, unknownEpicServicePolicy);
|
|
4428
|
+
for (const warning of normalizedPlanEpics.warnings) {
|
|
4429
|
+
await this.jobService.appendLog(job.id, `[create-tasks] ${warning}\n`);
|
|
4430
|
+
}
|
|
4431
|
+
plan = {
|
|
4432
|
+
...plan,
|
|
4433
|
+
epics: normalizedPlanEpics.epics.map((epic, index) => ({
|
|
4434
|
+
...epic,
|
|
4435
|
+
localId: epic.localId ?? `e${index + 1}`,
|
|
4436
|
+
stories: [],
|
|
4437
|
+
})),
|
|
4438
|
+
};
|
|
3375
4439
|
plan = this.enforceStoryScopedDependencies(plan);
|
|
3376
4440
|
plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
|
|
3377
4441
|
plan = this.enforceStoryScopedDependencies(plan);
|
|
@@ -3389,7 +4453,7 @@ export class CreateTasksService {
|
|
|
3389
4453
|
timestamp: new Date().toISOString(),
|
|
3390
4454
|
details: { tasks: plan.tasks.length, source: planSource, fallbackReason },
|
|
3391
4455
|
});
|
|
3392
|
-
const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary, docs, projectBuildPlan);
|
|
4456
|
+
const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary, docs, projectBuildPlan, serviceCatalog);
|
|
3393
4457
|
await this.jobService.writeCheckpoint(job.id, {
|
|
3394
4458
|
stage: "plan_written",
|
|
3395
4459
|
timestamp: new Date().toISOString(),
|
|
@@ -3483,12 +4547,37 @@ export class CreateTasksService {
|
|
|
3483
4547
|
throw new Error(`create-tasks blocked: task sufficiency audit did not reach full coverage. Report: ${sufficiencyAudit.reportPath}`);
|
|
3484
4548
|
}
|
|
3485
4549
|
}
|
|
4550
|
+
if ((sufficiencyAudit?.totalTasksAdded ?? 0) > 0) {
|
|
4551
|
+
await this.seedPriorities(options.projectKey);
|
|
4552
|
+
}
|
|
4553
|
+
const finalBacklog = await this.loadPersistedBacklog(project.id);
|
|
4554
|
+
const finalPlan = this.buildPlanFromPersistedBacklog(finalBacklog);
|
|
4555
|
+
const finalArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, finalPlan);
|
|
4556
|
+
const finalCoverageAnchors = this.collectCoverageAnchorsFromBacklog(finalBacklog);
|
|
4557
|
+
const expectedCoverage = await this.loadExpectedCoverageFromSufficiencyReport(sufficiencyAudit?.reportPath);
|
|
4558
|
+
await this.writePlanArtifacts(options.projectKey, finalPlan, docSummary, docs, finalArtifacts.projectBuildPlan, finalArtifacts.serviceCatalog, {
|
|
4559
|
+
existingCoverageAnchors: finalCoverageAnchors,
|
|
4560
|
+
expectedCoverage,
|
|
4561
|
+
});
|
|
4562
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
4563
|
+
stage: "plan_refreshed",
|
|
4564
|
+
timestamp: new Date().toISOString(),
|
|
4565
|
+
details: {
|
|
4566
|
+
folder,
|
|
4567
|
+
epics: finalBacklog.epics.length,
|
|
4568
|
+
stories: finalBacklog.stories.length,
|
|
4569
|
+
tasks: finalBacklog.tasks.length,
|
|
4570
|
+
dependencies: finalBacklog.dependencies.length,
|
|
4571
|
+
services: finalArtifacts.serviceCatalog.services.length,
|
|
4572
|
+
startupWaves: finalArtifacts.projectBuildPlan.startupWaves.length,
|
|
4573
|
+
},
|
|
4574
|
+
});
|
|
3486
4575
|
await this.jobService.updateJobStatus(job.id, "completed", {
|
|
3487
4576
|
payload: {
|
|
3488
|
-
epicsCreated:
|
|
3489
|
-
storiesCreated:
|
|
3490
|
-
tasksCreated:
|
|
3491
|
-
dependenciesCreated:
|
|
4577
|
+
epicsCreated: finalBacklog.epics.length,
|
|
4578
|
+
storiesCreated: finalBacklog.stories.length,
|
|
4579
|
+
tasksCreated: finalBacklog.tasks.length,
|
|
4580
|
+
dependenciesCreated: finalBacklog.dependencies.length,
|
|
3492
4581
|
docs: docSummary,
|
|
3493
4582
|
planFolder: folder,
|
|
3494
4583
|
planSource,
|
|
@@ -3508,6 +4597,8 @@ export class CreateTasksService {
|
|
|
3508
4597
|
reportPath: sdsPreflight.reportPath,
|
|
3509
4598
|
openQuestionsPath: sdsPreflight.openQuestionsPath,
|
|
3510
4599
|
gapAddendumPath: sdsPreflight.gapAddendumPath,
|
|
4600
|
+
blockingReasons: sdsPreflightBlockingReasons,
|
|
4601
|
+
continuedWithWarnings: continueAfterSdsPreflightWarnings,
|
|
3511
4602
|
warnings: sdsPreflight.warnings,
|
|
3512
4603
|
}
|
|
3513
4604
|
: undefined,
|
|
@@ -3555,10 +4646,10 @@ export class CreateTasksService {
|
|
|
3555
4646
|
return {
|
|
3556
4647
|
jobId: job.id,
|
|
3557
4648
|
commandRunId: commandRun.id,
|
|
3558
|
-
epics:
|
|
3559
|
-
stories:
|
|
3560
|
-
tasks:
|
|
3561
|
-
dependencies:
|
|
4649
|
+
epics: finalBacklog.epics,
|
|
4650
|
+
stories: finalBacklog.stories,
|
|
4651
|
+
tasks: finalBacklog.tasks,
|
|
4652
|
+
dependencies: finalBacklog.dependencies,
|
|
3562
4653
|
};
|
|
3563
4654
|
}
|
|
3564
4655
|
catch (error) {
|