@mcoda/core 0.1.18 → 0.1.20

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 (58) hide show
  1. package/dist/api/QaTasksApi.d.ts.map +1 -1
  2. package/dist/api/QaTasksApi.js +3 -0
  3. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  4. package/dist/prompts/PdrPrompts.js +22 -8
  5. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  6. package/dist/prompts/SdsPrompts.js +53 -34
  7. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  8. package/dist/services/backlog/BacklogService.js +3 -0
  9. package/dist/services/backlog/TaskOrderingService.d.ts +9 -0
  10. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  11. package/dist/services/backlog/TaskOrderingService.js +251 -35
  12. package/dist/services/docs/DocsService.d.ts.map +1 -1
  13. package/dist/services/docs/DocsService.js +487 -71
  14. package/dist/services/docs/review/gates/PdrFolderTreeGate.d.ts +7 -0
  15. package/dist/services/docs/review/gates/PdrFolderTreeGate.d.ts.map +1 -0
  16. package/dist/services/docs/review/gates/PdrFolderTreeGate.js +151 -0
  17. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.d.ts +7 -0
  18. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.d.ts.map +1 -0
  19. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.js +109 -0
  20. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.d.ts +7 -0
  21. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.d.ts.map +1 -0
  22. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.js +128 -0
  23. package/dist/services/docs/review/gates/SdsFolderTreeGate.d.ts +7 -0
  24. package/dist/services/docs/review/gates/SdsFolderTreeGate.d.ts.map +1 -0
  25. package/dist/services/docs/review/gates/SdsFolderTreeGate.js +153 -0
  26. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts +7 -0
  27. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts.map +1 -0
  28. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.js +109 -0
  29. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.d.ts +7 -0
  30. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.d.ts.map +1 -0
  31. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.js +128 -0
  32. package/dist/services/estimate/EstimateService.d.ts +2 -0
  33. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  34. package/dist/services/estimate/EstimateService.js +54 -0
  35. package/dist/services/execution/QaTasksService.d.ts +6 -0
  36. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  37. package/dist/services/execution/QaTasksService.js +278 -95
  38. package/dist/services/execution/TaskSelectionService.d.ts +3 -0
  39. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  40. package/dist/services/execution/TaskSelectionService.js +33 -0
  41. package/dist/services/execution/WorkOnTasksService.d.ts +4 -0
  42. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  43. package/dist/services/execution/WorkOnTasksService.js +146 -22
  44. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  45. package/dist/services/openapi/OpenApiService.js +43 -4
  46. package/dist/services/planning/CreateTasksService.d.ts +15 -0
  47. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  48. package/dist/services/planning/CreateTasksService.js +592 -81
  49. package/dist/services/planning/RefineTasksService.d.ts +1 -0
  50. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  51. package/dist/services/planning/RefineTasksService.js +88 -2
  52. package/dist/services/review/CodeReviewService.d.ts +6 -0
  53. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  54. package/dist/services/review/CodeReviewService.js +260 -41
  55. package/dist/services/shared/ProjectGuidance.d.ts +18 -2
  56. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  57. package/dist/services/shared/ProjectGuidance.js +535 -34
  58. package/package.json +6 -6
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { promises as fs } from "node:fs";
3
+ import YAML from "yaml";
3
4
  import { AgentService } from "@mcoda/agents";
4
5
  import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
5
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -128,6 +129,7 @@ const extractScriptPort = (script) => {
128
129
  };
129
130
  const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));
130
131
  const DOC_CONTEXT_BUDGET = 8000;
132
+ const OPENAPI_HINT_OPERATIONS_LIMIT = 30;
131
133
  const DOCDEX_HANDLE = /^docdex:/i;
132
134
  const VALID_AREAS = new Set(["web", "adm", "bck", "ops", "infra", "mobile"]);
133
135
  const VALID_TASK_TYPES = new Set(["feature", "bug", "chore", "spike"]);
@@ -185,6 +187,28 @@ const normalizeRelatedDocs = (value) => {
185
187
  })
186
188
  .filter((entry) => Boolean(entry && DOCDEX_HANDLE.test(entry)));
187
189
  };
