@mcoda/core 0.1.17 → 0.1.19

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.
@@ -387,6 +387,110 @@ const collectFilesRecursively = async (target) => {
387
387
  }
388
388
  return [target];
389
389
  };
390
+ const DOC_SCAN_MAX_DEPTH = 5;
391
+ const DOC_SCAN_IGNORE_DIRS = new Set([
392
+ ".git",
393
+ ".hg",
394
+ ".svn",
395
+ ".mcoda",
396
+ "node_modules",
397
+ "dist",
398
+ "build",
399
+ "coverage",
400
+ ".next",
401
+ ".turbo",
402
+ ".cache",
403
+ "tmp",
404
+ "temp",
405
+ ]);
406
+ const DOC_SCAN_FILE_PATTERN = /\.(md|markdown|txt|rst|ya?ml|json)$/i;
407
+ const SDS_LIKE_PATH_PATTERN = /(^|\/)(sds|software[-_ ]design|design[-_ ]spec|requirements|prd|pdr|rfp|architecture|solution[-_ ]design)/i;
408
+ const OPENAPI_LIKE_PATH_PATTERN = /(openapi|swagger)/i;
409
+ const STRUCTURE_LIKE_PATH_PATTERN = /(^|\/)(tree|structure|layout|folder|directory|services?|modules?)(\/|[-_.]|$)/i;
410
+ const DOC_PATH_TOKEN_PATTERN = /(^|[\s`"'([{<])([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+)(?=$|[\s`"')\]}>.,;:!?])/g;
411
+ const FILE_EXTENSION_PATTERN = /\.[a-z0-9]{1,10}$/i;
412
+ const SERVICE_PATH_CONTAINER_SEGMENTS = new Set([
413
+ "services",
414
+ "service",
415
+ "apps",
416
+ "app",
417
+ "packages",
418
+ "package",
419
+ "modules",
420
+ "module",
421
+ "libs",
422
+ "lib",
423
+ "src",
424
+ ]);
425
+ const SERVICE_NAME_STOPWORDS = new Set([
426
+ "the",
427
+ "a",
428
+ "an",
429
+ "and",
430
+ "or",
431
+ "for",
432
+ "to",
433
+ "of",
434
+ "in",
435
+ "on",
436
+ "with",
437
+ "by",
438
+ "from",
439
+ "layer",
440
+ "stack",
441
+ "system",
442
+ "platform",
443
+ "project",
444
+ "repository",
445
+ "codebase",
446
+ "component",
447
+ "feature",
448
+ "implement",
449
+ "build",
450
+ "create",
451
+ "develop",
452
+ "deliver",
453
+ "setup",
454
+ "set",
455
+ "provision",
456
+ "define",
457
+ "configure",
458
+ "add",
459
+ "update",
460
+ "refactor",
461
+ "init",
462
+ "initialize",
463
+ "prepare",
464
+ "establish",
465
+ "support",
466
+ "enable",
467
+ ]);
468
+ const SERVICE_NAME_INVALID = new Set([
469
+ "service",
470
+ "services",
471
+ "module",
472
+ "modules",
473
+ "app",
474
+ "apps",
475
+ "layer",
476
+ "stack",
477
+ "system",
478
+ "project",
479
+ "repository",
480
+ "codebase",
481
+ ]);
482
+ 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
+ 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;
484
+ const nextUniqueLocalId = (prefix, existing) => {
485
+ let index = 1;
486
+ let candidate = `${prefix}-${index}`;
487
+ while (existing.has(candidate)) {
488
+ index += 1;
489
+ candidate = `${prefix}-${index}`;
490
+ }
491
+ existing.add(candidate);
492
+ return candidate;
493
+ };
390
494
  const EPIC_SCHEMA_SNIPPET = `{
391
495
  "epics": [
392
496
  {
@@ -489,6 +593,18 @@ export class CreateTasksService {
489
593
  const docdex = this.docdex;
490
594
  await swallow(docdex?.close?.bind(docdex));
491
595
  }
596
+ storyScopeKey(epicLocalId, storyLocalId) {
597
+ return `${epicLocalId}::${storyLocalId}`;
598
+ }
599
+ taskScopeKey(epicLocalId, storyLocalId, taskLocalId) {
600
+ return `${epicLocalId}::${storyLocalId}::${taskLocalId}`;
601
+ }
602
+ scopeStory(story) {
603
+ return this.storyScopeKey(story.epicLocalId, story.localId);
604
+ }
605
+ scopeTask(task) {
606
+ return this.taskScopeKey(task.epicLocalId, task.storyLocalId, task.localId);
607
+ }
492
608
  async seedPriorities(projectKey) {
493
609
  const ordering = await this.taskOrderingFactory(this.workspace, { recordTelemetry: false });
494
610
  try {
@@ -584,7 +700,818 @@ export class CreateTasksService {
584
700
  // Ignore missing candidates; fall back to empty inputs.
585
701
  }
586
702
  }
587
- return existing;
703
+ if (existing.length > 0)
704
+ return existing;
705
+ return this.findFuzzyDocInputs();
706
+ }
707
+ async walkDocCandidates(currentDir, depth, collector) {
708
+ if (depth > DOC_SCAN_MAX_DEPTH)
709
+ return;
710
+ let entries = [];
711
+ try {
712
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
713
+ }
714
+ catch {
715
+ return;
716
+ }
717
+ for (const entry of entries) {
718
+ const entryPath = path.join(currentDir, entry.name);
719
+ if (entry.isDirectory()) {
720
+ if (DOC_SCAN_IGNORE_DIRS.has(entry.name.toLowerCase()))
721
+ continue;
722
+ await this.walkDocCandidates(entryPath, depth + 1, collector);
723
+ continue;
724
+ }
725
+ if (entry.isFile()) {
726
+ collector(entryPath);
727
+ }
728
+ }
729
+ }
730
+ scoreDocCandidate(filePath) {
731
+ const workspaceRelative = path.relative(this.workspace.workspaceRoot, filePath).replace(/\\/g, "/").toLowerCase();
732
+ const mcodaRelative = path.relative(this.workspace.mcodaDir, filePath).replace(/\\/g, "/").toLowerCase();
733
+ const relative = workspaceRelative && !workspaceRelative.startsWith("..")
734
+ ? workspaceRelative
735
+ : mcodaRelative && !mcodaRelative.startsWith("..")
736
+ ? mcodaRelative
737
+ : path.basename(filePath).toLowerCase();
738
+ const normalized = `/${relative}`;
739
+ const baseName = path.basename(relative);
740
+ if (!DOC_SCAN_FILE_PATTERN.test(baseName))
741
+ return 0;
742
+ let score = 0;
743
+ if (SDS_LIKE_PATH_PATTERN.test(normalized))
744
+ score += 100;
745
+ if (OPENAPI_LIKE_PATH_PATTERN.test(normalized))
746
+ score += 80;
747
+ if (STRUCTURE_LIKE_PATH_PATTERN.test(normalized))
748
+ score += 30;
749
+ if (normalized.includes("/docs/"))
750
+ score += 20;
751
+ if (normalized.endsWith(".md") || normalized.endsWith(".markdown"))
752
+ score += 10;
753
+ return score;
754
+ }
755
+ async findFuzzyDocInputs() {
756
+ const ranked = [];
757
+ const seen = new Set();
758
+ const collect = (candidate) => {
759
+ const resolved = path.resolve(candidate);
760
+ if (seen.has(resolved))
761
+ return;
762
+ const score = this.scoreDocCandidate(resolved);
763
+ if (score <= 0)
764
+ return;
765
+ ranked.push({ path: resolved, score });
766
+ seen.add(resolved);
767
+ };
768
+ await this.walkDocCandidates(this.workspace.workspaceRoot, 0, collect);
769
+ const mcodaDocs = path.join(this.workspace.mcodaDir, "docs");
770
+ try {
771
+ const stat = await fs.stat(mcodaDocs);
772
+ if (stat.isDirectory()) {
773
+ await this.walkDocCandidates(mcodaDocs, 0, collect);
774
+ }
775
+ }
776
+ catch {
777
+ // Ignore missing workspace docs.
778
+ }
779
+ return ranked
780
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
781
+ .slice(0, 24)
782
+ .map((entry) => entry.path);
783
+ }
784
+ normalizeStructurePathToken(value) {
785
+ const normalized = value
786
+ .replace(/\\/g, "/")
787
+ .replace(/^[./]+/, "")
788
+ .replace(/^\/+/, "")
789
+ .trim();
790
+ if (!normalized)
791
+ return undefined;
792
+ if (normalized.length > 140)
793
+ return undefined;
794
+ if (!normalized.includes("/"))
795
+ return undefined;
796
+ if (normalized.includes("://"))
797
+ return undefined;
798
+ if (/[\u0000-\u001f]/.test(normalized))
799
+ return undefined;
800
+ const parts = normalized.split("/").filter(Boolean);
801
+ if (parts.length < 2)
802
+ return undefined;
803
+ if (parts.some((part) => part === "." || part === ".."))
804
+ return undefined;
805
+ if (DOC_SCAN_IGNORE_DIRS.has(parts[0].toLowerCase()))
806
+ return undefined;
807
+ return parts.join("/");
808
+ }
809
+ extractStructureTargets(docs) {
810
+ const directories = new Set();
811
+ const files = new Set();
812
+ for (const doc of docs) {
813
+ const segments = (doc.segments ?? []).map((segment) => segment.content).filter(Boolean).join("\n");
814
+ const corpus = [doc.title, doc.path, doc.content, segments].filter(Boolean).join("\n");
815
+ for (const match of corpus.matchAll(DOC_PATH_TOKEN_PATTERN)) {
816
+ const token = match[2];
817
+ if (!token)
818
+ continue;
819
+ const normalized = this.normalizeStructurePathToken(token);
820
+ if (!normalized)
821
+ continue;
822
+ if (FILE_EXTENSION_PATTERN.test(path.basename(normalized))) {
823
+ files.add(normalized);
824
+ const parent = path.dirname(normalized).replace(/\\/g, "/");
825
+ if (parent && parent !== ".")
826
+ directories.add(parent);
827
+ }
828
+ else {
829
+ directories.add(normalized);
830
+ }
831
+ }
832
+ }
833
+ return {
834
+ directories: Array.from(directories).sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, 32),
835
+ files: Array.from(files).sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, 32),
836
+ };
837
+ }
838
+ normalizeServiceName(value) {
839
+ const normalized = value
840
+ .toLowerCase()
841
+ .replace(/[`"'()[\]{}]/g, " ")
842
+ .replace(/[._/-]+/g, " ")
843
+ .replace(/[^a-z0-9\s]+/g, " ")
844
+ .replace(/\s+/g, " ")
845
+ .trim();
846
+ if (!normalized)
847
+ return undefined;
848
+ const keepTokens = new Set(["api", "ui", "db", "qa", "ml", "ai", "etl"]);
849
+ const tokens = normalized
850
+ .split(" ")
851
+ .map((token) => token.trim())
852
+ .filter(Boolean)
853
+ .filter((token) => keepTokens.has(token) || !SERVICE_NAME_STOPWORDS.has(token))
854
+ .slice(0, 4);
855
+ if (!tokens.length)
856
+ return undefined;
857
+ const candidate = tokens.join(" ");
858
+ if (SERVICE_NAME_INVALID.has(candidate))
859
+ return undefined;
860
+ return candidate.length >= 2 ? candidate : undefined;
861
+ }
862
+ deriveServiceFromPathToken(pathToken) {
863
+ const parts = pathToken
864
+ .replace(/\\/g, "/")
865
+ .split("/")
866
+ .map((part) => part.trim().toLowerCase())
867
+ .filter(Boolean);
868
+ if (!parts.length)
869
+ return undefined;
870
+ let idx = 0;
871
+ while (idx < parts.length - 1 && SERVICE_PATH_CONTAINER_SEGMENTS.has(parts[idx])) {
872
+ idx += 1;
873
+ }
874
+ return this.normalizeServiceName(parts[idx] ?? parts[0]);
875
+ }
876
+ addServiceAlias(aliases, rawValue) {
877
+ const canonical = this.normalizeServiceName(rawValue);
878
+ if (!canonical)
879
+ return undefined;
880
+ const existing = aliases.get(canonical) ?? new Set();
881
+ existing.add(canonical);
882
+ const alias = rawValue
883
+ .toLowerCase()
884
+ .replace(/[._/-]+/g, " ")
885
+ .replace(/[^a-z0-9\s]+/g, " ")
886
+ .replace(/\s+/g, " ")
887
+ .trim();
888
+ if (alias)
889
+ existing.add(alias);
890
+ aliases.set(canonical, existing);
891
+ return canonical;
892
+ }
893
+ extractServiceMentionsFromText(text) {
894
+ if (!text)
895
+ return [];
896
+ const mentions = new Set();
897
+ for (const match of text.matchAll(SERVICE_LABEL_PATTERN)) {
898
+ const phrase = `${match[1] ?? ""} ${match[2] ?? ""}`.trim();
899
+ const normalized = this.normalizeServiceName(phrase);
900
+ if (normalized)
901
+ mentions.add(normalized);
902
+ }
903
+ for (const match of text.matchAll(DOC_PATH_TOKEN_PATTERN)) {
904
+ const token = match[2];
905
+ if (!token)
906
+ continue;
907
+ const normalized = this.deriveServiceFromPathToken(token);
908
+ if (normalized)
909
+ mentions.add(normalized);
910
+ }
911
+ return Array.from(mentions);
912
+ }
913
+ resolveServiceMentionFromPhrase(phrase, aliases) {
914
+ const normalizedPhrase = phrase
915
+ .toLowerCase()
916
+ .replace(/[._/-]+/g, " ")
917
+ .replace(/[^a-z0-9\s]+/g, " ")
918
+ .replace(/\s+/g, " ")
919
+ .trim();
920
+ if (!normalizedPhrase)
921
+ return undefined;
922
+ let best;
923
+ const haystack = ` ${normalizedPhrase} `;
924
+ for (const [service, names] of aliases.entries()) {
925
+ for (const alias of names) {
926
+ const needle = ` ${alias} `;
927
+ if (!haystack.includes(needle))
928
+ continue;
929
+ if (!best || alias.length > best.aliasLength) {
930
+ best = { key: service, aliasLength: alias.length };
931
+ }
932
+ }
933
+ }
934
+ if (best)
935
+ return best.key;
936
+ const mention = this.extractServiceMentionsFromText(phrase)[0];
937
+ if (!mention)
938
+ return undefined;
939
+ return this.addServiceAlias(aliases, mention);
940
+ }
941
+ collectDependencyStatements(text) {
942
+ const statements = [];
943
+ const lines = text
944
+ .split(/\r?\n/)
945
+ .map((line) => line.trim())
946
+ .filter(Boolean)
947
+ .slice(0, 300);
948
+ const dependencyPatterns = [
949
+ {
950
+ regex: /^(.+?)\b(?:depends on|requires|needs|uses|consumes|calls|reads from|writes to|must come after|comes after|built after|runs after|backed by)\b(.+)$/i,
951
+ dependentGroup: 1,
952
+ dependencyGroup: 2,
953
+ },
954
+ {
955
+ regex: /^(.+?)\b(?:before|prerequisite for)\b(.+)$/i,
956
+ dependentGroup: 2,
957
+ dependencyGroup: 1,
958
+ },
959
+ ];
960
+ for (const rawLine of lines) {
961
+ const line = rawLine.replace(/^[-*]\s+/, "").trim();
962
+ if (!line)
963
+ continue;
964
+ for (const match of line.matchAll(SERVICE_ARROW_PATTERN)) {
965
+ const dependent = match[1]?.trim();
966
+ const dependency = match[2]?.trim();
967
+ if (dependent && dependency) {
968
+ statements.push({ dependent, dependency });
969
+ }
970
+ }
971
+ for (const pattern of dependencyPatterns) {
972
+ const match = line.match(pattern.regex);
973
+ if (!match)
974
+ continue;
975
+ const dependent = match[pattern.dependentGroup]?.trim();
976
+ const dependency = match[pattern.dependencyGroup]?.trim();
977
+ if (!dependent || !dependency)
978
+ continue;
979
+ statements.push({ dependent, dependency });
980
+ }
981
+ }
982
+ return statements;
983
+ }
984
+ sortServicesByDependency(services, dependencies) {
985
+ const nodes = Array.from(new Set(services));
986
+ const indegree = new Map();
987
+ const adjacency = new Map();
988
+ const dependedBy = new Map();
989
+ for (const node of nodes) {
990
+ indegree.set(node, 0);
991
+ dependedBy.set(node, 0);
992
+ }
993
+ for (const [dependent, dependencySet] of dependencies.entries()) {
994
+ if (!indegree.has(dependent)) {
995
+ indegree.set(dependent, 0);
996
+ dependedBy.set(dependent, 0);
997
+ nodes.push(dependent);
998
+ }
999
+ for (const dependency of dependencySet) {
1000
+ if (!indegree.has(dependency)) {
1001
+ indegree.set(dependency, 0);
1002
+ dependedBy.set(dependency, 0);
1003
+ nodes.push(dependency);
1004
+ }
1005
+ indegree.set(dependent, (indegree.get(dependent) ?? 0) + 1);
1006
+ const out = adjacency.get(dependency) ?? new Set();
1007
+ out.add(dependent);
1008
+ adjacency.set(dependency, out);
1009
+ dependedBy.set(dependency, (dependedBy.get(dependency) ?? 0) + 1);
1010
+ }
1011
+ }
1012
+ const compare = (a, b) => {
1013
+ const dependedByA = dependedBy.get(a) ?? 0;
1014
+ const dependedByB = dependedBy.get(b) ?? 0;
1015
+ if (dependedByA !== dependedByB)
1016
+ return dependedByB - dependedByA;
1017
+ return a.localeCompare(b);
1018
+ };
1019
+ const queue = nodes.filter((node) => (indegree.get(node) ?? 0) === 0).sort(compare);
1020
+ const ordered = [];
1021
+ while (queue.length) {
1022
+ const current = queue.shift();
1023
+ if (!current)
1024
+ continue;
1025
+ ordered.push(current);
1026
+ const out = adjacency.get(current);
1027
+ if (!out)
1028
+ continue;
1029
+ for (const neighbor of out) {
1030
+ const nextInDegree = (indegree.get(neighbor) ?? 0) - 1;
1031
+ indegree.set(neighbor, nextInDegree);
1032
+ if (nextInDegree === 0) {
1033
+ queue.push(neighbor);
1034
+ }
1035
+ }
1036
+ queue.sort(compare);
1037
+ }
1038
+ if (ordered.length === nodes.length)
1039
+ return ordered;
1040
+ const remaining = nodes.filter((node) => !ordered.includes(node)).sort(compare);
1041
+ return [...ordered, ...remaining];
1042
+ }
1043
+ buildServiceDependencyGraph(plan, docs) {
1044
+ const aliases = new Map();
1045
+ const dependencies = new Map();
1046
+ const register = (value) => {
1047
+ if (!value)
1048
+ return undefined;
1049
+ return this.addServiceAlias(aliases, value);
1050
+ };
1051
+ const docsText = docs
1052
+ .map((doc) => [doc.title, doc.path, doc.content, ...(doc.segments ?? []).map((segment) => segment.content)].filter(Boolean).join("\n"))
1053
+ .join("\n");
1054
+ const planText = [
1055
+ ...plan.epics.map((epic) => `${epic.title}\n${epic.description ?? ""}`),
1056
+ ...plan.stories.map((story) => `${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`),
1057
+ ...plan.tasks.map((task) => `${task.title}\n${task.description ?? ""}`),
1058
+ ].join("\n");
1059
+ const structureTargets = this.extractStructureTargets(docs);
1060
+ for (const token of [...structureTargets.directories, ...structureTargets.files]) {
1061
+ register(this.deriveServiceFromPathToken(token));
1062
+ }
1063
+ for (const mention of this.extractServiceMentionsFromText(docsText))
1064
+ register(mention);
1065
+ for (const mention of this.extractServiceMentionsFromText(planText))
1066
+ register(mention);
1067
+ const corpus = [docsText, planText].filter(Boolean);
1068
+ for (const text of corpus) {
1069
+ const statements = this.collectDependencyStatements(text);
1070
+ for (const statement of statements) {
1071
+ const dependent = this.resolveServiceMentionFromPhrase(statement.dependent, aliases);
1072
+ const dependency = this.resolveServiceMentionFromPhrase(statement.dependency, aliases);
1073
+ if (!dependent || !dependency || dependent === dependency)
1074
+ continue;
1075
+ const next = dependencies.get(dependent) ?? new Set();
1076
+ next.add(dependency);
1077
+ dependencies.set(dependent, next);
1078
+ }
1079
+ }
1080
+ const services = this.sortServicesByDependency(Array.from(aliases.keys()), dependencies);
1081
+ return { services, dependencies, aliases };
1082
+ }
1083
+ orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope) {
1084
+ const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
1085
+ const indegree = new Map();
1086
+ const outgoing = new Map();
1087
+ for (const task of storyTasks) {
1088
+ indegree.set(task.localId, 0);
1089
+ }
1090
+ for (const task of storyTasks) {
1091
+ for (const dep of task.dependsOnKeys ?? []) {
1092
+ if (!byLocalId.has(dep) || dep === task.localId)
1093
+ continue;
1094
+ indegree.set(task.localId, (indegree.get(task.localId) ?? 0) + 1);
1095
+ const edges = outgoing.get(dep) ?? new Set();
1096
+ edges.add(task.localId);
1097
+ outgoing.set(dep, edges);
1098
+ }
1099
+ }
1100
+ const priorityComparator = (a, b) => {
1101
+ const classA = classifyTask({ title: a.title ?? "", description: a.description, type: a.type });
1102
+ const classB = classifyTask({ title: b.title ?? "", description: b.description, type: b.type });
1103
+ if (classA.foundation !== classB.foundation)
1104
+ return classA.foundation ? -1 : 1;
1105
+ const rankA = serviceRank.get(taskServiceByScope.get(this.scopeTask(a)) ?? "") ?? Number.MAX_SAFE_INTEGER;
1106
+ const rankB = serviceRank.get(taskServiceByScope.get(this.scopeTask(b)) ?? "") ?? Number.MAX_SAFE_INTEGER;
1107
+ if (rankA !== rankB)
1108
+ return rankA - rankB;
1109
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1110
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1111
+ if (priorityA !== priorityB)
1112
+ return priorityA - priorityB;
1113
+ return a.localId.localeCompare(b.localId);
1114
+ };
1115
+ const queue = storyTasks.filter((task) => (indegree.get(task.localId) ?? 0) === 0).sort(priorityComparator);
1116
+ const ordered = [];
1117
+ const seen = new Set();
1118
+ while (queue.length > 0) {
1119
+ const next = queue.shift();
1120
+ if (!next || seen.has(next.localId))
1121
+ continue;
1122
+ seen.add(next.localId);
1123
+ ordered.push(next);
1124
+ const dependents = outgoing.get(next.localId);
1125
+ if (!dependents)
1126
+ continue;
1127
+ for (const dependent of dependents) {
1128
+ const updated = (indegree.get(dependent) ?? 0) - 1;
1129
+ indegree.set(dependent, updated);
1130
+ if (updated === 0) {
1131
+ const depTask = byLocalId.get(dependent);
1132
+ if (depTask)
1133
+ queue.push(depTask);
1134
+ }
1135
+ }
1136
+ queue.sort(priorityComparator);
1137
+ }
1138
+ if (ordered.length === storyTasks.length)
1139
+ return ordered;
1140
+ const remaining = storyTasks.filter((task) => !seen.has(task.localId)).sort(priorityComparator);
1141
+ return [...ordered, ...remaining];
1142
+ }
1143
+ applyServiceDependencySequencing(plan, docs) {
1144
+ const graph = this.buildServiceDependencyGraph(plan, docs);
1145
+ if (!graph.services.length)
1146
+ return plan;
1147
+ const serviceRank = new Map(graph.services.map((service, index) => [service, index]));
1148
+ const resolveEntityService = (text) => this.resolveServiceMentionFromPhrase(text, graph.aliases);
1149
+ const epics = plan.epics.map((epic) => ({ ...epic }));
1150
+ const stories = plan.stories.map((story) => ({ ...story }));
1151
+ const tasks = plan.tasks.map((task) => ({ ...task, dependsOnKeys: uniqueStrings(task.dependsOnKeys ?? []) }));
1152
+ const storyByScope = new Map(stories.map((story) => [this.scopeStory(story), story]));
1153
+ const taskServiceByScope = new Map();
1154
+ for (const task of tasks) {
1155
+ const text = `${task.title ?? ""}\n${task.description ?? ""}`;
1156
+ taskServiceByScope.set(this.scopeTask(task), resolveEntityService(text));
1157
+ }
1158
+ const tasksByStory = new Map();
1159
+ for (const task of tasks) {
1160
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1161
+ const bucket = tasksByStory.get(storyScope) ?? [];
1162
+ bucket.push(task);
1163
+ tasksByStory.set(storyScope, bucket);
1164
+ }
1165
+ for (const storyTasks of tasksByStory.values()) {
1166
+ const tasksByService = new Map();
1167
+ for (const task of storyTasks) {
1168
+ const service = taskServiceByScope.get(this.scopeTask(task));
1169
+ if (!service)
1170
+ continue;
1171
+ const serviceTasks = tasksByService.get(service) ?? [];
1172
+ serviceTasks.push(task);
1173
+ tasksByService.set(service, serviceTasks);
1174
+ }
1175
+ for (const serviceTasks of tasksByService.values()) {
1176
+ serviceTasks.sort((a, b) => (a.priorityHint ?? Number.MAX_SAFE_INTEGER) - (b.priorityHint ?? Number.MAX_SAFE_INTEGER));
1177
+ }
1178
+ for (const task of storyTasks) {
1179
+ const service = taskServiceByScope.get(this.scopeTask(task));
1180
+ if (!service)
1181
+ continue;
1182
+ const requiredServices = graph.dependencies.get(service);
1183
+ if (!requiredServices || requiredServices.size === 0)
1184
+ continue;
1185
+ for (const requiredService of requiredServices) {
1186
+ const candidate = tasksByService.get(requiredService)?.[0];
1187
+ if (!candidate || candidate.localId === task.localId)
1188
+ continue;
1189
+ if (!(task.dependsOnKeys ?? []).includes(candidate.localId)) {
1190
+ task.dependsOnKeys = uniqueStrings([...(task.dependsOnKeys ?? []), candidate.localId]);
1191
+ }
1192
+ }
1193
+ }
1194
+ }
1195
+ const storyRankByScope = new Map();
1196
+ for (const story of stories) {
1197
+ const storyScope = this.scopeStory(story);
1198
+ const storyTasks = tasksByStory.get(storyScope) ?? [];
1199
+ const taskRanks = storyTasks
1200
+ .map((task) => serviceRank.get(taskServiceByScope.get(this.scopeTask(task)) ?? ""))
1201
+ .filter((value) => typeof value === "number");
1202
+ const storyTextRank = serviceRank.get(resolveEntityService(`${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`) ?? "");
1203
+ const rank = taskRanks.length > 0 ? Math.min(...taskRanks) : storyTextRank ?? Number.MAX_SAFE_INTEGER;
1204
+ storyRankByScope.set(storyScope, rank);
1205
+ }
1206
+ const epicRankByLocalId = new Map();
1207
+ for (const epic of epics) {
1208
+ const epicStories = stories.filter((story) => story.epicLocalId === epic.localId);
1209
+ const storyRanks = epicStories
1210
+ .map((story) => storyRankByScope.get(this.scopeStory(story)))
1211
+ .filter((value) => typeof value === "number");
1212
+ const epicTextRank = serviceRank.get(resolveEntityService(`${epic.title}\n${epic.description ?? ""}`) ?? "");
1213
+ const rank = storyRanks.length > 0 ? Math.min(...storyRanks) : epicTextRank ?? Number.MAX_SAFE_INTEGER;
1214
+ epicRankByLocalId.set(epic.localId, rank);
1215
+ }
1216
+ const isBootstrap = (value) => /bootstrap|foundation|structure/i.test(value);
1217
+ epics.sort((a, b) => {
1218
+ const bootstrapA = isBootstrap(`${a.title} ${a.description ?? ""}`);
1219
+ const bootstrapB = isBootstrap(`${b.title} ${b.description ?? ""}`);
1220
+ if (bootstrapA !== bootstrapB)
1221
+ return bootstrapA ? -1 : 1;
1222
+ const rankA = epicRankByLocalId.get(a.localId) ?? Number.MAX_SAFE_INTEGER;
1223
+ const rankB = epicRankByLocalId.get(b.localId) ?? Number.MAX_SAFE_INTEGER;
1224
+ if (rankA !== rankB)
1225
+ return rankA - rankB;
1226
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1227
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1228
+ if (priorityA !== priorityB)
1229
+ return priorityA - priorityB;
1230
+ return a.localId.localeCompare(b.localId);
1231
+ });
1232
+ epics.forEach((epic, index) => {
1233
+ epic.priorityHint = index + 1;
1234
+ });
1235
+ const storiesOrdered = [];
1236
+ const tasksOrdered = [];
1237
+ for (const epic of epics) {
1238
+ const epicStories = stories
1239
+ .filter((story) => story.epicLocalId === epic.localId)
1240
+ .sort((a, b) => {
1241
+ const bootstrapA = isBootstrap(`${a.title} ${a.description ?? ""}`);
1242
+ const bootstrapB = isBootstrap(`${b.title} ${b.description ?? ""}`);
1243
+ if (bootstrapA !== bootstrapB)
1244
+ return bootstrapA ? -1 : 1;
1245
+ const rankA = storyRankByScope.get(this.scopeStory(a)) ?? Number.MAX_SAFE_INTEGER;
1246
+ const rankB = storyRankByScope.get(this.scopeStory(b)) ?? Number.MAX_SAFE_INTEGER;
1247
+ if (rankA !== rankB)
1248
+ return rankA - rankB;
1249
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1250
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1251
+ if (priorityA !== priorityB)
1252
+ return priorityA - priorityB;
1253
+ return a.localId.localeCompare(b.localId);
1254
+ });
1255
+ epicStories.forEach((story, index) => {
1256
+ story.priorityHint = index + 1;
1257
+ storiesOrdered.push(story);
1258
+ const storyTasks = tasksByStory.get(this.scopeStory(story)) ?? [];
1259
+ const orderedTasks = this.orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope);
1260
+ orderedTasks.forEach((task, taskIndex) => {
1261
+ task.priorityHint = taskIndex + 1;
1262
+ tasksOrdered.push(task);
1263
+ });
1264
+ });
1265
+ }
1266
+ const orderedStoryScopes = new Set(storiesOrdered.map((story) => this.scopeStory(story)));
1267
+ for (const story of stories) {
1268
+ if (orderedStoryScopes.has(this.scopeStory(story)))
1269
+ continue;
1270
+ storiesOrdered.push(story);
1271
+ }
1272
+ const orderedTaskScopes = new Set(tasksOrdered.map((task) => this.scopeTask(task)));
1273
+ for (const task of tasks) {
1274
+ if (orderedTaskScopes.has(this.scopeTask(task)))
1275
+ continue;
1276
+ tasksOrdered.push(task);
1277
+ }
1278
+ // Keep parent linkage intact even if malformed story references exist.
1279
+ for (const story of storiesOrdered) {
1280
+ if (!storyByScope.has(this.scopeStory(story)))
1281
+ continue;
1282
+ story.epicLocalId = storyByScope.get(this.scopeStory(story))?.epicLocalId ?? story.epicLocalId;
1283
+ }
1284
+ return { epics, stories: storiesOrdered, tasks: tasksOrdered };
1285
+ }
1286
+ shouldInjectStructureBootstrap(plan, docs) {
1287
+ if (docs.length === 0)
1288
+ return false;
1289
+ return !plan.tasks.some((task) => /codebase structure|folder tree|scaffold|bootstrap|repository layout|project skeleton/i.test(`${task.title} ${task.description ?? ""}`));
1290
+ }
1291
+ injectStructureBootstrapPlan(plan, docs, projectKey) {
1292
+ if (!this.shouldInjectStructureBootstrap(plan, docs))
1293
+ return plan;
1294
+ const localIds = new Set([
1295
+ ...plan.epics.map((epic) => epic.localId),
1296
+ ...plan.stories.map((story) => story.localId),
1297
+ ...plan.tasks.map((task) => task.localId),
1298
+ ]);
1299
+ const epicLocalId = nextUniqueLocalId("bootstrap-epic", localIds);
1300
+ const storyLocalId = nextUniqueLocalId("bootstrap-story", localIds);
1301
+ const task1LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1302
+ const task2LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1303
+ const task3LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1304
+ const structureTargets = this.extractStructureTargets(docs);
1305
+ const directoryPreview = structureTargets.directories.length
1306
+ ? structureTargets.directories.slice(0, 20).map((item) => `- ${item}`).join("\n")
1307
+ : "- Infer top-level source directories from SDS sections and create them.";
1308
+ const filePreview = structureTargets.files.length
1309
+ ? structureTargets.files.slice(0, 20).map((item) => `- ${item}`).join("\n")
1310
+ : "- Create minimal entrypoint/config placeholders required by the SDS-defined architecture.";
1311
+ const relatedDocs = docs
1312
+ .map((doc) => (doc.id ? `docdex:${doc.id}` : undefined))
1313
+ .filter((value) => Boolean(value))
1314
+ .slice(0, 12);
1315
+ const bootstrapEpic = {
1316
+ localId: epicLocalId,
1317
+ area: normalizeArea(projectKey) ?? "infra",
1318
+ title: "Codebase Foundation and Structure Setup",
1319
+ description: "Create the SDS-defined codebase scaffold first (folders/files/service boundaries) before feature implementation tasks.",
1320
+ acceptanceCriteria: [
1321
+ "Required folder tree exists for the planned architecture.",
1322
+ "Minimal entrypoint/config files exist for each discovered service/module.",
1323
+ "Service dependency assumptions are explicit and actionable in follow-up tasks.",
1324
+ ],
1325
+ relatedDocs,
1326
+ priorityHint: 1,
1327
+ stories: [],
1328
+ };
1329
+ const bootstrapStory = {
1330
+ localId: storyLocalId,
1331
+ epicLocalId,
1332
+ title: "Bootstrap repository structure from SDS",
1333
+ userStory: "As an engineer, I want a concrete codebase scaffold first so implementation tasks can target real modules instead of only tests.",
1334
+ description: [
1335
+ "Parse SDS/PDR/OpenAPI context and establish the expected folder/file tree.",
1336
+ "Start with dependencies-first service ordering (foundational components before dependents).",
1337
+ ].join("\n"),
1338
+ acceptanceCriteria: [
1339
+ "Repository scaffold matches documented architecture at a high level.",
1340
+ "Core service/module placeholders are committed as executable starting points.",
1341
+ "Follow-up tasks reference real directories/files under the scaffold.",
1342
+ ],
1343
+ relatedDocs,
1344
+ priorityHint: 1,
1345
+ tasks: [],
1346
+ };
1347
+ const bootstrapTasks = [
1348
+ {
1349
+ localId: task1LocalId,
1350
+ storyLocalId,
1351
+ epicLocalId,
1352
+ title: "Create SDS-aligned folder tree",
1353
+ type: "chore",
1354
+ description: [
1355
+ "Create the initial folder tree inferred from SDS and related docs.",
1356
+ "Target directories:",
1357
+ directoryPreview,
1358
+ ].join("\n"),
1359
+ estimatedStoryPoints: 2,
1360
+ priorityHint: 1,
1361
+ dependsOnKeys: [],
1362
+ relatedDocs,
1363
+ unitTests: [],
1364
+ componentTests: [],
1365
+ integrationTests: [],
1366
+ apiTests: [],
1367
+ },
1368
+ {
1369
+ localId: task2LocalId,
1370
+ storyLocalId,
1371
+ epicLocalId,
1372
+ title: "Create foundational file stubs for discovered modules",
1373
+ type: "chore",
1374
+ description: [
1375
+ "Create minimal file stubs/config entrypoints for the scaffolded modules/services.",
1376
+ "Target files:",
1377
+ filePreview,
1378
+ ].join("\n"),
1379
+ estimatedStoryPoints: 3,
1380
+ priorityHint: 2,
1381
+ dependsOnKeys: [task1LocalId],
1382
+ relatedDocs,
1383
+ unitTests: [],
1384
+ componentTests: [],
1385
+ integrationTests: [],
1386
+ apiTests: [],
1387
+ },
1388
+ {
1389
+ localId: task3LocalId,
1390
+ storyLocalId,
1391
+ epicLocalId,
1392
+ title: "Define service dependency baseline for implementation sequencing",
1393
+ type: "spike",
1394
+ description: "Document and codify service/module dependency direction so highly depended foundational services are implemented first.",
1395
+ estimatedStoryPoints: 2,
1396
+ priorityHint: 3,
1397
+ dependsOnKeys: [task2LocalId],
1398
+ relatedDocs,
1399
+ unitTests: [],
1400
+ componentTests: [],
1401
+ integrationTests: [],
1402
+ apiTests: [],
1403
+ },
1404
+ ];
1405
+ return {
1406
+ epics: [bootstrapEpic, ...plan.epics],
1407
+ stories: [bootstrapStory, ...plan.stories],
1408
+ tasks: [...bootstrapTasks, ...plan.tasks],
1409
+ };
1410
+ }
1411
+ enforceStoryScopedDependencies(plan) {
1412
+ const taskMap = new Map(plan.tasks.map((task) => [
1413
+ this.scopeTask(task),
1414
+ {
1415
+ ...task,
1416
+ dependsOnKeys: uniqueStrings((task.dependsOnKeys ?? []).filter(Boolean)),
1417
+ },
1418
+ ]));
1419
+ const tasksByStory = new Map();
1420
+ for (const task of taskMap.values()) {
1421
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1422
+ const storyTasks = tasksByStory.get(storyScope) ?? [];
1423
+ storyTasks.push(task);
1424
+ tasksByStory.set(storyScope, storyTasks);
1425
+ }
1426
+ for (const storyTasks of tasksByStory.values()) {
1427
+ const localIds = new Set(storyTasks.map((task) => task.localId));
1428
+ const foundationTasks = storyTasks
1429
+ .filter((task) => classifyTask({
1430
+ title: task.title ?? "",
1431
+ description: task.description,
1432
+ type: task.type,
1433
+ }).foundation)
1434
+ .sort((a, b) => (a.priorityHint ?? Number.MAX_SAFE_INTEGER) - (b.priorityHint ?? Number.MAX_SAFE_INTEGER));
1435
+ const foundationAnchor = foundationTasks.find((task) => !(task.dependsOnKeys ?? []).some((dep) => localIds.has(dep)))?.localId ??
1436
+ foundationTasks[0]?.localId;
1437
+ for (const task of storyTasks) {
1438
+ const filtered = (task.dependsOnKeys ?? []).filter((dep) => dep !== task.localId && localIds.has(dep));
1439
+ const classification = classifyTask({
1440
+ title: task.title ?? "",
1441
+ description: task.description,
1442
+ type: task.type,
1443
+ });
1444
+ if (foundationAnchor &&
1445
+ foundationAnchor !== task.localId &&
1446
+ !classification.foundation &&
1447
+ !filtered.includes(foundationAnchor)) {
1448
+ filtered.push(foundationAnchor);
1449
+ }
1450
+ task.dependsOnKeys = uniqueStrings(filtered);
1451
+ }
1452
+ }
1453
+ return {
1454
+ ...plan,
1455
+ tasks: plan.tasks.map((task) => taskMap.get(this.scopeTask(task)) ?? task),
1456
+ };
1457
+ }
1458
+ validatePlanLocalIdentifiers(plan) {
1459
+ const errors = [];
1460
+ const epicIds = new Set();
1461
+ for (const epic of plan.epics) {
1462
+ if (!epic.localId || !epic.localId.trim()) {
1463
+ errors.push("epic has missing localId");
1464
+ continue;
1465
+ }
1466
+ if (epicIds.has(epic.localId)) {
1467
+ errors.push(`duplicate epic localId: ${epic.localId}`);
1468
+ continue;
1469
+ }
1470
+ epicIds.add(epic.localId);
1471
+ }
1472
+ const storyScopes = new Set();
1473
+ for (const story of plan.stories) {
1474
+ const scope = this.scopeStory(story);
1475
+ if (!epicIds.has(story.epicLocalId)) {
1476
+ errors.push(`story ${scope} references unknown epicLocalId ${story.epicLocalId}`);
1477
+ }
1478
+ if (storyScopes.has(scope)) {
1479
+ errors.push(`duplicate story scope: ${scope}`);
1480
+ continue;
1481
+ }
1482
+ storyScopes.add(scope);
1483
+ }
1484
+ const taskScopes = new Set();
1485
+ const storyTaskLocals = new Map();
1486
+ for (const task of plan.tasks) {
1487
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1488
+ const taskScope = this.scopeTask(task);
1489
+ if (!storyScopes.has(storyScope)) {
1490
+ errors.push(`task ${taskScope} references unknown story scope ${storyScope}`);
1491
+ }
1492
+ if (taskScopes.has(taskScope)) {
1493
+ errors.push(`duplicate task scope: ${taskScope}`);
1494
+ continue;
1495
+ }
1496
+ taskScopes.add(taskScope);
1497
+ const locals = storyTaskLocals.get(storyScope) ?? new Set();
1498
+ locals.add(task.localId);
1499
+ storyTaskLocals.set(storyScope, locals);
1500
+ }
1501
+ for (const task of plan.tasks) {
1502
+ const storyScope = this.storyScopeKey(task.epicLocalId, task.storyLocalId);
1503
+ const localIds = storyTaskLocals.get(storyScope) ?? new Set();
1504
+ for (const dep of task.dependsOnKeys ?? []) {
1505
+ if (!dep || dep === task.localId)
1506
+ continue;
1507
+ if (!localIds.has(dep)) {
1508
+ errors.push(`task ${this.scopeTask(task)} has dependency ${dep} that is outside story scope ${storyScope}`);
1509
+ }
1510
+ }
1511
+ }
1512
+ if (errors.length > 0) {
1513
+ throw new Error(`Invalid generated plan local identifiers:\n- ${errors.join("\n- ")}`);
1514
+ }
588
1515
  }
