@mcoda/core 0.1.19 → 0.1.21

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 (55) 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/execution/QaTasksService.d.ts +6 -0
  33. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  34. package/dist/services/execution/QaTasksService.js +278 -95
  35. package/dist/services/execution/TaskSelectionService.d.ts +3 -0
  36. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  37. package/dist/services/execution/TaskSelectionService.js +33 -0
  38. package/dist/services/execution/WorkOnTasksService.d.ts +5 -1
  39. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  40. package/dist/services/execution/WorkOnTasksService.js +178 -34
  41. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  42. package/dist/services/openapi/OpenApiService.js +43 -4
  43. package/dist/services/planning/CreateTasksService.d.ts +12 -0
  44. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  45. package/dist/services/planning/CreateTasksService.js +585 -48
  46. package/dist/services/planning/RefineTasksService.d.ts +1 -0
  47. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  48. package/dist/services/planning/RefineTasksService.js +88 -2
  49. package/dist/services/review/CodeReviewService.d.ts +6 -0
  50. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  51. package/dist/services/review/CodeReviewService.js +260 -41
  52. package/dist/services/shared/ProjectGuidance.d.ts +18 -2
  53. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  54. package/dist/services/shared/ProjectGuidance.js +535 -34
  55. 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
  {
@@ -610,6 +651,7 @@ export class CreateTasksService {
610
651
  try {
611
652
  await ordering.orderTasks({
612
653
  projectKey,
654
+ apply: true,
613
655
  });
614
656
  }
615
657
  finally {
@@ -636,7 +678,68 @@ export class CreateTasksService {
636
678
  return this.ratingService;
637
679
  }
638
680
  async prepareDocs(inputs) {
639
- 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) {
640
743
  if (resolvedInputs.length === 0)
641
744
  return [];
642
745
  const documents = [];
@@ -690,19 +793,41 @@ export class CreateTasksService {
690
793
  path.join(this.workspace.workspaceRoot, "openapi.json"),
691
794
  ];
692
795
  const existing = [];
796
+ const existingSet = new Set();
797
+ const existingDirectories = [];
693
798
  for (const candidate of candidates) {
694
799
  try {
695
800
  const stat = await fs.stat(candidate);
696
- if (stat.isFile() || stat.isDirectory())
697
- 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);
698
810
  }
699
811
  catch {
700
812
  // Ignore missing candidates; fall back to empty inputs.
701
813
  }
702
814
  }
703
- if (existing.length > 0)
704
- return existing;
705
- 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];
706
831
  }
707
832
  async walkDocCandidates(currentDir, depth, collector) {
708
833
  if (depth > DOC_SCAN_MAX_DEPTH)
@@ -797,11 +922,14 @@ export class CreateTasksService {
797
922
  return undefined;
798
923
  if (/[\u0000-\u001f]/.test(normalized))
799
924
  return undefined;
925
+ const hadTrailingSlash = /\/$/.test(normalized);
800
926
  const parts = normalized.split("/").filter(Boolean);
801
- if (parts.length < 2)
927
+ if (parts.length < 2 && !(hadTrailingSlash && parts.length === 1))
802
928
  return undefined;
803
929
  if (parts.some((part) => part === "." || part === ".."))
804
930
  return undefined;
931
+ if (parts.length === 1 && !TOP_LEVEL_STRUCTURE_PATTERN.test(parts[0]))
932
+ return undefined;
805
933
  if (DOC_SCAN_IGNORE_DIRS.has(parts[0].toLowerCase()))
806
934
  return undefined;
807
935
  return parts.join("/");
@@ -981,7 +1109,97 @@ export class CreateTasksService {
981
1109
  }
982
1110
  return statements;
983
1111
  }
984
- 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()) {
985
1203
  const nodes = Array.from(new Set(services));
986
1204
  const indegree = new Map();
987
1205
  const adjacency = new Map();
@@ -1010,6 +1228,10 @@ export class CreateTasksService {
1010
1228
  }
1011
1229
  }
1012
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;
1013
1235
  const dependedByA = dependedBy.get(a) ?? 0;
1014
1236
  const dependedByB = dependedBy.get(b) ?? 0;
1015
1237
  if (dependedByA !== dependedByB)
@@ -1060,6 +1282,10 @@ export class CreateTasksService {
1060
1282
  for (const token of [...structureTargets.directories, ...structureTargets.files]) {
1061
1283
  register(this.deriveServiceFromPathToken(token));
1062
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]);
1063
1289
  for (const mention of this.extractServiceMentionsFromText(docsText))
1064
1290
  register(mention);
1065
1291
  for (const mention of this.extractServiceMentionsFromText(planText))
@@ -1077,8 +1303,55 @@ export class CreateTasksService {
1077
1303
  dependencies.set(dependent, next);
1078
1304
  }
1079
1305
  }