190
+ const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
191
+ const parseStructuredDoc = (raw) => {
192
+ if (!raw || raw.trim().length === 0)
193
+ return undefined;
194
+ try {
195
+ const parsed = YAML.parse(raw);
196
+ if (isPlainObject(parsed))
197
+ return parsed;
198
+ }
199
+ catch {
200
+ // fallback to JSON parse
201
+ }
202
+ try {
203
+ const parsed = JSON.parse(raw);
204
+ if (isPlainObject(parsed))
205
+ return parsed;
206
+ }
207
+ catch {
208
+ // ignore invalid fallback parse
209
+ }
210
+ return undefined;
211
+ };
188
212
  const describeDoc = (doc, idx) => {
189
213
  const title = doc.title ?? doc.path ?? doc.id ?? `doc-${idx + 1}`;
190
214
  const source = doc.path ?? doc.id ?? "docdex";
@@ -404,11 +428,14 @@ const DOC_SCAN_IGNORE_DIRS = new Set([
404
428
  "temp",
405
429
  ]);
406
430
  const DOC_SCAN_FILE_PATTERN = /\.(md|markdown|txt|rst|ya?ml|json)$/i;
431
+ const STRICT_SDS_PATH_PATTERN = /(^|\/)(sds(?:[-_. ][a-z0-9]+)?|software[-_ ]design(?:[-_ ](?:spec|specification|outline|doc))?|design[-_ ]spec(?:ification)?)(\/|[-_.]|$)/i;
432
+ const STRICT_SDS_CONTENT_PATTERN = /\b(software design specification|software design document|system design specification|\bSDS\b)\b/i;
407
433
  const SDS_LIKE_PATH_PATTERN = /(^|\/)(sds|software[-_ ]design|design[-_ ]spec|requirements|prd|pdr|rfp|architecture|solution[-_ ]design)/i;
408
434
  const OPENAPI_LIKE_PATH_PATTERN = /(openapi|swagger)/i;
409
435
  const STRUCTURE_LIKE_PATH_PATTERN = /(^|\/)(tree|structure|layout|folder|directory|services?|modules?)(\/|[-_.]|$)/i;
410
436
  const DOC_PATH_TOKEN_PATTERN = /(^|[\s`"'([{<])([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+)(?=$|[\s`"')\]}>.,;:!?])/g;
411
437
  const FILE_EXTENSION_PATTERN = /\.[a-z0-9]{1,10}$/i;
438
+ const TOP_LEVEL_STRUCTURE_PATTERN = /^[a-z][a-z0-9._-]{1,60}$/i;
412
439
  const SERVICE_PATH_CONTAINER_SEGMENTS = new Set([
413
440
  "services",
414
441
  "service",
@@ -481,6 +508,8 @@ const SERVICE_NAME_INVALID = new Set([
481
508
  ]);
482
509
  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;
483
510
  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;
511
+ const SERVICE_HANDLE_PATTERN = /\b((?:svc|ui|worker)-[a-z0-9-*]+)\b/gi;
512
+ const WAVE_LABEL_PATTERN = /\bwave\s*([0-9]{1,2})\b/i;
484
513
  const nextUniqueLocalId = (prefix, existing) => {
485
514
  let index = 1;
486
515
  let candidate = `${prefix}-${index}`;
@@ -491,6 +520,18 @@ const nextUniqueLocalId = (prefix, existing) => {
491
520
  existing.add(candidate);
492
521
  return candidate;
493
522
  };
523
+ const looksLikeSdsPath = (value) => STRICT_SDS_PATH_PATTERN.test(value.replace(/\\/g, "/").toLowerCase());
524
+ const looksLikeSdsDoc = (doc) => {
525
+ if ((doc.docType ?? "").toUpperCase() === "SDS")
526
+ return true;
527
+ const pathOrTitle = `${doc.path ?? ""}\n${doc.title ?? ""}`;
528
+ if (looksLikeSdsPath(pathOrTitle))
529
+ return true;
530
+ const sample = [doc.content ?? "", ...(doc.segments ?? []).slice(0, 4).map((seg) => seg.content ?? "")]
531
+ .join("\n")
532
+ .slice(0, 5000);
533
+ return STRICT_SDS_CONTENT_PATTERN.test(sample);
534
+ };
494
535
  const EPIC_SCHEMA_SNIPPET = `{
495
536
  "epics": [
496
537
  {
@@ -593,11 +634,24 @@ export class CreateTasksService {
593
634
  const docdex = this.docdex;
594
635
  await swallow(docdex?.close?.bind(docdex));
595
636
  }
637
+ storyScopeKey(epicLocalId, storyLocalId) {
638
+ return `${epicLocalId}::${storyLocalId}`;
639
+ }
640
+ taskScopeKey(epicLocalId, storyLocalId, taskLocalId) {
641
+ return `${epicLocalId}::${storyLocalId}::${taskLocalId}`;
642
+ }
643
+ scopeStory(story) {
644
+ return this.storyScopeKey(story.epicLocalId, story.localId);
645
+ }
646
+ scopeTask(task) {
647
+ return this.taskScopeKey(task.epicLocalId, task.storyLocalId, task.localId);
648
+ }
596
649
  async seedPriorities(projectKey) {
597
650
  const ordering = await this.taskOrderingFactory(this.workspace, { recordTelemetry: false });
598
651
  try {
599
652
  await ordering.orderTasks({
600
653
  projectKey,
654
+ apply: true,
601
655
  });
602
656
  }
603
657
  finally {
@@ -624,7 +678,68 @@ export class CreateTasksService {
624
678
  return this.ratingService;
625
679
  }
626
680
  async prepareDocs(inputs) {
627
- const resolvedInputs = inputs.length > 0 ? inputs : await this.resolveDefaultDocInputs();
681
+ const primaryInputs = inputs.length > 0 ? inputs : await this.resolveDefaultDocInputs();
682
+ let documents = await this.collectDocsFromInputs(primaryInputs);
683
+ if (!documents.some((doc) => looksLikeSdsDoc(doc))) {
684
+ const fallbackInputs = await this.resolveDefaultDocInputs();
685
+ if (fallbackInputs.length > 0) {
686
+ const alreadyUsed = new Set(primaryInputs.map((input) => this.normalizeDocInputForSet(input)));
687
+ const missingInputs = fallbackInputs.filter((candidate) => !alreadyUsed.has(this.normalizeDocInputForSet(candidate)));
688
+ if (missingInputs.length > 0) {
689
+ const discovered = await this.collectDocsFromInputs(missingInputs);
690
+ documents = this.mergeDocs(documents, discovered);
691
+ }
692
+ }
693
+ }
694
+ if (!documents.some((doc) => looksLikeSdsDoc(doc))) {
695
+ 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.");
696
+ }
697
+ return this.sortDocsForPlanning(documents);
698
+ }
699
+ normalizeDocInputForSet(input) {
700
+ if (input.startsWith("docdex:"))
701
+ return input.trim().toLowerCase();
702
+ const resolved = path.isAbsolute(input) ? input : path.join(this.workspace.workspaceRoot, input);
703
+ return path.resolve(resolved).toLowerCase();
704
+ }
705
+ docIdentity(doc) {
706
+ const pathKey = `${doc.path ?? ""}`.trim().toLowerCase();
707
+ const idKey = `${doc.id ?? ""}`.trim().toLowerCase();
708
+ if (pathKey)
709
+ return `path:${pathKey}`;
710
+ if (idKey)
711
+ return `id:${idKey}`;
712
+ const titleKey = `${doc.title ?? ""}`.trim().toLowerCase();
713
+ if (titleKey)
714
+ return `title:${titleKey}`;
715
+ const sample = `${doc.content ?? doc.segments?.[0]?.content ?? ""}`.slice(0, 120).toLowerCase();
716
+ return `sample:${sample}`;
717
+ }
718
+ mergeDocs(base, incoming) {
719
+ const merged = [...base];
720
+ const seen = new Set(merged.map((doc) => this.docIdentity(doc)));
721
+ for (const doc of incoming) {
722
+ const identity = this.docIdentity(doc);
723
+ if (seen.has(identity))
724
+ continue;
725
+ seen.add(identity);
726
+ merged.push(doc);
727
+ }
728
+ return merged;
729
+ }
730
+ sortDocsForPlanning(docs) {
731
+ return [...docs].sort((a, b) => {
732
+ const aIsSds = looksLikeSdsDoc(a) ? 0 : 1;
733
+ const bIsSds = looksLikeSdsDoc(b) ? 0 : 1;
734
+ if (aIsSds !== bIsSds)
735
+ return aIsSds - bIsSds;
736
+ const byUpdated = `${b.updatedAt ?? ""}`.localeCompare(`${a.updatedAt ?? ""}`);
737
+ if (byUpdated !== 0)
738
+ return byUpdated;
739
+ return `${a.path ?? a.title ?? ""}`.localeCompare(`${b.path ?? b.title ?? ""}`);
740
+ });
741
+ }
742
+ async collectDocsFromInputs(resolvedInputs) {
628
743
  if (resolvedInputs.length === 0)
629
744
  return [];
630
745
  const documents = [];
@@ -678,19 +793,41 @@ export class CreateTasksService {
678
793
  path.join(this.workspace.workspaceRoot, "openapi.json"),
679
794
  ];
680
795
  const existing = [];
796
+ const existingSet = new Set();
797
+ const existingDirectories = [];
681
798
  for (const candidate of candidates) {
682
799
  try {
683
800
  const stat = await fs.stat(candidate);
684
- if (stat.isFile() || stat.isDirectory())
685
- existing.push(candidate);
801
+ const resolved = path.resolve(candidate);
802
+ if (!stat.isFile() && !stat.isDirectory())
803
+ continue;
804
+ if (existingSet.has(resolved))
805
+ continue;
806
+ existing.push(resolved);
807
+ existingSet.add(resolved);
808
+ if (stat.isDirectory())
809
+ existingDirectories.push(resolved);
686
810
  }
687
811
  catch {
688
812
  // Ignore missing candidates; fall back to empty inputs.
689
813
  }
690
814
  }
691
- if (existing.length > 0)
692
- return existing;
693
- return this.findFuzzyDocInputs();
815
+ const fuzzy = await this.findFuzzyDocInputs();
816
+ if (existing.length === 0)
817
+ return fuzzy;
818
+ const isCoveredByDefaultInputs = (candidate) => {
819
+ const resolved = path.resolve(candidate);
820
+ if (existingSet.has(resolved))
821
+ return true;
822
+ for (const directory of existingDirectories) {
823
+ const relative = path.relative(directory, resolved);
824
+ if (!relative || (!relative.startsWith("..") && !path.isAbsolute(relative)))
825
+ return true;
826
+ }
827
+ return false;
828
+ };
829
+ const extras = fuzzy.filter((candidate) => !isCoveredByDefaultInputs(candidate));
830
+ return [...existing, ...extras];
694
831
  }
695
832
  async walkDocCandidates(currentDir, depth, collector) {
696
833
  if (depth > DOC_SCAN_MAX_DEPTH)
@@ -785,11 +922,14 @@ export class CreateTasksService {
785
922
  return undefined;
786
923
  if (/[\u0000-\u001f]/.test(normalized))
787
924
  return undefined;
925
+ const hadTrailingSlash = /\/$/.test(normalized);
788
926
  const parts = normalized.split("/").filter(Boolean);
789
- if (parts.length < 2)
927
+ if (parts.length < 2 && !(hadTrailingSlash && parts.length === 1))
790
928
  return undefined;
791
929
  if (parts.some((part) => part === "." || part === ".."))
792
930
  return undefined;
931
+ if (parts.length === 1 && !TOP_LEVEL_STRUCTURE_PATTERN.test(parts[0]))
932
+ return undefined;
793
933
  if (DOC_SCAN_IGNORE_DIRS.has(parts[0].toLowerCase()))
794
934
  return undefined;
795
935
  return parts.join("/");
@@ -969,7 +1109,97 @@ export class CreateTasksService {
969
1109
  }
970
1110
  return statements;
971
1111
  }
972
- sortServicesByDependency(services, dependencies) {
1112
+ extractStartupWaveHints(text, aliases) {
1113
+ const waveRank = new Map();
1114
+ const startupWavesMap = new Map();
1115
+ const foundational = new Set();
1116
+ const registerWave = (service, wave) => {
1117
+ const normalizedWave = Number.isFinite(wave) ? Math.max(0, wave) : Number.MAX_SAFE_INTEGER;
1118
+ const current = waveRank.get(service);
1119
+ if (current === undefined || normalizedWave < current) {
1120
+ waveRank.set(service, normalizedWave);
1121
+ }
1122
+ const bucket = startupWavesMap.get(normalizedWave) ?? new Set();
1123
+ bucket.add(service);
1124
+ startupWavesMap.set(normalizedWave, bucket);
1125
+ };
1126
+ const resolveServicesFromCell = (cell) => {
1127
+ const resolved = new Set();
1128
+ for (const match of cell.matchAll(SERVICE_HANDLE_PATTERN)) {
1129
+ const token = match[1]?.trim();
1130
+ if (!token)
1131
+ continue;
1132
+ if (token.includes("*")) {
1133
+ const normalizedPrefix = this.normalizeServiceName(token.replace(/\*+/g, ""));
1134
+ if (!normalizedPrefix)
1135
+ continue;
1136
+ for (const service of aliases.keys()) {
1137
+ if (service.startsWith(normalizedPrefix))
1138
+ resolved.add(service);
1139
+ }
1140
+ continue;
1141
+ }
1142
+ const canonical = this.resolveServiceMentionFromPhrase(token, aliases) ?? this.addServiceAlias(aliases, token);
1143
+ if (canonical)
1144
+ resolved.add(canonical);
1145
+ }
1146
+ if (resolved.size === 0) {
1147
+ for (const mention of this.extractServiceMentionsFromText(cell)) {
1148
+ const canonical = this.addServiceAlias(aliases, mention);
1149
+ if (canonical)
1150
+ resolved.add(canonical);
1151
+ }
1152
+ }
1153
+ return Array.from(resolved);
1154
+ };
1155
+ const lines = text
1156
+ .split(/\r?\n/)
1157
+ .map((line) => line.trim())
1158
+ .filter(Boolean)
1159
+ .slice(0, 2000);
1160
+ for (const line of lines) {
1161
+ if (!line.startsWith("|"))
1162
+ continue;
1163
+ const cells = line
1164
+ .split("|")
1165
+ .map((cell) => cell.trim())
1166
+ .filter(Boolean);
1167
+ if (cells.length < 2)
1168
+ continue;
1169
+ const waveFromFirst = cells[0].match(WAVE_LABEL_PATTERN);
1170
+ if (waveFromFirst) {
1171
+ const waveIndex = Number.parseInt(waveFromFirst[1] ?? "", 10);
1172
+ const services = resolveServicesFromCell(cells[1]);
1173
+ for (const service of services)
1174
+ registerWave(service, waveIndex);
1175
+ if (waveIndex === 0 && services.length === 0) {
1176
+ for (const token of cells[1]
1177
+ .replace(/[`_*]/g, "")
1178
+ .split(/[,+]/)
1179
+ .map((entry) => entry.trim())
1180
+ .filter(Boolean)) {
1181
+ foundational.add(token);
1182
+ }
1183
+ }
1184
+ continue;
1185
+ }
1186
+ const waveFromSecond = cells[1].match(WAVE_LABEL_PATTERN);
1187
+ if (!waveFromSecond)
1188
+ continue;
1189
+ const waveIndex = Number.parseInt(waveFromSecond[1] ?? "", 10);
1190
+ for (const service of resolveServicesFromCell(cells[0]))
1191
+ registerWave(service, waveIndex);
1192
+ }
1193
+ const startupWaves = Array.from(startupWavesMap.entries())
1194
+ .sort((a, b) => a[0] - b[0])
1195
+ .map(([wave, services]) => ({ wave, services: Array.from(services).sort((a, b) => a.localeCompare(b)) }));
1196
+ return {
1197
+ waveRank,
1198
+ startupWaves,
1199
+ foundationalDependencies: Array.from(foundational).slice(0, 12),
1200
+ };
1201
+ }
1202
+ sortServicesByDependency(services, dependencies, waveRank = new Map()) {
973
1203
  const nodes = Array.from(new Set(services));
974
1204
  const indegree = new Map();
975
1205
  const adjacency = new Map();
@@ -998,6 +1228,10 @@ export class CreateTasksService {
998
1228
  }
999
1229
  }