589
1516
  async buildQaPreflight() {
590
1517
  const preflight = {
@@ -737,6 +1664,8 @@ export class CreateTasksService {
737
1664
  "- Do NOT include final slugs; the system will assign keys.",
738
1665
  "- Use docdex handles when referencing docs.",
739
1666
  "- acceptanceCriteria must be an array of strings (5-10 items).",
1667
+ "- Prefer dependency-first sequencing: foundational codebase/service setup epics should precede dependent feature epics.",
1668
+ "- Keep output technology-agnostic and derived from docs; do not assume specific stacks unless docs state them.",
740
1669
  limits || "Use reasonable scope without over-generating epics.",
741
1670
  "Docs available:",
742
1671
  docSummary || "- (no docs provided; propose sensible epics).",
@@ -973,6 +1902,9 @@ export class CreateTasksService {
973
1902
  "- When known, include qa object with profiles_expected/requires/entrypoints/data_setup to guide QA.",
974
1903
  "- Do not hardcode ports. For QA entrypoints, use http://localhost:<PORT> placeholders or omit base_url when unknown.",
975
1904
  "- dependsOnKeys must reference localIds in this story.",
1905
+ "- Start from prerequisite codebase setup: add structure/bootstrap tasks before feature tasks when missing.",
1906
+ "- 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).",
976
1908
  "- Use docdex handles when citing docs.",
977
1909
  `Story context (key=${story.key ?? story.localId ?? "TBD"}):`,
978
1910
  story.description ?? story.userStory ?? "",
@@ -1124,21 +2056,24 @@ export class CreateTasksService {
1124
2056
  for (const story of storyMeta) {
1125
2057
  const storyId = storyIdByKey.get(story.storyKey);
1126
2058
  const existingTaskKeys = storyId ? await this.workspaceRepo.listTaskKeys(storyId) : [];
1127
- const tasks = plan.tasks.filter((t) => t.storyLocalId === story.node.localId);
2059
+ const tasks = plan.tasks.filter((t) => t.storyLocalId === story.node.localId && t.epicLocalId === story.node.epicLocalId);
1128
2060
  const taskKeyGen = createTaskKeyGenerator(story.storyKey, existingTaskKeys);
1129
2061
  for (const task of tasks) {
1130
2062
  const key = taskKeyGen();
1131
2063
  const localId = task.localId ?? key;
1132
2064
  taskDetails.push({
1133
2065
  localId,
2066
+ epicLocalId: story.node.epicLocalId,
1134
2067
  key,
2068
+ storyLocalId: story.node.localId,
1135
2069
  storyKey: story.storyKey,
1136
2070
  epicKey: story.epicKey,
1137
2071
  plan: task,
1138
2072
  });
1139
2073
  }
1140
2074
  }
1141
- const localToKey = new Map(taskDetails.map((t) => [t.localId, t.key]));
2075
+ const scopedLocalKey = (epicLocalId, storyLocalId, localId) => this.taskScopeKey(epicLocalId, storyLocalId, localId);
2076
+ const localToKey = new Map(taskDetails.map((t) => [scopedLocalKey(t.epicLocalId, t.storyLocalId, t.localId), t.key]));
1142
2077
  const taskInserts = [];
1143
2078
  const testCommandBuilder = new QaTestCommandBuilder(this.workspace.workspaceRoot);
1144
2079
  for (const task of taskDetails) {
@@ -1198,7 +2133,7 @@ export class CreateTasksService {
1198
2133
  blockers: qaBlockers.length ? qaBlockers : undefined,
1199
2134
  };
1200
2135
  const depSlugs = (task.plan.dependsOnKeys ?? [])
1201
- .map((dep) => localToKey.get(dep))
2136
+ .map((dep) => localToKey.get(scopedLocalKey(task.plan.epicLocalId, task.storyLocalId, dep)))
1202
2137
  .filter((value) => Boolean(value));
1203
2138
  const metadata = {
1204
2139
  doc_links: task.plan.relatedDocs ?? [],
@@ -1235,17 +2170,17 @@ export class CreateTasksService {
1235
2170
  for (const detail of taskDetails) {
1236
2171
  const row = taskRows.find((t) => t.key === detail.key);
1237
2172
  if (row) {
1238
- taskByLocal.set(detail.localId, row);
2173
+ taskByLocal.set(scopedLocalKey(detail.epicLocalId, detail.storyLocalId, detail.localId), row);
1239
2174
  }
1240
2175
  }
1241
2176
  const depKeys = new Set();
1242
2177
  const dependencies = [];
1243
2178
  for (const detail of taskDetails) {
1244
- const current = taskByLocal.get(detail.localId);
2179
+ const current = taskByLocal.get(scopedLocalKey(detail.epicLocalId, detail.storyLocalId, detail.localId));
1245
2180
  if (!current)
1246
2181
  continue;
1247
2182
  for (const dep of detail.plan.dependsOnKeys ?? []) {
1248
- const target = taskByLocal.get(dep);
2183
+ const target = taskByLocal.get(scopedLocalKey(detail.plan.epicLocalId, detail.storyLocalId, dep));
1249
2184
  if (!target || target.id === current.id)
1250
2185
  continue;
1251
2186
  const depKey = `${current.id}|${target.id}|blocks`;
@@ -1340,13 +2275,19 @@ export class CreateTasksService {
1340
2275
  timestamp: new Date().toISOString(),
1341
2276
  details: { epics: epics.length },
1342
2277
  });
1343
- const plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2278
+ let plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
1344
2279
  agentStream,
1345
2280
  jobId: job.id,
1346
2281
  commandRunId: commandRun.id,
1347
2282
  maxStoriesPerEpic: options.maxStoriesPerEpic,
1348
2283
  maxTasksPerStory: options.maxTasksPerStory,
1349
2284
  });
2285
+ plan = this.enforceStoryScopedDependencies(plan);
2286
+ plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
2287
+ plan = this.enforceStoryScopedDependencies(plan);
2288
+ plan = this.applyServiceDependencySequencing(plan, docs);
2289
+ plan = this.enforceStoryScopedDependencies(plan);
2290
+ this.validatePlanLocalIdentifiers(plan);
1350
2291
  await this.jobService.writeCheckpoint(job.id, {
1351
2292
  stage: "stories_generated",
1352
2293
  timestamp: new Date().toISOString(),
@@ -1468,11 +2409,15 @@ export class CreateTasksService {
1468
2409
  name: projectKey,
1469
2410
  description: `Workspace project ${projectKey}`,
1470
2411
  });
1471
- const plan = {
2412
+ let plan = {
1472
2413
  epics: epics,
1473
2414
  stories: stories,
1474
2415
  tasks: tasks,
1475
2416
  };
2417
+ plan = this.enforceStoryScopedDependencies(plan);
2418
+ plan = this.applyServiceDependencySequencing(plan, []);
2419
+ plan = this.enforceStoryScopedDependencies(plan);
2420
+ this.validatePlanLocalIdentifiers(plan);
1476
2421
  const loadRefinePlans = async () => {
1477
2422
  const candidates = [];
1478
2423
  if (options.refinePlanPath)