1080
- const services = this.sortServicesByDependency(Array.from(aliases.keys()), dependencies);
1081
- 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
+ };
1316
+ }
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");
1082
1355
  }
1083
1356
  orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope) {
1084
1357
  const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
@@ -1144,7 +1417,12 @@ export class CreateTasksService {
1144
1417
  const graph = this.buildServiceDependencyGraph(plan, docs);
1145
1418
  if (!graph.services.length)
1146
1419
  return plan;
1147
- 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
+ }));
1148
1426
  const resolveEntityService = (text) => this.resolveServiceMentionFromPhrase(text, graph.aliases);
1149
1427
  const epics = plan.epics.map((epic) => ({ ...epic }));
1150
1428
  const stories = plan.stories.map((story) => ({ ...story }));
@@ -1613,6 +1891,64 @@ export class CreateTasksService {
1613
1891
  entrypoints: entrypoints.length ? entrypoints : undefined,
1614
1892
  };
1615
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
+ }
1616
1952
  buildDocContext(docs) {
1617
1953
  const warnings = [];
1618
1954
  const blocks = [];
@@ -1643,9 +1979,21 @@ export class CreateTasksService {
1643
1979
  if (budget <= 0)
1644
1980
  break;
1645
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
+ }
1646
1994
  return { docSummary: blocks.join("\n\n") || "(no docs)", warnings };
1647
1995
  }
