@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.
Files changed (26) hide show
  1. package/dist/api/AgentsApi.d.ts +4 -1
  2. package/dist/api/AgentsApi.d.ts.map +1 -1
  3. package/dist/api/AgentsApi.js +4 -1
  4. package/dist/services/docs/DocsService.d.ts +37 -0
  5. package/dist/services/docs/DocsService.d.ts.map +1 -1
  6. package/dist/services/docs/DocsService.js +537 -2
  7. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -1
  8. package/dist/services/docs/review/gates/OpenQuestionsGate.js +13 -2
  9. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts.map +1 -1
  10. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.js +12 -1
  11. package/dist/services/planning/CreateTasksService.d.ts +30 -0
  12. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  13. package/dist/services/planning/CreateTasksService.js +1269 -178
  14. package/dist/services/planning/SdsCoverageModel.d.ts +27 -0
  15. package/dist/services/planning/SdsCoverageModel.d.ts.map +1 -0
  16. package/dist/services/planning/SdsCoverageModel.js +138 -0
  17. package/dist/services/planning/SdsPreflightService.d.ts +2 -0
  18. package/dist/services/planning/SdsPreflightService.d.ts.map +1 -1
  19. package/dist/services/planning/SdsPreflightService.js +125 -31
  20. package/dist/services/planning/SdsStructureSignals.d.ts +24 -0
  21. package/dist/services/planning/SdsStructureSignals.d.ts.map +1 -0
  22. package/dist/services/planning/SdsStructureSignals.js +402 -0
  23. package/dist/services/planning/TaskSufficiencyService.d.ts +1 -0
  24. package/dist/services/planning/TaskSufficiencyService.d.ts.map +1 -1
  25. package/dist/services/planning/TaskSufficiencyService.js +218 -285
  26. 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 tokens = value
194
+ const normalized = value
191
195
  .toLowerCase()