1000
1230
  const compare = (a, b) => {
1231
+ const waveA = waveRank.get(a) ?? Number.MAX_SAFE_INTEGER;
1232
+ const waveB = waveRank.get(b) ?? Number.MAX_SAFE_INTEGER;
1233
+ if (waveA !== waveB)
1234
+ return waveA - waveB;
1001
1235
  const dependedByA = dependedBy.get(a) ?? 0;
1002
1236
  const dependedByB = dependedBy.get(b) ?? 0;
1003
1237
  if (dependedByA !== dependedByB)
@@ -1048,6 +1282,10 @@ export class CreateTasksService {
1048
1282
  for (const token of [...structureTargets.directories, ...structureTargets.files]) {
1049
1283
  register(this.deriveServiceFromPathToken(token));
1050
1284
  }
1285
+ for (const match of docsText.matchAll(SERVICE_HANDLE_PATTERN))
1286
+ register(match[1]);
1287
+ for (const match of planText.matchAll(SERVICE_HANDLE_PATTERN))
1288
+ register(match[1]);
1051
1289
  for (const mention of this.extractServiceMentionsFromText(docsText))
1052
1290
  register(mention);
1053
1291
  for (const mention of this.extractServiceMentionsFromText(planText))
@@ -1065,10 +1303,57 @@ export class CreateTasksService {
1065
1303
  dependencies.set(dependent, next);
1066
1304
  }
1067
1305
  }
1068
- const services = this.sortServicesByDependency(Array.from(aliases.keys()), dependencies);
1069
- return { services, dependencies, aliases };
1306
+ const waveHints = this.extractStartupWaveHints(corpus.join("\n"), aliases);
1307
+ const services = this.sortServicesByDependency(Array.from(aliases.keys()), dependencies, waveHints.waveRank);
1308
+ return {
1309
+ services,
1310
+ dependencies,
1311
+ aliases,
1312
+ waveRank: waveHints.waveRank,
1313
+ startupWaves: waveHints.startupWaves,
1314
+ foundationalDependencies: waveHints.foundationalDependencies,
1315
+ };
1070
1316
  }