1648
- buildPrompt(projectKey, docs, options) {
1996
+ buildPrompt(projectKey, docs, projectBuildMethod, options) {
1649
1997
  const docSummary = docs.map((doc, idx) => describeDoc(doc, idx)).join("\n");
1650
1998
  const limits = [
1651
1999
  options.maxEpics ? `Limit epics to ${options.maxEpics}.` : "",
@@ -1666,6 +2014,8 @@ export class CreateTasksService {
1666
2014
  "- acceptanceCriteria must be an array of strings (5-10 items).",
1667
2015
  "- Prefer dependency-first sequencing: foundational codebase/service setup epics should precede dependent feature epics.",
1668
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,
1669
2019
  limits || "Use reasonable scope without over-generating epics.",
1670
2020
  "Docs available:",
1671
2021
  docSummary || "- (no docs provided; propose sensible epics).",
@@ -1673,7 +2023,7 @@ export class CreateTasksService {
1673
2023
  return { prompt, docSummary };
1674
2024
  }
1675
2025
  fallbackPlan(projectKey, docs) {
1676
- 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"));
1677
2027
  return {
1678
2028
  epics: [
1679
2029
  {
@@ -1729,6 +2079,63 @@ export class CreateTasksService {
1729
2079
  ],
1730
2080
  };
1731
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
+ }
1732
2139
  async invokeAgentWithRetry(agent, prompt, action, stream, jobId, commandRunId, metadata) {
1733
2140
  const startedAt = Date.now();
1734
2141
  let output = "";
@@ -1846,7 +2253,7 @@ export class CreateTasksService {
1846
2253
  }))
1847
2254
  .filter((e) => e.title);
1848
2255
  }
1849
- async generateStoriesForEpic(agent, epic, docSummary, stream, jobId, commandRunId) {
2256
+ async generateStoriesForEpic(agent, epic, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
1850
2257
  const prompt = [
1851
2258
  `Generate user stories for epic "${epic.title}".`,
1852
2259
  "Use the User Story template: User Story; Context; Preconditions; Main Flow; Alternative/Error Flows; UX/UI; Data & Integrations; Acceptance Criteria; NFR; Related Docs.",
@@ -1856,8 +2263,11 @@ export class CreateTasksService {
1856
2263
  "- No tasks in this step.",
1857
2264
  "- acceptanceCriteria must be an array of strings.",
1858
2265
  "- Use docdex handles when citing docs.",
2266
+ "- Keep story sequencing aligned with the project construction method.",
1859
2267
  `Epic context (key=${epic.key ?? epic.localId ?? "TBD"}):`,
1860
2268
  epic.description ?? "(no description provided)",
2269
+ "Project construction method:",
2270
+ projectBuildMethod,
1861
2271
  `Docs: ${docSummary || "none"}`,
1862
2272
  ].join("\n\n");
1863
2273
  const { output } = await this.invokeAgentWithRetry(agent, prompt, "stories", stream, jobId, commandRunId, {
@@ -1880,7 +2290,7 @@ export class CreateTasksService {
1880
2290
  }))
1881
2291
  .filter((s) => s.title);
1882
2292
  }
1883
- async generateTasksForStory(agent, epic, story, docSummary, stream, jobId, commandRunId) {
2293
+ async generateTasksForStory(agent, epic, story, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
1884
2294
  const parseTestList = (value) => {
1885
2295
  if (!Array.isArray(value))
1886
2296
  return [];
@@ -1898,17 +2308,21 @@ export class CreateTasksService {
1898
2308
  "- Each task must include localId, title, description, type, estimatedStoryPoints, priorityHint.",
1899
2309
  "- Include test arrays: unitTests, componentTests, integrationTests, apiTests. Use [] when not applicable.",
1900
2310
  "- Only include tests that are relevant to the task's scope.",
1901
- "- 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.",
1902
2312
  "- When known, include qa object with profiles_expected/requires/entrypoints/data_setup to guide QA.",
1903
2313
  "- Do not hardcode ports. For QA entrypoints, use http://localhost:<PORT> placeholders or omit base_url when unknown.",
1904
2314
  "- dependsOnKeys must reference localIds in this story.",
1905
2315
  "- Start from prerequisite codebase setup: add structure/bootstrap tasks before feature tasks when missing.",
1906
2316
  "- Keep dependencies strictly inside this story; never reference tasks from other stories/epics.",
1907
- "- 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.",
1908
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.",
1909
2321
  `Story context (key=${story.key ?? story.localId ?? "TBD"}):`,
1910
2322
  story.description ?? story.userStory ?? "",
1911
2323
  `Acceptance criteria: ${(story.acceptanceCriteria ?? []).join("; ")}`,
2324
+ "Project construction method:",
2325
+ projectBuildMethod,
1912
2326
  `Docs: ${docSummary || "none"}`,
1913
2327
  ].join("\n\n");
1914
2328
  const { output } = await this.invokeAgentWithRetry(agent, prompt, "tasks", stream, jobId, commandRunId, {
@@ -1925,13 +2339,8 @@ export class CreateTasksService {
1925
2339
  const componentTests = parseTestList(task.componentTests);
1926
2340
  const integrationTests = parseTestList(task.integrationTests);
1927
2341
  const apiTests = parseTestList(task.apiTests);
1928
- const hasTests = unitTests.length || componentTests.length || integrationTests.length || apiTests.length;
1929
2342
  const title = task.title ?? "Task";
1930
2343
  const description = task.description ?? "";
1931
- const docOnly = /doc|documentation|readme|pdr|sds|openapi|spec/.test(`${title} ${description}`.toLowerCase());
1932
- if (!hasTests && !docOnly) {
1933
- unitTests.push(`Add tests for ${title} (unit/component/integration/api as applicable)`);
1934
- }
1935
2344
  const qa = normalizeQaReadiness(task.qa);
1936
2345
  return {
1937
2346
  localId: task.localId ?? `t${idx + 1}`,
@@ -1951,6 +2360,71 @@ export class CreateTasksService {
1951
2360
  })
1952
2361
  .filter((t) => t.title);
1953
2362
  }
2363
+ buildFallbackStoryForEpic(epic) {
2364
+ const criteria = epic.acceptanceCriteria?.filter(Boolean) ?? [];
2365
+ return {
2366
+ localId: "us-fallback-1",
2367
+ title: `Deliver ${epic.title}`,
2368
+ userStory: `As a delivery team, we need an executable implementation story for ${epic.title}.`,
2369
+ description: [
2370
+ `Deterministic fallback story generated because model output for epic "${epic.title}" could not be parsed reliably.`,
2371
+ "Use SDS and related docs to decompose this story into concrete implementation tasks.",
2372
+ ].join("\n"),
2373
+ acceptanceCriteria: criteria.length > 0
2374
+ ? criteria
2375
+ : [
2376
+ "Story has actionable implementation tasks.",
2377
+ "Dependencies are explicit and story-scoped.",
2378
+ "Tasks are ready for execution.",
2379
+ ],
2380
+ relatedDocs: epic.relatedDocs ?? [],
2381
+ priorityHint: 1,
2382
+ tasks: [],
2383
+ };
2384
+ }
2385
+ buildFallbackTasksForStory(story) {
2386
+ const criteriaLines = (story.acceptanceCriteria ?? [])
2387
+ .slice(0, 6)
2388
+ .map((criterion) => `- ${criterion}`)
2389
+ .join("\n");
2390
+ return [
2391
+ {
2392
+ localId: "t-fallback-1",
2393
+ title: `Fallback planning for ${story.title}`,
2394
+ type: "chore",
2395
+ description: [
2396
+ `Draft a concrete implementation plan for story "${story.title}" using SDS/OpenAPI context.`,
2397
+ "List exact files/modules to touch and implementation order.",
2398
+ criteriaLines ? `Acceptance criteria to satisfy:\n${criteriaLines}` : "Acceptance criteria: use story definition.",
2399
+ ].join("\n"),
2400
+ estimatedStoryPoints: 2,
2401
+ priorityHint: 1,
2402
+ dependsOnKeys: [],
2403
+ relatedDocs: story.relatedDocs ?? [],
2404
+ unitTests: [],
2405
+ componentTests: [],
2406
+ integrationTests: [],
2407
+ apiTests: [],
2408
+ },
2409
+ {
2410
+ localId: "t-fallback-2",
2411
+ title: `Fallback implementation for ${story.title}`,
2412
+ type: "feature",
2413
+ description: [
2414
+ `Implement story "${story.title}" according to the fallback planning task.`,
2415
+ "Ensure done criteria and test requirements are explicitly documented for execution.",
2416
+ ].join("\n"),
2417
+ estimatedStoryPoints: 3,
2418
+ priorityHint: 2,
2419
+ dependsOnKeys: ["t-fallback-1"],
2420
+ relatedDocs: story.relatedDocs ?? [],
2421
+ unitTests: [],
2422
+ componentTests: [],
2423
+ integrationTests: [],
2424
+ apiTests: [],
2425
+ },
2426
+ ];
2427
+ }
1954
2428
  async generatePlanFromAgent(epics, agent, docSummary, options) {
1955
2429
  const planEpics = epics.map((epic, idx) => ({
1956
2430
  ...epic,
@@ -1958,20 +2432,56 @@ export class CreateTasksService {
1958
2432
  }));
1959
2433
  const planStories = [];
1960
2434
  const planTasks = [];
2435
+ const fallbackStoryScopes = new Set();
1961
2436
  for (const epic of planEpics) {
1962
- const stories = await this.generateStoriesForEpic(agent, { ...epic }, docSummary, options.agentStream, options.jobId, options.commandRunId);
1963
- const limitedStories = stories.slice(0, options.maxStoriesPerEpic ?? stories.length);
2437
+ let stories = [];
2438
+ let usedFallbackStories = false;
2439
+ try {
2440
+ stories = await this.generateStoriesForEpic(agent, { ...epic }, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
2441
+ }
2442
+ catch (error) {
2443
+ usedFallbackStories = true;
2444
+ await this.jobService.appendLog(options.jobId, `Story generation failed for epic "${epic.title}". Using deterministic fallback story. Reason: ${error.message ?? String(error)}\n`);
2445
+ stories = [this.buildFallbackStoryForEpic(epic)];
2446
+ }
2447
+ let limitedStories = stories.slice(0, options.maxStoriesPerEpic ?? stories.length);
2448
+ if (limitedStories.length === 0) {
2449
+ usedFallbackStories = true;
2450
+ await this.jobService.appendLog(options.jobId, `Story generation returned no stories for epic "${epic.title}". Using deterministic fallback story.\n`);
2451
+ limitedStories = [this.buildFallbackStoryForEpic(epic)];
2452
+ }
1964
2453
  limitedStories.forEach((story, idx) => {
1965
- planStories.push({
2454
+ const planStory = {
1966
2455
  ...story,
1967
2456
  localId: story.localId ?? `us${idx + 1}`,
1968
2457
  epicLocalId: epic.localId,
1969
- });
2458
+ };
2459
+ planStories.push(planStory);
2460
+ if (usedFallbackStories) {
2461
+ fallbackStoryScopes.add(this.storyScopeKey(planStory.epicLocalId, planStory.localId));
2462
+ }
1970
2463
  });
1971
2464
  }
1972
2465
  for (const story of planStories) {
1973
- const tasks = await this.generateTasksForStory(agent, { key: story.epicLocalId, title: story.title }, story, docSummary, options.agentStream, options.jobId, options.commandRunId);
1974
- const limitedTasks = tasks.slice(0, options.maxTasksPerStory ?? tasks.length);
2466
+ const storyScope = this.storyScopeKey(story.epicLocalId, story.localId);
2467
+ let tasks = [];
2468
+ if (fallbackStoryScopes.has(storyScope)) {
2469
+ tasks = this.buildFallbackTasksForStory(story);
2470
+ }
2471
+ else {
2472
+ try {
2473
+ tasks = await this.generateTasksForStory(agent, { key: story.epicLocalId, title: story.title }, story, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
2474
+ }
2475
+ catch (error) {
2476
+ await this.jobService.appendLog(options.jobId, `Task generation failed for story "${story.title}" (${storyScope}). Using deterministic fallback tasks. Reason: ${error.message ?? String(error)}\n`);
2477
+ tasks = this.buildFallbackTasksForStory(story);
2478
+ }
2479
+ }
2480
+ let limitedTasks = tasks.slice(0, options.maxTasksPerStory ?? tasks.length);
2481
+ if (limitedTasks.length === 0) {
2482
+ await this.jobService.appendLog(options.jobId, `Task generation returned no tasks for story "${story.title}" (${storyScope}). Using deterministic fallback tasks.\n`);
2483
+ limitedTasks = this.buildFallbackTasksForStory(story).slice(0, options.maxTasksPerStory ?? Number.MAX_SAFE_INTEGER);
2484
+ }
1975
2485
  limitedTasks.forEach((task, idx) => {
1976
2486
  planTasks.push({
1977
2487
  ...task,
@@ -2254,49 +2764,74 @@ export class CreateTasksService {
2254
2764
  });
2255
2765
  const docs = await this.prepareDocs(options.inputs);
2256
2766
  const { docSummary, warnings: docWarnings } = this.buildDocContext(docs);
2257
- const { prompt } = this.buildPrompt(options.projectKey, docs, options);
2767
+ const discoveryGraph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
2768
+ const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
2769
+ const { prompt } = this.buildPrompt(options.projectKey, docs, projectBuildMethod, options);
2258
2770
  const qaPreflight = await this.buildQaPreflight();
2259
2771
  const qaOverrides = this.buildQaOverrides(options);
2260
2772
  await this.jobService.writeCheckpoint(job.id, {
2261
2773
  stage: "docs_indexed",
2262
2774
  timestamp: new Date().toISOString(),
2263
- details: { count: docs.length, warnings: docWarnings },
2775
+ details: { count: docs.length, warnings: docWarnings, startupWaves: discoveryGraph.startupWaves.slice(0, 8) },
2264
2776
  });
2265
2777
  await this.jobService.writeCheckpoint(job.id, {
2266
2778
  stage: "qa_preflight",
2267
2779
  timestamp: new Date().toISOString(),
2268
2780
  details: qaPreflight,
2269
2781
  });
2270
- const agent = await this.resolveAgent(options.agentName);
2271
- const { output: epicOutput } = await this.invokeAgentWithRetry(agent, prompt, "epics", agentStream, job.id, commandRun.id, { docWarnings });
2272
- const epics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
2273
- await this.jobService.writeCheckpoint(job.id, {
2274
- stage: "epics_generated",
2275
- timestamp: new Date().toISOString(),
2276
- details: { epics: epics.length },
2277
- });
2278
- let plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2279
- agentStream,
2280
- jobId: job.id,
2281
- commandRunId: commandRun.id,
2282
- maxStoriesPerEpic: options.maxStoriesPerEpic,
2283
- maxTasksPerStory: options.maxTasksPerStory,
2284
- });
2782
+ let agent;
2783
+ let planSource = "agent";
2784
+ let fallbackReason;
2785
+ let plan;
2786
+ try {
2787
+ agent = await this.resolveAgent(options.agentName);
2788
+ const { output: epicOutput } = await this.invokeAgentWithRetry(agent, prompt, "epics", agentStream, job.id, commandRun.id, { docWarnings });
2789
+ const epics = this.parseEpics(epicOutput, docs, options.projectKey).slice(0, options.maxEpics ?? Number.MAX_SAFE_INTEGER);
2790
+ await this.jobService.writeCheckpoint(job.id, {
2791
+ stage: "epics_generated",
2792
+ timestamp: new Date().toISOString(),
2793
+ details: { epics: epics.length, source: "agent" },
2794
+ });
2795
+ plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2796
+ agentStream,
2797
+ jobId: job.id,
2798
+ commandRunId: commandRun.id,
2799
+ maxStoriesPerEpic: options.maxStoriesPerEpic,
2800
+ maxTasksPerStory: options.maxTasksPerStory,
2801
+ projectBuildMethod,
2802
+ });
2803
+ }
2804
+ catch (error) {
2805
+ fallbackReason = error.message ?? String(error);
2806
+ planSource = "fallback";
2807
+ await this.jobService.appendLog(job.id, `Agent planning failed, using deterministic fallback plan: ${fallbackReason}\n`);
2808
+ plan = this.materializePlanFromSeed(this.fallbackPlan(options.projectKey, docs), {
2809
+ maxEpics: options.maxEpics,
2810
+ maxStoriesPerEpic: options.maxStoriesPerEpic,
2811
+ maxTasksPerStory: options.maxTasksPerStory,
2812
+ });
2813
+ await this.jobService.writeCheckpoint(job.id, {
2814
+ stage: "epics_generated",
2815
+ timestamp: new Date().toISOString(),
2816
+ details: { epics: plan.epics.length, source: planSource, reason: fallbackReason },
2817
+ });
2818
+ }
2285
2819
  plan = this.enforceStoryScopedDependencies(plan);
2286
2820
  plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
2287
2821
  plan = this.enforceStoryScopedDependencies(plan);
2822
+ this.validatePlanLocalIdentifiers(plan);
2288
2823
  plan = this.applyServiceDependencySequencing(plan, docs);
2289
2824
  plan = this.enforceStoryScopedDependencies(plan);
2290
2825
  this.validatePlanLocalIdentifiers(plan);
2291
2826
  await this.jobService.writeCheckpoint(job.id, {
2292
2827
  stage: "stories_generated",
2293
2828
  timestamp: new Date().toISOString(),
2294
- details: { stories: plan.stories.length },
2829
+ details: { stories: plan.stories.length, source: planSource, fallbackReason },
2295
2830
  });
2296
2831
  await this.jobService.writeCheckpoint(job.id, {
2297
2832
  stage: "tasks_generated",
2298
2833
  timestamp: new Date().toISOString(),
2299
- details: { tasks: plan.tasks.length },
2834
+ details: { tasks: plan.tasks.length, source: planSource, fallbackReason },
2300
2835
  });
2301
2836
  const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary);
2302
2837
  await this.jobService.writeCheckpoint(job.id, {
@@ -2319,10 +2854,12 @@ export class CreateTasksService {
2319
2854
  dependenciesCreated: dependencyRows.length,
2320
2855
  docs: docSummary,
2321
2856
  planFolder: folder,
2857
+ planSource,
2858
+ fallbackReason,
2322
2859
  },
2323
2860
  });
2324
2861
  await this.jobService.finishCommandRun(commandRun.id, "succeeded");
2325
- if (options.rateAgents) {
2862
+ if (options.rateAgents && planSource === "agent" && agent) {
2326
2863
  try {
2327
2864
  const ratingService = this.ensureRatingService();
2328
2865
  await ratingService.rate({