192
- .split(/[^a-z]+/)
193
- .map((token) => token.trim())
194
- .filter(Boolean);
195
- for (const token of tokens) {
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 contracts/docs are consistent with delivered behavior."
565
- : "- Documentation impact is reviewed and no additional contract docs are required.",
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 SDS/OpenAPI contracts to avoid drift."];
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": "web|adm|bck|ops|infra|mobile",
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 = `${doc.path ?? ""}`.trim().toLowerCase();
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
- if (normalized.length > 140)
1154
- return undefined;
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
- const hadTrailingSlash = /\/$/.test(normalized);
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 = [doc.title, doc.path, doc.content, segments].filter(Boolean).join("\n");
1179
- for (const match of corpus.matchAll(DOC_PATH_TOKEN_PATTERN)) {
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 (FILE_EXTENSION_PATTERN.test(path.basename(normalized))) {
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 && SERVICE_PATH_CONTAINER_SEGMENTS.has(parts[idx])) {
1458
+ while (idx < parts.length - 1 && this.isLikelyServiceContainerSegment(parts, idx)) {
1236
1459
  idx += 1;
1237
1460
  }
1238
- return this.normalizeServiceName(parts[idx] ?? parts[0]);
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.normalizeServiceName(phrase);
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
- resolveServiceMentionFromPhrase(phrase, aliases) {
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
- for (const mention of this.extractServiceMentionsFromText(cell)) {
1384
- const canonical = this.addServiceAlias(aliases, mention);
1385
- if (canonical)
1386
- resolved.add(canonical);
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
- for (const token of [...structureTargets.directories, ...structureTargets.files]) {
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 topDirectories = structureTargets.directories.slice(0, 10);
1557
- const topFiles = structureTargets.files.slice(0, 10);
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 ? startupWaveLines : [" - startup waves: infer from dependency contracts"]),
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(resolveEntityService(`${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`) ?? "");
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(resolveEntityService(`${epic.title}\n${epic.description ?? ""}`) ?? "");
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) ?? "infra",
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) => extractMarkdownHeadings(segment.content ?? "", Math.max(6, Math.ceil(limit / 4))))
2213
- .slice(0, limit);
2214
- const contentHeadings = extractMarkdownHeadings(doc.content ?? "", limit);
2215
- for (const heading of [...segmentHeadings, ...segmentContentHeadings, ...contentHeadings]) {
2216
- const normalized = heading.replace(/[`*_]/g, "").trim();
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, docs, projectBuildMethod, options) {
2299
- const docSummary = docs.map((doc, idx) => describeDoc(doc, idx)).join("\n");
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 contracts and dependencies",
3084
+ title: "Integrate core dependencies and interfaces",
2367
3085
  type: "feature",
2368
- description: "Wire key contracts/interfaces and dependency paths so core behavior can execute end-to-end.",
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 contracts for ${story.title}`,
3516
+ title: `Integrate dependencies for ${story.title}`,
2793
3517
  type: "feature",
2794
3518
  description: [
2795
- `Integrate dependent contracts/interfaces for "${story.title}" after core scope implementation.`,
2796
- "Align internal/external interfaces, data contracts, and dependency wiring with SDS/OpenAPI context.",
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
- buildSdsCoverageReport(projectKey, docs, plan) {
2897
- const sections = this.extractSdsSectionCandidates(docs, SDS_COVERAGE_REPORT_SECTION_LIMIT);
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
- const matched = [];
2910
- const unmatched = [];
2911
- for (const section of sections) {
2912
- const normalizedSection = normalize(section);
2913
- if (!normalizedSection)
2914
- continue;
2915
- const keywords = normalizedSection
2916
- .split(/\s+/)
2917
- .filter((token) => token.length >= 4)
2918
- .slice(0, 6);
2919
- const hasDirectMatch = normalizedSection.length >= 6 && planCorpus.includes(normalizedSection);
2920
- const hasKeywordMatch = keywords.some((keyword) => planCorpus.includes(keyword));
2921
- if (hasDirectMatch || hasKeywordMatch) {
2922
- matched.push(section);
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
- else {
2925
- unmatched.push(section);
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
- const totalSections = matched.length + unmatched.length;
2929
- const coverageRatio = totalSections === 0 ? 1 : matched.length / totalSections;
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
- totalSections,
2934
- matched,
2935
- unmatched,
2936
- coverageRatio: Number(coverageRatio.toFixed(4)),
2937
- notes: totalSections === 0
2938
- ? ["No SDS section headings detected; coverage defaults to 1.0."]
2939
- : ["Coverage is heading-based heuristic match between SDS sections and generated epic/story/task corpus."],
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 writePlanArtifacts(projectKey, plan, docSummary, docs, buildPlan) {
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 write = async (file, data) => {
2946
- const target = path.join(baseDir, file);
2947
- await fs.writeFile(target, JSON.stringify(data, null, 2), "utf8");
2948
- };
2949
- await write("plan.json", { projectKey, generatedAt: new Date().toISOString(), docSummary, buildPlan, ...plan });
2950
- await write("build-plan.json", buildPlan);
2951
- await write("epics.json", plan.epics);
2952
- await write("stories.json", plan.stories);
2953
- await write("tasks.json", plan.tasks);
2954
- await write("coverage-report.json", this.buildSdsCoverageReport(projectKey, docs, plan));
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 ? { doc_links: epic.relatedDocs } : undefined,
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 ? "blocked" : "succeeded",
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 preflightDocInputs = this.mergeDocInputs(options.inputs, sdsPreflight ? [...sdsPreflight.sourceSdsPaths, ...sdsPreflight.generatedDocPaths] : []);
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 discoveryGraph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
3314
- const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
3315
- const projectBuildPlan = this.buildProjectPlanArtifact(options.projectKey, docs, discoveryGraph, projectBuildMethod);
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: { count: docs.length, warnings: docWarnings, startupWaves: discoveryGraph.startupWaves.slice(0, 8) },
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 epics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
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: epicRows.length,
3489
- storiesCreated: storyRows.length,
3490
- tasksCreated: taskRows.length,
3491
- dependenciesCreated: dependencyRows.length,
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: epicRows,
3559
- stories: storyRows,
3560
- tasks: taskRows,
3561
- dependencies: dependencyRows,
4649
+ epics: finalBacklog.epics,
4650
+ stories: finalBacklog.stories,
4651
+ tasks: finalBacklog.tasks,
4652
+ dependencies: finalBacklog.dependencies,
3562
4653
  };
3563
4654
  }
3564
4655
  catch (error) {