1071
- orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByLocalId) {
1317
+ buildProjectConstructionMethod(docs, graph) {
1318
+ const toLabel = (value) => value.replace(/\s+/g, "-");
1319
+ const structureTargets = this.extractStructureTargets(docs);
1320
+ const topDirectories = structureTargets.directories.slice(0, 10);
1321
+ const topFiles = structureTargets.files.slice(0, 10);
1322
+ const startupWaveLines = graph.startupWaves
1323
+ .slice(0, 8)
1324
+ .map((wave) => `- Wave ${wave.wave}: ${wave.services.map(toLabel).join(", ")}`);
1325
+ const serviceOrderLine = graph.services.length > 0
1326
+ ? graph.services
1327
+ .slice(0, 16)
1328
+ .map(toLabel)
1329
+ .join(" -> ")
1330
+ : "infer from SDS service dependencies and startup waves";
1331
+ const dependencyPairs = [];
1332
+ for (const [dependent, needs] of graph.dependencies.entries()) {
1333
+ for (const dependency of needs) {
1334
+ dependencyPairs.push(`${toLabel(dependent)} after ${toLabel(dependency)}`);
1335
+ }
1336
+ }
1337
+ return [
1338
+ "Project construction method (strict):",
1339
+ "1) Build repository structure from SDS folder tree first.",
1340
+ ...topDirectories.map((dir) => ` - create dir: ${dir}`),
1341
+ ...topFiles.map((file) => ` - create file: ${file}`),
1342
+ "2) Build foundational dependencies and low-wave services before consumers.",
1343
+ ...(graph.foundationalDependencies.length > 0
1344
+ ? graph.foundationalDependencies.map((dependency) => ` - foundation: ${dependency}`)
1345
+ : [" - foundation: infer runtime prerequisites from SDS deployment sections"]),
1346
+ ...(startupWaveLines.length > 0 ? startupWaveLines : [" - startup waves: infer from dependency contracts"]),
1347
+ "3) Implement services by dependency direction and startup wave.",
1348
+ ` - service order: ${serviceOrderLine}`,
1349
+ ...(dependencyPairs.length > 0
1350
+ ? dependencyPairs.slice(0, 14).map((pair) => ` - dependency: ${pair}`)
1351
+ : [" - dependency: infer explicit \"depends on\" relations from SDS"]),
1352
+ "4) Only then sequence user-facing features, QA hardening, and release chores.",
1353
+ "5) Keep task dependencies story-scoped while preserving epic/story/task ordering by this build method.",
1354
+ ].join("\n");
1355
+ }
1356
+ orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope) {
1072
1357
  const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
1073
1358
  const indegree = new Map();
1074
1359
  const outgoing = new Map();
@@ -1090,8 +1375,8 @@ export class CreateTasksService {
1090
1375
  const classB = classifyTask({ title: b.title ?? "", description: b.description, type: b.type });
1091
1376
  if (classA.foundation !== classB.foundation)
1092
1377
  return classA.foundation ? -1 : 1;
1093
- const rankA = serviceRank.get(taskServiceByLocalId.get(a.localId) ?? "") ?? Number.MAX_SAFE_INTEGER;
1094
- const rankB = serviceRank.get(taskServiceByLocalId.get(b.localId) ?? "") ?? Number.MAX_SAFE_INTEGER;
1378
+ const rankA = serviceRank.get(taskServiceByScope.get(this.scopeTask(a)) ?? "") ?? Number.MAX_SAFE_INTEGER;
1379
+ const rankB = serviceRank.get(taskServiceByScope.get(this.scopeTask(b)) ?? "") ?? Number.MAX_SAFE_INTEGER;
1095
1380
  if (rankA !== rankB)
1096
1381
  return rankA - rankB;
1097
1382
  const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
@@ -1132,27 +1417,33 @@ export class CreateTasksService {
1132
1417
  const graph = this.buildServiceDependencyGraph(plan, docs);
1133
1418
  if (!graph.services.length)
1134
1419
  return plan;
1135
- const serviceRank = new Map(graph.services.map((service, index) => [service, index]));
1420
+ const serviceOrderRank = new Map(graph.services.map((service, index) => [service, index]));
1421
+ const serviceRank = new Map(graph.services.map((service) => {
1422
+ const wave = graph.waveRank.get(service) ?? Number.MAX_SAFE_INTEGER;
1423
+ const order = serviceOrderRank.get(service) ?? Number.MAX_SAFE_INTEGER;
1424
+ return [service, wave * 10000 + order];
1425
+ }));
1136
1426
  const resolveEntityService = (text) => this.resolveServiceMentionFromPhrase(text, graph.aliases);
1137
1427
  const epics = plan.epics.map((epic) => ({ ...epic }));
1138
1428
  const stories = plan.stories.map((story) => ({ ...story }));
1139
1429
  const tasks = plan.tasks.map((task) => ({ ...task, dependsOnKeys: uniqueStrings(task.dependsOnKeys ?? []) }));
1140
- const storyByLocalId = new Map(stories.map((story) => [story.localId, story]));
1141
- const taskServiceByLocalId = new Map();
1430
+ const storyByScope = new Map(stories.map((story) => [this.scopeStory(story), story]));
1431
+ const taskServiceByScope = new Map();
1142
1432
  for (const task of tasks) {
1143
1433
  const text = `${task.title ?? ""}\n${task.description ?? ""}`;
1144
- taskServiceByLocalId.set(task.localId, resolveEntityService(text));
1434
+ taskServiceByScope.set(this.scopeTask(task), resolveEntityService(text));
1145
1435
  }
1146
1436
  const tasksByStory = new Map();
1147
1437
  for (const task of tasks) {
1148
- const bucket = tasksByStory.get(task.storyLocalId) ?? [];
1438
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1439
+ const bucket = tasksByStory.get(storyScope) ?? [];
1149
1440
  bucket.push(task);
1150
- tasksByStory.set(task.storyLocalId, bucket);
1441
+ tasksByStory.set(storyScope, bucket);
1151
1442
  }
1152
1443
  for (const storyTasks of tasksByStory.values()) {
1153
1444
  const tasksByService = new Map();
1154
1445
  for (const task of storyTasks) {
1155
- const service = taskServiceByLocalId.get(task.localId);
1446
+ const service = taskServiceByScope.get(this.scopeTask(task));
1156
1447
  if (!service)
1157
1448
  continue;
1158
1449
  const serviceTasks = tasksByService.get(service) ?? [];
@@ -1163,7 +1454,7 @@ export class CreateTasksService {
1163
1454
  serviceTasks.sort((a, b) => (a.priorityHint ?? Number.MAX_SAFE_INTEGER) - (b.priorityHint ?? Number.MAX_SAFE_INTEGER));
1164
1455
  }
1165
1456
  for (const task of storyTasks) {
1166
- const service = taskServiceByLocalId.get(task.localId);
1457
+ const service = taskServiceByScope.get(this.scopeTask(task));
1167
1458
  if (!service)
1168
1459
  continue;
1169
1460
  const requiredServices = graph.dependencies.get(service);
@@ -1179,21 +1470,22 @@ export class CreateTasksService {
1179
1470
  }
1180
1471
  }
1181
1472
  }
1182
- const storyRankByLocalId = new Map();
1473
+ const storyRankByScope = new Map();
1183
1474
  for (const story of stories) {
1184
- const storyTasks = tasksByStory.get(story.localId) ?? [];
1475
+ const storyScope = this.scopeStory(story);
1476
+ const storyTasks = tasksByStory.get(storyScope) ?? [];
1185
1477
  const taskRanks = storyTasks
1186
- .map((task) => serviceRank.get(taskServiceByLocalId.get(task.localId) ?? ""))
1478
+ .map((task) => serviceRank.get(taskServiceByScope.get(this.scopeTask(task)) ?? ""))
1187
1479
  .filter((value) => typeof value === "number");
1188
1480
  const storyTextRank = serviceRank.get(resolveEntityService(`${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`) ?? "");
1189
1481
  const rank = taskRanks.length > 0 ? Math.min(...taskRanks) : storyTextRank ?? Number.MAX_SAFE_INTEGER;
1190
- storyRankByLocalId.set(story.localId, rank);
1482
+ storyRankByScope.set(storyScope, rank);
1191
1483
  }
1192
1484
  const epicRankByLocalId = new Map();
1193
1485
  for (const epic of epics) {
1194
1486
  const epicStories = stories.filter((story) => story.epicLocalId === epic.localId);
1195
1487
  const storyRanks = epicStories
1196
- .map((story) => storyRankByLocalId.get(story.localId))
1488
+ .map((story) => storyRankByScope.get(this.scopeStory(story)))
1197
1489
  .filter((value) => typeof value === "number");
1198
1490
  const epicTextRank = serviceRank.get(resolveEntityService(`${epic.title}\n${epic.description ?? ""}`) ?? "");
1199
1491
  const rank = storyRanks.length > 0 ? Math.min(...storyRanks) : epicTextRank ?? Number.MAX_SAFE_INTEGER;
@@ -1228,8 +1520,8 @@ export class CreateTasksService {
1228
1520
  const bootstrapB = isBootstrap(`${b.title} ${b.description ?? ""}`);
1229
1521
  if (bootstrapA !== bootstrapB)
1230
1522
  return bootstrapA ? -1 : 1;
1231
- const rankA = storyRankByLocalId.get(a.localId) ?? Number.MAX_SAFE_INTEGER;
1232
- const rankB = storyRankByLocalId.get(b.localId) ?? Number.MAX_SAFE_INTEGER;
1523
+ const rankA = storyRankByScope.get(this.scopeStory(a)) ?? Number.MAX_SAFE_INTEGER;
1524
+ const rankB = storyRankByScope.get(this.scopeStory(b)) ?? Number.MAX_SAFE_INTEGER;
1233
1525
  if (rankA !== rankB)
1234
1526
  return rankA - rankB;
1235
1527
  const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
@@ -1241,31 +1533,31 @@ export class CreateTasksService {
1241
1533
  epicStories.forEach((story, index) => {
1242
1534
  story.priorityHint = index + 1;
1243
1535
  storiesOrdered.push(story);
1244
- const storyTasks = tasksByStory.get(story.localId) ?? [];
1245
- const orderedTasks = this.orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByLocalId);
1536
+ const storyTasks = tasksByStory.get(this.scopeStory(story)) ?? [];
1537
+ const orderedTasks = this.orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope);
1246
1538
  orderedTasks.forEach((task, taskIndex) => {
1247
1539
  task.priorityHint = taskIndex + 1;
1248
1540
  tasksOrdered.push(task);
1249
1541
  });
1250
1542
  });
1251
1543
  }
1252
- const orderedStoryIds = new Set(storiesOrdered.map((story) => story.localId));
1544
+ const orderedStoryScopes = new Set(storiesOrdered.map((story) => this.scopeStory(story)));
1253
1545
  for (const story of stories) {
1254
- if (orderedStoryIds.has(story.localId))
1546
+ if (orderedStoryScopes.has(this.scopeStory(story)))
1255
1547
  continue;
1256
1548
  storiesOrdered.push(story);
1257
1549
  }
1258
- const orderedTaskIds = new Set(tasksOrdered.map((task) => task.localId));
1550
+ const orderedTaskScopes = new Set(tasksOrdered.map((task) => this.scopeTask(task)));
1259
1551
  for (const task of tasks) {
1260
- if (orderedTaskIds.has(task.localId))
1552
+ if (orderedTaskScopes.has(this.scopeTask(task)))
1261
1553
  continue;
1262
1554
  tasksOrdered.push(task);
1263
1555
  }
1264
1556
  // Keep parent linkage intact even if malformed story references exist.
1265
1557
  for (const story of storiesOrdered) {
1266
- if (!storyByLocalId.has(story.localId))
1558
+ if (!storyByScope.has(this.scopeStory(story)))
1267
1559
  continue;
1268
- story.epicLocalId = storyByLocalId.get(story.localId)?.epicLocalId ?? story.epicLocalId;
1560
+ story.epicLocalId = storyByScope.get(this.scopeStory(story))?.epicLocalId ?? story.epicLocalId;
1269
1561
  }
1270
1562
  return { epics, stories: storiesOrdered, tasks: tasksOrdered };
1271
1563
  }
@@ -1395,9 +1687,8 @@ export class CreateTasksService {
1395
1687
  };
1396
1688
  }
1397
1689
  enforceStoryScopedDependencies(plan) {
1398
- const scopedLocalKey = (storyLocalId, localId) => `${storyLocalId}::${localId}`;
1399
1690
  const taskMap = new Map(plan.tasks.map((task) => [
1400
- scopedLocalKey(task.storyLocalId, task.localId),
1691
+ this.scopeTask(task),
1401
1692
  {
1402
1693
  ...task,
1403
1694
  dependsOnKeys: uniqueStrings((task.dependsOnKeys ?? []).filter(Boolean)),
@@ -1405,9 +1696,10 @@ export class CreateTasksService {
1405
1696
  ]));
1406
1697
  const tasksByStory = new Map();
1407
1698
  for (const task of taskMap.values()) {
1408
- const storyTasks = tasksByStory.get(task.storyLocalId) ?? [];
1699
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1700
+ const storyTasks = tasksByStory.get(storyScope) ?? [];
1409
1701
  storyTasks.push(task);
1410
- tasksByStory.set(task.storyLocalId, storyTasks);
1702
+ tasksByStory.set(storyScope, storyTasks);
1411
1703
  }
1412
1704
  for (const storyTasks of tasksByStory.values()) {
1413
1705
  const localIds = new Set(storyTasks.map((task) => task.localId));
@@ -1438,9 +1730,67 @@ export class CreateTasksService {
1438
1730
  }
1439
1731
  return {
1440
1732
  ...plan,
1441
- tasks: plan.tasks.map((task) => taskMap.get(scopedLocalKey(task.storyLocalId, task.localId)) ?? task),
1733
+ tasks: plan.tasks.map((task) => taskMap.get(this.scopeTask(task)) ?? task),
1442
1734
  };
1443
1735
  }
1736
+ validatePlanLocalIdentifiers(plan) {
1737
+ const errors = [];
1738
+ const epicIds = new Set();
1739
+ for (const epic of plan.epics) {
1740
+ if (!epic.localId || !epic.localId.trim()) {
1741
+ errors.push("epic has missing localId");
1742
+ continue;
1743
+ }
1744
+ if (epicIds.has(epic.localId)) {
1745
+ errors.push(`duplicate epic localId: ${epic.localId}`);
1746
+ continue;
1747
+ }
1748
+ epicIds.add(epic.localId);
1749
+ }
1750
+ const storyScopes = new Set();
1751
+ for (const story of plan.stories) {
1752
+ const scope = this.scopeStory(story);
1753
+ if (!epicIds.has(story.epicLocalId)) {
1754
+ errors.push(`story ${scope} references unknown epicLocalId ${story.epicLocalId}`);
1755
+ }
1756
+ if (storyScopes.has(scope)) {
1757
+ errors.push(`duplicate story scope: ${scope}`);
1758
+ continue;
1759
+ }
1760
+ storyScopes.add(scope);
1761
+ }
1762
+ const taskScopes = new Set();
1763
+ const storyTaskLocals = new Map();
1764
+ for (const task of plan.tasks) {
1765
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1766
+ const taskScope = this.scopeTask(task);
1767
+ if (!storyScopes.has(storyScope)) {
1768
+ errors.push(`task ${taskScope} references unknown story scope ${storyScope}`);
1769
+ }
1770
+ if (taskScopes.has(taskScope)) {
1771
+ errors.push(`duplicate task scope: ${taskScope}`);
1772
+ continue;
1773
+ }
1774
+ taskScopes.add(taskScope);
1775
+ const locals = storyTaskLocals.get(storyScope) ?? new Set();
1776
+ locals.add(task.localId);
1777
+ storyTaskLocals.set(storyScope, locals);
1778
+ }
1779
+ for (const task of plan.tasks) {
1780
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1781
+ const localIds = storyTaskLocals.get(storyScope) ?? new Set();
1782
+ for (const dep of task.dependsOnKeys ?? []) {
1783
+ if (!dep || dep === task.localId)
1784
+ continue;
1785
+ if (!localIds.has(dep)) {
1786
+ errors.push(`task ${this.scopeTask(task)} has dependency ${dep} that is outside story scope ${storyScope}`);
1787
+ }
1788
+ }
1789
+ }
1790
+ if (errors.length > 0) {
1791
+ throw new Error(`Invalid generated plan local identifiers:\n- ${errors.join("\n- ")}`);
1792
+ }
1793
+ }
1444
1794
  async buildQaPreflight() {
1445
1795
  const preflight = {
1446
1796
  scripts: {},
@@ -1541,6 +1891,64 @@ export class CreateTasksService {
1541
1891
  entrypoints: entrypoints.length ? entrypoints : undefined,
1542
1892
  };
1543
1893
  }
1894
+ isOpenApiDoc(doc) {
1895
+ const type = (doc.docType ?? "").toLowerCase();
1896
+ if (type.includes("openapi") || type.includes("swagger"))
1897
+ return true;
1898
+ const pathTitle = `${doc.path ?? ""} ${doc.title ?? ""}`.toLowerCase();
1899
+ return OPENAPI_LIKE_PATH_PATTERN.test(pathTitle);
1900
+ }
1901
+ buildOpenApiHintSummary(docs) {
1902
+ const lines = [];
1903
+ for (const doc of docs) {
1904
+ if (!this.isOpenApiDoc(doc))
1905
+ continue;
1906
+ const rawContent = doc.content && doc.content.trim().length > 0
1907
+ ? doc.content
1908
+ : (doc.segments ?? []).map((segment) => segment.content).join("\n\n");
1909
+ const parsed = parseStructuredDoc(rawContent);
1910
+ if (!parsed)
1911
+ continue;
1912
+ const paths = parsed.paths;
1913
+ if (!isPlainObject(paths))
1914
+ continue;
1915
+ for (const [apiPath, pathItem] of Object.entries(paths)) {
1916
+ if (!isPlainObject(pathItem))
1917
+ continue;
1918
+ for (const [method, operation] of Object.entries(pathItem)) {
1919
+ const normalizedMethod = method.toLowerCase();
1920
+ if (!["get", "post", "put", "patch", "delete", "options", "head", "trace"].includes(normalizedMethod)) {
1921
+ continue;
1922
+ }
1923
+ if (!isPlainObject(operation))
1924
+ continue;
1925
+ const hints = operation["x-mcoda-task-hints"];
1926
+ if (!isPlainObject(hints))
1927
+ continue;
1928
+ const service = typeof hints.service === "string" ? hints.service : "-";
1929
+ const capability = typeof hints.capability === "string" ? hints.capability : "-";
1930
+ const stage = typeof hints.stage === "string" ? hints.stage : "-";
1931
+ const complexity = typeof hints.complexity === "number" && Number.isFinite(hints.complexity)
1932
+ ? hints.complexity.toFixed(1)
1933
+ : "-";
1934
+ const dependsOn = Array.isArray(hints.depends_on_operations)
1935
+ ? hints.depends_on_operations.filter((entry) => typeof entry === "string").length
1936
+ : 0;
1937
+ const tests = isPlainObject(hints.test_requirements) ? hints.test_requirements : undefined;
1938
+ const countEntries = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string").length : 0;
1939
+ const unitCount = countEntries(tests?.unit);
1940
+ const componentCount = countEntries(tests?.component);
1941
+ const integrationCount = countEntries(tests?.integration);
1942
+ const apiCount = countEntries(tests?.api);
1943
+ lines.push(`- ${normalizedMethod.toUpperCase()} ${apiPath} :: service=${service}; capability=${capability}; stage=${stage}; complexity=${complexity}; deps=${dependsOn}; tests(u/c/i/a)=${unitCount}/${componentCount}/${integrationCount}/${apiCount}`);
1944
+ if (lines.length >= OPENAPI_HINT_OPERATIONS_LIMIT) {
1945
+ return lines.join("\n");
1946
+ }
1947
+ }
1948
+ }
1949
+ }
1950
+ return lines.join("\n");
1951
+ }
1544
1952
  buildDocContext(docs) {
1545
1953
  const warnings = [];
1546
1954
  const blocks = [];
@@ -1571,9 +1979,21 @@ export class CreateTasksService {
1571
1979
  if (budget <= 0)
1572
1980
  break;
1573
1981
  }
1982
+ const openApiHints = this.buildOpenApiHintSummary(sorted);
1983
+ if (openApiHints) {
1984
+ const hintBlock = ["[OPENAPI_HINTS]", openApiHints].join("\n");
1985
+ const hintCost = estimateTokens(hintBlock);
1986
+ if (budget - hintCost >= 0) {
1987
+ budget -= hintCost;
1988
+ blocks.push(hintBlock);
1989
+ }
1990
+ else {
1991
+ warnings.push("Context truncated due to token budget; skipped OpenAPI hint summary.");
1992
+ }
1993
+ }
1574
1994
  return { docSummary: blocks.join("\n\n") || "(no docs)", warnings };
1575
1995
  }
1576
- buildPrompt(projectKey, docs, options) {
1996
+ buildPrompt(projectKey, docs, projectBuildMethod, options) {
1577
1997
  const docSummary = docs.map((doc, idx) => describeDoc(doc, idx)).join("\n");
1578
1998
  const limits = [
1579
1999
  options.maxEpics ? `Limit epics to ${options.maxEpics}.` : "",
@@ -1594,6 +2014,8 @@ export class CreateTasksService {
1594
2014
  "- acceptanceCriteria must be an array of strings (5-10 items).",
1595
2015
  "- Prefer dependency-first sequencing: foundational codebase/service setup epics should precede dependent feature epics.",
1596
2016
  "- Keep output technology-agnostic and derived from docs; do not assume specific stacks unless docs state them.",
2017
+ "Project construction method to follow:",
2018
+ projectBuildMethod,
1597
2019
  limits || "Use reasonable scope without over-generating epics.",
1598
2020
  "Docs available:",
1599
2021
  docSummary || "- (no docs provided; propose sensible epics).",
@@ -1601,7 +2023,7 @@ export class CreateTasksService {
1601
2023
  return { prompt, docSummary };
1602
2024
  }
1603
2025
  fallbackPlan(projectKey, docs) {
1604
- const docRefs = docs.map((doc) => doc.id ?? doc.path ?? doc.title ?? "doc");
2026
+ const docRefs = docs.map((doc) => (doc.id ? `docdex:${doc.id}` : doc.path ?? doc.title ?? "doc"));
1605
2027
  return {
1606
2028
  epics: [
1607
2029
  {
@@ -1657,6 +2079,63 @@ export class CreateTasksService {
1657
2079
  ],
1658
2080
  };
1659
2081
  }
2082
+ materializePlanFromSeed(seed, options) {
2083
+ const epics = [];
2084
+ const stories = [];
2085
+ const tasks = [];
2086
+ const epicLimit = options.maxEpics ?? Number.MAX_SAFE_INTEGER;
2087
+ const storyLimit = options.maxStoriesPerEpic ?? Number.MAX_SAFE_INTEGER;
2088
+ const taskLimit = options.maxTasksPerStory ?? Number.MAX_SAFE_INTEGER;
2089
+ const seedEpics = Array.isArray(seed.epics) ? seed.epics.slice(0, epicLimit) : [];
2090
+ for (const [epicIndex, epic] of seedEpics.entries()) {
2091
+ const epicLocalId = typeof epic.localId === "string" && epic.localId.trim().length > 0 ? epic.localId : `e${epicIndex + 1}`;
2092
+ const planEpic = {
2093
+ ...epic,
2094
+ localId: epicLocalId,
2095
+ area: normalizeArea(epic.area),
2096
+ relatedDocs: normalizeRelatedDocs(epic.relatedDocs),
2097
+ acceptanceCriteria: Array.isArray(epic.acceptanceCriteria) ? epic.acceptanceCriteria : [],
2098
+ stories: [],
2099
+ };
2100
+ epics.push(planEpic);
2101
+ const epicStories = Array.isArray(epic.stories) ? epic.stories.slice(0, storyLimit) : [];
2102
+ for (const [storyIndex, story] of epicStories.entries()) {
2103
+ const storyLocalId = typeof story.localId === "string" && story.localId.trim().length > 0 ? story.localId : `us${storyIndex + 1}`;
2104
+ const planStory = {
2105
+ ...story,
2106
+ localId: storyLocalId,
2107
+ epicLocalId,
2108
+ relatedDocs: normalizeRelatedDocs(story.relatedDocs),
2109
+ acceptanceCriteria: Array.isArray(story.acceptanceCriteria) ? story.acceptanceCriteria : [],
2110
+ tasks: [],
2111
+ };
2112
+ stories.push(planStory);
2113
+ const storyTasks = Array.isArray(story.tasks) ? story.tasks.slice(0, taskLimit) : [];
2114
+ for (const [taskIndex, task] of storyTasks.entries()) {
2115
+ const localId = typeof task.localId === "string" && task.localId.trim().length > 0 ? task.localId : `t${taskIndex + 1}`;
2116
+ tasks.push({
2117
+ ...task,
2118
+ localId,
2119
+ storyLocalId,
2120
+ epicLocalId,
2121
+ title: task.title ?? "Task",
2122
+ type: normalizeTaskType(task.type) ?? "feature",
2123
+ description: task.description ?? "",
2124
+ estimatedStoryPoints: typeof task.estimatedStoryPoints === "number" ? task.estimatedStoryPoints : undefined,
2125
+ priorityHint: typeof task.priorityHint === "number" ? task.priorityHint : undefined,
2126
+ dependsOnKeys: normalizeStringArray(task.dependsOnKeys),
2127
+ relatedDocs: normalizeRelatedDocs(task.relatedDocs),
2128
+ unitTests: normalizeStringArray(task.unitTests),
2129
+ componentTests: normalizeStringArray(task.componentTests),
2130
+ integrationTests: normalizeStringArray(task.integrationTests),
2131
+ apiTests: normalizeStringArray(task.apiTests),
2132
+ qa: normalizeQaReadiness(task.qa),
2133
+ });
2134
+ }
2135
+ }
2136
+ }
2137
+ return { epics, stories, tasks };
2138
+ }
1660
2139
  async invokeAgentWithRetry(agent, prompt, action, stream, jobId, commandRunId, metadata) {
1661
2140
  const startedAt = Date.now();
1662
2141
  let output = "";
@@ -1774,7 +2253,7 @@ export class CreateTasksService {
1774
2253
  }))
1775
2254
  .filter((e) => e.title);
1776
2255
  }
1777
- async generateStoriesForEpic(agent, epic, docSummary, stream, jobId, commandRunId) {
2256
+ async generateStoriesForEpic(agent, epic, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
1778
2257
  const prompt = [
1779
2258
  `Generate user stories for epic "${epic.title}".`,
1780
2259
  "Use the User Story template: User Story; Context; Preconditions; Main Flow; Alternative/Error Flows; UX/UI; Data & Integrations; Acceptance Criteria; NFR; Related Docs.",
@@ -1784,8 +2263,11 @@ export class CreateTasksService {
1784
2263
  "- No tasks in this step.",
1785
2264
  "- acceptanceCriteria must be an array of strings.",
1786
2265
  "- Use docdex handles when citing docs.",
2266
+ "- Keep story sequencing aligned with the project construction method.",
1787
2267
  `Epic context (key=${epic.key ?? epic.localId ?? "TBD"}):`,
1788
2268
  epic.description ?? "(no description provided)",
2269
+ "Project construction method:",
2270
+ projectBuildMethod,
1789
2271
  `Docs: ${docSummary || "none"}`,
1790
2272
  ].join("\n\n");
1791
2273
  const { output } = await this.invokeAgentWithRetry(agent, prompt, "stories", stream, jobId, commandRunId, {
@@ -1808,7 +2290,7 @@ export class CreateTasksService {
1808
2290
  }))
1809
2291
  .filter((s) => s.title);
1810
2292
  }
1811
- async generateTasksForStory(agent, epic, story, docSummary, stream, jobId, commandRunId) {
2293
+ async generateTasksForStory(agent, epic, story, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
1812
2294
  const parseTestList = (value) => {
1813
2295
  if (!Array.isArray(value))
1814
2296
  return [];
@@ -1826,17 +2308,21 @@ export class CreateTasksService {
1826
2308
  "- Each task must include localId, title, description, type, estimatedStoryPoints, priorityHint.",
1827
2309
  "- Include test arrays: unitTests, componentTests, integrationTests, apiTests. Use [] when not applicable.",
1828
2310
  "- Only include tests that are relevant to the task's scope.",
1829
- "- If the task involves code or configuration changes, include at least one relevant test; do not leave all test arrays empty unless it's purely documentation or research.",
2311
+ "- Prefer including task-relevant tests when they are concrete and actionable; do not invent generic placeholders.",
1830
2312
  "- When known, include qa object with profiles_expected/requires/entrypoints/data_setup to guide QA.",
1831
2313
  "- Do not hardcode ports. For QA entrypoints, use http://localhost:<PORT> placeholders or omit base_url when unknown.",
1832
2314
  "- dependsOnKeys must reference localIds in this story.",
1833
2315
  "- Start from prerequisite codebase setup: add structure/bootstrap tasks before feature tasks when missing.",
1834
2316
  "- Keep dependencies strictly inside this story; never reference tasks from other stories/epics.",
1835
- "- Order tasks from foundational prerequisites to dependents (infrastructure -> backend/services -> frontend/consumers where applicable).",
2317
+ "- Order tasks from foundational prerequisites to dependents based on documented dependency direction and startup constraints.",
1836
2318
  "- Use docdex handles when citing docs.",
2319
+ "- If OPENAPI_HINTS are present in Docs, align tasks with hinted service/capability/stage/test_requirements.",
2320
+ "- Follow the project construction method and startup-wave order from SDS when available.",
1837
2321
  `Story context (key=${story.key ?? story.localId ?? "TBD"}):`,
1838
2322
  story.description ?? story.userStory ?? "",
1839
2323
  `Acceptance criteria: ${(story.acceptanceCriteria ?? []).join("; ")}`,
2324
+ "Project construction method:",
2325
+ projectBuildMethod,
1840
2326
  `Docs: ${docSummary || "none"}`,
1841
2327
  ].join("\n\n");
1842
2328
  const { output } = await this.invokeAgentWithRetry(agent, prompt, "tasks", stream, jobId, commandRunId, {
@@ -1853,13 +2339,8 @@ export class CreateTasksService {
1853
2339
  const componentTests = parseTestList(task.componentTests);
1854
2340
  const integrationTests = parseTestList(task.integrationTests);
1855
2341
  const apiTests = parseTestList(task.apiTests);
1856
- const hasTests = unitTests.length || componentTests.length || integrationTests.length || apiTests.length;
1857
2342
  const title = task.title ?? "Task";
1858
2343
  const description = task.description ?? "";
1859
- const docOnly = /doc|documentation|readme|pdr|sds|openapi|spec/.test(`${title} ${description}`.toLowerCase());
1860
- if (!hasTests && !docOnly) {
1861
- unitTests.push(`Add tests for ${title} (unit/component/integration/api as applicable)`);
1862
- }
1863
2344
  const qa = normalizeQaReadiness(task.qa);
1864
2345
  return {
1865
2346
  localId: task.localId ?? `t${idx + 1}`,
@@ -1887,7 +2368,7 @@ export class CreateTasksService {
1887
2368
  const planStories = [];
1888
2369
  const planTasks = [];
1889
2370
  for (const epic of planEpics) {
1890
- const stories = await this.generateStoriesForEpic(agent, { ...epic }, docSummary, options.agentStream, options.jobId, options.commandRunId);
2371
+ const stories = await this.generateStoriesForEpic(agent, { ...epic }, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
1891
2372
  const limitedStories = stories.slice(0, options.maxStoriesPerEpic ?? stories.length);
1892
2373
  limitedStories.forEach((story, idx) => {
1893
2374
  planStories.push({
@@ -1898,7 +2379,7 @@ export class CreateTasksService {
1898
2379
  });
1899
2380
  }
1900
2381
  for (const story of planStories) {
1901
- const tasks = await this.generateTasksForStory(agent, { key: story.epicLocalId, title: story.title }, story, docSummary, options.agentStream, options.jobId, options.commandRunId);
2382
+ const tasks = await this.generateTasksForStory(agent, { key: story.epicLocalId, title: story.title }, story, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
1902
2383
  const limitedTasks = tasks.slice(0, options.maxTasksPerStory ?? tasks.length);
1903
2384
  limitedTasks.forEach((task, idx) => {
1904
2385
  planTasks.push({
@@ -1984,13 +2465,14 @@ export class CreateTasksService {
1984
2465
  for (const story of storyMeta) {
1985
2466
  const storyId = storyIdByKey.get(story.storyKey);
1986
2467
  const existingTaskKeys = storyId ? await this.workspaceRepo.listTaskKeys(storyId) : [];
1987
- const tasks = plan.tasks.filter((t) => t.storyLocalId === story.node.localId);
2468
+ const tasks = plan.tasks.filter((t) => t.storyLocalId === story.node.localId && t.epicLocalId === story.node.epicLocalId);
1988
2469
  const taskKeyGen = createTaskKeyGenerator(story.storyKey, existingTaskKeys);
1989
2470
  for (const task of tasks) {
1990
2471
  const key = taskKeyGen();
1991
2472
  const localId = task.localId ?? key;
1992
2473
  taskDetails.push({
1993
2474
  localId,
2475
+ epicLocalId: story.node.epicLocalId,
1994
2476
  key,
1995
2477
  storyLocalId: story.node.localId,
1996
2478
  storyKey: story.storyKey,
@@ -1999,8 +2481,8 @@ export class CreateTasksService {
1999
2481
  });
2000
2482
  }
2001
2483
  }
2002
- const scopedLocalKey = (storyLocalId, localId) => `${storyLocalId}::${localId}`;
2003
- const localToKey = new Map(taskDetails.map((t) => [scopedLocalKey(t.storyLocalId, t.localId), t.key]));
2484
+ const scopedLocalKey = (epicLocalId, storyLocalId, localId) => this.taskScopeKey(epicLocalId, storyLocalId, localId);
2485
+ const localToKey = new Map(taskDetails.map((t) => [scopedLocalKey(t.epicLocalId, t.storyLocalId, t.localId), t.key]));
2004
2486
  const taskInserts = [];
2005
2487
  const testCommandBuilder = new QaTestCommandBuilder(this.workspace.workspaceRoot);
2006
2488
  for (const task of taskDetails) {
@@ -2060,7 +2542,7 @@ export class CreateTasksService {
2060
2542
  blockers: qaBlockers.length ? qaBlockers : undefined,
2061
2543
  };
2062
2544
  const depSlugs = (task.plan.dependsOnKeys ?? [])
2063
- .map((dep) => localToKey.get(scopedLocalKey(task.storyLocalId, dep)))
2545
+ .map((dep) => localToKey.get(scopedLocalKey(task.plan.epicLocalId, task.storyLocalId, dep)))
2064
2546
  .filter((value) => Boolean(value));
2065
2547
  const metadata = {
2066
2548
  doc_links: task.plan.relatedDocs ?? [],
@@ -2097,17 +2579,17 @@ export class CreateTasksService {
2097
2579
  for (const detail of taskDetails) {
2098
2580
  const row = taskRows.find((t) => t.key === detail.key);
2099
2581
  if (row) {
2100
- taskByLocal.set(scopedLocalKey(detail.storyLocalId, detail.localId), row);
2582
+ taskByLocal.set(scopedLocalKey(detail.epicLocalId, detail.storyLocalId, detail.localId), row);
2101
2583
  }
2102
2584
  }
2103
2585
  const depKeys = new Set();
2104
2586
  const dependencies = [];
2105
2587
  for (const detail of taskDetails) {
2106
- const current = taskByLocal.get(scopedLocalKey(detail.storyLocalId, detail.localId));
2588
+ const current = taskByLocal.get(scopedLocalKey(detail.epicLocalId, detail.storyLocalId, detail.localId));
2107
2589
  if (!current)
2108
2590
  continue;
2109
2591
  for (const dep of detail.plan.dependsOnKeys ?? []) {
2110
- const target = taskByLocal.get(scopedLocalKey(detail.storyLocalId, dep));
2592
+ const target = taskByLocal.get(scopedLocalKey(detail.plan.epicLocalId, detail.storyLocalId, dep));
2111
2593
  if (!target || target.id === current.id)
2112
2594
  continue;
2113
2595
  const depKey = `${current.id}|${target.id}|blocks`;
@@ -2181,48 +2663,74 @@ export class CreateTasksService {
2181
2663
  });
2182
2664
  const docs = await this.prepareDocs(options.inputs);
2183
2665
  const { docSummary, warnings: docWarnings } = this.buildDocContext(docs);
2184
- const { prompt } = this.buildPrompt(options.projectKey, docs, options);
2666
+ const discoveryGraph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
2667
+ const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
2668
+ const { prompt } = this.buildPrompt(options.projectKey, docs, projectBuildMethod, options);
2185
2669
  const qaPreflight = await this.buildQaPreflight();
2186
2670
  const qaOverrides = this.buildQaOverrides(options);
2187
2671
  await this.jobService.writeCheckpoint(job.id, {
2188
2672
  stage: "docs_indexed",
2189
2673
  timestamp: new Date().toISOString(),
2190
- details: { count: docs.length, warnings: docWarnings },
2674
+ details: { count: docs.length, warnings: docWarnings, startupWaves: discoveryGraph.startupWaves.slice(0, 8) },
2191
2675
  });
2192
2676
  await this.jobService.writeCheckpoint(job.id, {
2193
2677
  stage: "qa_preflight",
2194
2678
  timestamp: new Date().toISOString(),
2195
2679
  details: qaPreflight,
2196
2680
  });
2197
- const agent = await this.resolveAgent(options.agentName);
2198
- const { output: epicOutput } = await this.invokeAgentWithRetry(agent, prompt, "epics", agentStream, job.id, commandRun.id, { docWarnings });
2199
- const epics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
2200
- await this.jobService.writeCheckpoint(job.id, {
2201
- stage: "epics_generated",
2202
- timestamp: new Date().toISOString(),
2203
- details: { epics: epics.length },
2204
- });
2205
- let plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2206
- agentStream,
2207
- jobId: job.id,
2208
- commandRunId: commandRun.id,
2209
- maxStoriesPerEpic: options.maxStoriesPerEpic,
2210
- maxTasksPerStory: options.maxTasksPerStory,
2211
- });
2681
+ let agent;
2682
+ let planSource = "agent";
2683
+ let fallbackReason;
2684
+ let plan;
2685
+ try {
2686
+ agent = await this.resolveAgent(options.agentName);
2687
+ const { output: epicOutput } = await this.invokeAgentWithRetry(agent, prompt, "epics", agentStream, job.id, commandRun.id, { docWarnings });
2688
+ const epics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
2689
+ await this.jobService.writeCheckpoint(job.id, {
2690
+ stage: "epics_generated",
2691
+ timestamp: new Date().toISOString(),
2692
+ details: { epics: epics.length, source: "agent" },
2693
+ });
2694
+ plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2695
+ agentStream,
2696
+ jobId: job.id,
2697
+ commandRunId: commandRun.id,
2698
+ maxStoriesPerEpic: options.maxStoriesPerEpic,
2699
+ maxTasksPerStory: options.maxTasksPerStory,
2700
+ projectBuildMethod,
2701
+ });
2702
+ }
2703
+ catch (error) {
2704
+ fallbackReason = error.message ?? String(error);
2705
+ planSource = "fallback";
2706
+ await this.jobService.appendLog(job.id, `Agent planning failed, using deterministic fallback plan: ${fallbackReason}\n`);
2707
+ plan = this.materializePlanFromSeed(this.fallbackPlan(options.projectKey, docs), {
2708
+ maxEpics: options.maxEpics,
2709
+ maxStoriesPerEpic: options.maxStoriesPerEpic,
2710
+ maxTasksPerStory: options.maxTasksPerStory,
2711
+ });
2712
+ await this.jobService.writeCheckpoint(job.id, {
2713
+ stage: "epics_generated",
2714
+ timestamp: new Date().toISOString(),
2715
+ details: { epics: plan.epics.length, source: planSource, reason: fallbackReason },
2716
+ });
2717
+ }
2212
2718
  plan = this.enforceStoryScopedDependencies(plan);
2213
2719
  plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
2214
2720
  plan = this.enforceStoryScopedDependencies(plan);
2721
+ this.validatePlanLocalIdentifiers(plan);
2215
2722
  plan = this.applyServiceDependencySequencing(plan, docs);
2216
2723
  plan = this.enforceStoryScopedDependencies(plan);
2724
+ this.validatePlanLocalIdentifiers(plan);
2217
2725
  await this.jobService.writeCheckpoint(job.id, {
2218
2726
  stage: "stories_generated",
2219
2727
  timestamp: new Date().toISOString(),
2220
- details: { stories: plan.stories.length },
2728
+ details: { stories: plan.stories.length, source: planSource, fallbackReason },
2221
2729
  });
2222
2730
  await this.jobService.writeCheckpoint(job.id, {
2223
2731
  stage: "tasks_generated",
2224
2732
  timestamp: new Date().toISOString(),
2225
- details: { tasks: plan.tasks.length },
2733
+ details: { tasks: plan.tasks.length, source: planSource, fallbackReason },
2226
2734
  });
2227
2735
  const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary);
2228
2736
  await this.jobService.writeCheckpoint(job.id, {
@@ -2245,10 +2753,12 @@ export class CreateTasksService {
2245
2753
  dependenciesCreated: dependencyRows.length,
2246
2754
  docs: docSummary,
2247
2755
  planFolder: folder,
2756
+ planSource,
2757
+ fallbackReason,
2248
2758
  },
2249
2759
  });
2250
2760
  await this.jobService.finishCommandRun(commandRun.id, "succeeded");
2251
- if (options.rateAgents) {
2761
+ if (options.rateAgents && planSource === "agent" && agent) {
2252
2762
  try {
2253
2763
  const ratingService = this.ensureRatingService();
2254
2764
  await ratingService.rate({
@@ -2343,6 +2853,7 @@ export class CreateTasksService {
2343
2853
  plan = this.enforceStoryScopedDependencies(plan);
2344
2854
  plan = this.applyServiceDependencySequencing(plan, []);
2345
2855
  plan = this.enforceStoryScopedDependencies(plan);
2856
+ this.validatePlanLocalIdentifiers(plan);
2346
2857
  const loadRefinePlans = async () => {
2347
2858
  const candidates = [];
2348
2859
  if (options.refinePlanPath)