@mcoda/core 0.1.16 → 0.1.18

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
  {
@@ -584,7 +688,758 @@ export class CreateTasksService {
584
688
  // Ignore missing candidates; fall back to empty inputs.
585
689
  }
586
690
  }
587
- return existing;
691
+ if (existing.length > 0)
692
+ return existing;
693
+ return this.findFuzzyDocInputs();
694
+ }
695
+ async walkDocCandidates(currentDir, depth, collector) {
696
+ if (depth > DOC_SCAN_MAX_DEPTH)
697
+ return;
698
+ let entries = [];
699
+ try {
700
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
701
+ }
702
+ catch {
703
+ return;
704
+ }
705
+ for (const entry of entries) {
706
+ const entryPath = path.join(currentDir, entry.name);
707
+ if (entry.isDirectory()) {
708
+ if (DOC_SCAN_IGNORE_DIRS.has(entry.name.toLowerCase()))
709
+ continue;
710
+ await this.walkDocCandidates(entryPath, depth + 1, collector);
711
+ continue;
712
+ }
713
+ if (entry.isFile()) {
714
+ collector(entryPath);
715
+ }
716
+ }
717
+ }
718
+ scoreDocCandidate(filePath) {
719
+ const workspaceRelative = path.relative(this.workspace.workspaceRoot, filePath).replace(/\\/g, "/").toLowerCase();
720
+ const mcodaRelative = path.relative(this.workspace.mcodaDir, filePath).replace(/\\/g, "/").toLowerCase();
721
+ const relative = workspaceRelative && !workspaceRelative.startsWith("..")
722
+ ? workspaceRelative
723
+ : mcodaRelative && !mcodaRelative.startsWith("..")
724
+ ? mcodaRelative
725
+ : path.basename(filePath).toLowerCase();
726
+ const normalized = `/${relative}`;
727
+ const baseName = path.basename(relative);
728
+ if (!DOC_SCAN_FILE_PATTERN.test(baseName))
729
+ return 0;
730
+ let score = 0;
731
+ if (SDS_LIKE_PATH_PATTERN.test(normalized))
732
+ score += 100;
733
+ if (OPENAPI_LIKE_PATH_PATTERN.test(normalized))
734
+ score += 80;
735
+ if (STRUCTURE_LIKE_PATH_PATTERN.test(normalized))
736
+ score += 30;
737
+ if (normalized.includes("/docs/"))
738
+ score += 20;
739
+ if (normalized.endsWith(".md") || normalized.endsWith(".markdown"))
740
+ score += 10;
741
+ return score;
742
+ }
743
+ async findFuzzyDocInputs() {
744
+ const ranked = [];
745
+ const seen = new Set();
746
+ const collect = (candidate) => {
747
+ const resolved = path.resolve(candidate);
748
+ if (seen.has(resolved))
749
+ return;
750
+ const score = this.scoreDocCandidate(resolved);
751
+ if (score <= 0)
752
+ return;
753
+ ranked.push({ path: resolved, score });
754
+ seen.add(resolved);
755
+ };
756
+ await this.walkDocCandidates(this.workspace.workspaceRoot, 0, collect);
757
+ const mcodaDocs = path.join(this.workspace.mcodaDir, "docs");
758
+ try {
759
+ const stat = await fs.stat(mcodaDocs);
760
+ if (stat.isDirectory()) {
761
+ await this.walkDocCandidates(mcodaDocs, 0, collect);
762
+ }
763
+ }
764
+ catch {
765
+ // Ignore missing workspace docs.
766
+ }
767
+ return ranked
768
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
769
+ .slice(0, 24)
770
+ .map((entry) => entry.path);
771
+ }
772
+ normalizeStructurePathToken(value) {
773
+ const normalized = value
774
+ .replace(/\\/g, "/")
775
+ .replace(/^[./]+/, "")
776
+ .replace(/^\/+/, "")
777
+ .trim();
778
+ if (!normalized)
779
+ return undefined;
780
+ if (normalized.length > 140)
781
+ return undefined;
782
+ if (!normalized.includes("/"))
783
+ return undefined;
784
+ if (normalized.includes("://"))
785
+ return undefined;
786
+ if (/[\u0000-\u001f]/.test(normalized))
787
+ return undefined;
788
+ const parts = normalized.split("/").filter(Boolean);
789
+ if (parts.length < 2)
790
+ return undefined;
791
+ if (parts.some((part) => part === "." || part === ".."))
792
+ return undefined;
793
+ if (DOC_SCAN_IGNORE_DIRS.has(parts[0].toLowerCase()))
794
+ return undefined;
795
+ return parts.join("/");
796
+ }
797
+ extractStructureTargets(docs) {
798
+ const directories = new Set();
799
+ const files = new Set();
800
+ for (const doc of docs) {
801
+ const segments = (doc.segments ?? []).map((segment) => segment.content).filter(Boolean).join("\n");
802
+ const corpus = [doc.title, doc.path, doc.content, segments].filter(Boolean).join("\n");
803
+ for (const match of corpus.matchAll(DOC_PATH_TOKEN_PATTERN)) {
804
+ const token = match[2];
805
+ if (!token)
806
+ continue;
807
+ const normalized = this.normalizeStructurePathToken(token);
808
+ if (!normalized)
809
+ continue;
810
+ if (FILE_EXTENSION_PATTERN.test(path.basename(normalized))) {
811
+ files.add(normalized);
812
+ const parent = path.dirname(normalized).replace(/\\/g, "/");
813
+ if (parent && parent !== ".")
814
+ directories.add(parent);
815
+ }
816
+ else {
817
+ directories.add(normalized);
818
+ }
819
+ }
820
+ }
821
+ return {
822
+ directories: Array.from(directories).sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, 32),
823
+ files: Array.from(files).sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, 32),
824
+ };
825
+ }
826
+ normalizeServiceName(value) {
827
+ const normalized = value
828
+ .toLowerCase()
829
+ .replace(/[`"'()[\]{}]/g, " ")
830
+ .replace(/[._/-]+/g, " ")
831
+ .replace(/[^a-z0-9\s]+/g, " ")
832
+ .replace(/\s+/g, " ")
833
+ .trim();
834
+ if (!normalized)
835
+ return undefined;
836
+ const keepTokens = new Set(["api", "ui", "db", "qa", "ml", "ai", "etl"]);
837
+ const tokens = normalized
838
+ .split(" ")
839
+ .map((token) => token.trim())
840
+ .filter(Boolean)
841
+ .filter((token) => keepTokens.has(token) || !SERVICE_NAME_STOPWORDS.has(token))
842
+ .slice(0, 4);
843
+ if (!tokens.length)
844
+ return undefined;
845
+ const candidate = tokens.join(" ");
846
+ if (SERVICE_NAME_INVALID.has(candidate))
847
+ return undefined;
848
+ return candidate.length >= 2 ? candidate : undefined;
849
+ }
850
+ deriveServiceFromPathToken(pathToken) {
851
+ const parts = pathToken
852
+ .replace(/\\/g, "/")
853
+ .split("/")
854
+ .map((part) => part.trim().toLowerCase())
855
+ .filter(Boolean);
856
+ if (!parts.length)
857
+ return undefined;
858
+ let idx = 0;
859
+ while (idx < parts.length - 1 && SERVICE_PATH_CONTAINER_SEGMENTS.has(parts[idx])) {
860
+ idx += 1;
861
+ }
862
+ return this.normalizeServiceName(parts[idx] ?? parts[0]);
863
+ }
864
+ addServiceAlias(aliases, rawValue) {
865
+ const canonical = this.normalizeServiceName(rawValue);
866
+ if (!canonical)
867
+ return undefined;
868
+ const existing = aliases.get(canonical) ?? new Set();
869
+ existing.add(canonical);
870
+ const alias = rawValue
871
+ .toLowerCase()
872
+ .replace(/[._/-]+/g, " ")
873
+ .replace(/[^a-z0-9\s]+/g, " ")
874
+ .replace(/\s+/g, " ")
875
+ .trim();
876
+ if (alias)
877
+ existing.add(alias);
878
+ aliases.set(canonical, existing);
879
+ return canonical;
880
+ }
881
+ extractServiceMentionsFromText(text) {
882
+ if (!text)
883
+ return [];
884
+ const mentions = new Set();
885
+ for (const match of text.matchAll(SERVICE_LABEL_PATTERN)) {
886
+ const phrase = `${match[1] ?? ""} ${match[2] ?? ""}`.trim();
887
+ const normalized = this.normalizeServiceName(phrase);
888
+ if (normalized)
889
+ mentions.add(normalized);
890
+ }
891
+ for (const match of text.matchAll(DOC_PATH_TOKEN_PATTERN)) {
892
+ const token = match[2];
893
+ if (!token)
894
+ continue;
895
+ const normalized = this.deriveServiceFromPathToken(token);
896
+ if (normalized)
897
+ mentions.add(normalized);
898
+ }
899
+ return Array.from(mentions);
900
+ }
901
+ resolveServiceMentionFromPhrase(phrase, aliases) {
902
+ const normalizedPhrase = phrase
903
+ .toLowerCase()
904
+ .replace(/[._/-]+/g, " ")
905
+ .replace(/[^a-z0-9\s]+/g, " ")
906
+ .replace(/\s+/g, " ")
907
+ .trim();
908
+ if (!normalizedPhrase)
909
+ return undefined;
910
+ let best;
911
+ const haystack = ` ${normalizedPhrase} `;
912
+ for (const [service, names] of aliases.entries()) {
913
+ for (const alias of names) {
914
+ const needle = ` ${alias} `;
915
+ if (!haystack.includes(needle))
916
+ continue;
917
+ if (!best || alias.length > best.aliasLength) {
918
+ best = { key: service, aliasLength: alias.length };
919
+ }
920
+ }
921
+ }
922
+ if (best)
923
+ return best.key;
924
+ const mention = this.extractServiceMentionsFromText(phrase)[0];
925
+ if (!mention)
926
+ return undefined;
927
+ return this.addServiceAlias(aliases, mention);
928
+ }
929
+ collectDependencyStatements(text) {
930
+ const statements = [];
931
+ const lines = text
932
+ .split(/\r?\n/)
933
+ .map((line) => line.trim())
934
+ .filter(Boolean)
935
+ .slice(0, 300);
936
+ const dependencyPatterns = [
937
+ {
938
+ 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,
939
+ dependentGroup: 1,
940
+ dependencyGroup: 2,
941
+ },
942
+ {
943
+ regex: /^(.+?)\b(?:before|prerequisite for)\b(.+)$/i,
944
+ dependentGroup: 2,
945
+ dependencyGroup: 1,
946
+ },
947
+ ];
948
+ for (const rawLine of lines) {
949
+ const line = rawLine.replace(/^[-*]\s+/, "").trim();
950
+ if (!line)
951
+ continue;
952
+ for (const match of line.matchAll(SERVICE_ARROW_PATTERN)) {
953
+ const dependent = match[1]?.trim();
954
+ const dependency = match[2]?.trim();
955
+ if (dependent && dependency) {
956
+ statements.push({ dependent, dependency });
957
+ }
958
+ }
959
+ for (const pattern of dependencyPatterns) {
960
+ const match = line.match(pattern.regex);
961
+ if (!match)
962
+ continue;
963
+ const dependent = match[pattern.dependentGroup]?.trim();
964
+ const dependency = match[pattern.dependencyGroup]?.trim();
965
+ if (!dependent || !dependency)
966
+ continue;
967
+ statements.push({ dependent, dependency });
968
+ }
969
+ }
970
+ return statements;
971
+ }
972
+ sortServicesByDependency(services, dependencies) {
973
+ const nodes = Array.from(new Set(services));
974
+ const indegree = new Map();
975
+ const adjacency = new Map();
976
+ const dependedBy = new Map();
977
+ for (const node of nodes) {
978
+ indegree.set(node, 0);
979
+ dependedBy.set(node, 0);
980
+ }
981
+ for (const [dependent, dependencySet] of dependencies.entries()) {
982
+ if (!indegree.has(dependent)) {
983
+ indegree.set(dependent, 0);
984
+ dependedBy.set(dependent, 0);
985
+ nodes.push(dependent);
986
+ }
987
+ for (const dependency of dependencySet) {
988
+ if (!indegree.has(dependency)) {
989
+ indegree.set(dependency, 0);
990
+ dependedBy.set(dependency, 0);
991
+ nodes.push(dependency);
992
+ }
993
+ indegree.set(dependent, (indegree.get(dependent) ?? 0) + 1);
994
+ const out = adjacency.get(dependency) ?? new Set();
995
+ out.add(dependent);
996
+ adjacency.set(dependency, out);
997
+ dependedBy.set(dependency, (dependedBy.get(dependency) ?? 0) + 1);
998
+ }
999
+ }
1000
+ const compare = (a, b) => {
1001
+ const dependedByA = dependedBy.get(a) ?? 0;
1002
+ const dependedByB = dependedBy.get(b) ?? 0;
1003
+ if (dependedByA !== dependedByB)
1004
+ return dependedByB - dependedByA;
1005
+ return a.localeCompare(b);
1006
+ };
1007
+ const queue = nodes.filter((node) => (indegree.get(node) ?? 0) === 0).sort(compare);
1008
+ const ordered = [];
1009
+ while (queue.length) {
1010
+ const current = queue.shift();
1011
+ if (!current)
1012
+ continue;
1013
+ ordered.push(current);
1014
+ const out = adjacency.get(current);
1015
+ if (!out)
1016
+ continue;
1017
+ for (const neighbor of out) {
1018
+ const nextInDegree = (indegree.get(neighbor) ?? 0) - 1;
1019
+ indegree.set(neighbor, nextInDegree);
1020
+ if (nextInDegree === 0) {
1021
+ queue.push(neighbor);
1022
+ }
1023
+ }
1024
+ queue.sort(compare);
1025
+ }
1026
+ if (ordered.length === nodes.length)
1027
+ return ordered;
1028
+ const remaining = nodes.filter((node) => !ordered.includes(node)).sort(compare);
1029
+ return [...ordered, ...remaining];
1030
+ }
1031
+ buildServiceDependencyGraph(plan, docs) {
1032
+ const aliases = new Map();
1033
+ const dependencies = new Map();
1034
+ const register = (value) => {
1035
+ if (!value)
1036
+ return undefined;
1037
+ return this.addServiceAlias(aliases, value);
1038
+ };
1039
+ const docsText = docs
1040
+ .map((doc) => [doc.title, doc.path, doc.content, ...(doc.segments ?? []).map((segment) => segment.content)].filter(Boolean).join("\n"))
1041
+ .join("\n");
1042
+ const planText = [
1043
+ ...plan.epics.map((epic) => `${epic.title}\n${epic.description ?? ""}`),
1044
+ ...plan.stories.map((story) => `${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`),
1045
+ ...plan.tasks.map((task) => `${task.title}\n${task.description ?? ""}`),
1046
+ ].join("\n");
1047
+ const structureTargets = this.extractStructureTargets(docs);
1048
+ for (const token of [...structureTargets.directories, ...structureTargets.files]) {
1049
+ register(this.deriveServiceFromPathToken(token));
1050
+ }
1051
+ for (const mention of this.extractServiceMentionsFromText(docsText))
1052
+ register(mention);
1053
+ for (const mention of this.extractServiceMentionsFromText(planText))
1054
+ register(mention);
1055
+ const corpus = [docsText, planText].filter(Boolean);
1056
+ for (const text of corpus) {
1057
+ const statements = this.collectDependencyStatements(text);
1058
+ for (const statement of statements) {
1059
+ const dependent = this.resolveServiceMentionFromPhrase(statement.dependent, aliases);
1060
+ const dependency = this.resolveServiceMentionFromPhrase(statement.dependency, aliases);
1061
+ if (!dependent || !dependency || dependent === dependency)
1062
+ continue;
1063
+ const next = dependencies.get(dependent) ?? new Set();
1064
+ next.add(dependency);
1065
+ dependencies.set(dependent, next);
1066
+ }
1067
+ }
1068
+ const services = this.sortServicesByDependency(Array.from(aliases.keys()), dependencies);
1069
+ return { services, dependencies, aliases };
1070
+ }
1071
+ orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByLocalId) {
1072
+ const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
1073
+ const indegree = new Map();
1074
+ const outgoing = new Map();
1075
+ for (const task of storyTasks) {
1076
+ indegree.set(task.localId, 0);
1077
+ }
1078
+ for (const task of storyTasks) {
1079
+ for (const dep of task.dependsOnKeys ?? []) {
1080
+ if (!byLocalId.has(dep) || dep === task.localId)
1081
+ continue;
1082
+ indegree.set(task.localId, (indegree.get(task.localId) ?? 0) + 1);
1083
+ const edges = outgoing.get(dep) ?? new Set();
1084
+ edges.add(task.localId);
1085
+ outgoing.set(dep, edges);
1086
+ }
1087
+ }
1088
+ const priorityComparator = (a, b) => {
1089
+ const classA = classifyTask({ title: a.title ?? "", description: a.description, type: a.type });
1090
+ const classB = classifyTask({ title: b.title ?? "", description: b.description, type: b.type });
1091
+ if (classA.foundation !== classB.foundation)
1092
+ 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;
1095
+ if (rankA !== rankB)
1096
+ return rankA - rankB;
1097
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1098
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1099
+ if (priorityA !== priorityB)
1100
+ return priorityA - priorityB;
1101
+ return a.localId.localeCompare(b.localId);
1102
+ };
1103
+ const queue = storyTasks.filter((task) => (indegree.get(task.localId) ?? 0) === 0).sort(priorityComparator);
1104
+ const ordered = [];
1105
+ const seen = new Set();
1106
+ while (queue.length > 0) {
1107
+ const next = queue.shift();
1108
+ if (!next || seen.has(next.localId))
1109
+ continue;
1110
+ seen.add(next.localId);
1111
+ ordered.push(next);
1112
+ const dependents = outgoing.get(next.localId);
1113
+ if (!dependents)
1114
+ continue;
1115
+ for (const dependent of dependents) {
1116
+ const updated = (indegree.get(dependent) ?? 0) - 1;
1117
+ indegree.set(dependent, updated);
1118
+ if (updated === 0) {
1119
+ const depTask = byLocalId.get(dependent);
1120
+ if (depTask)
1121
+ queue.push(depTask);
1122
+ }
1123
+ }
1124
+ queue.sort(priorityComparator);
1125
+ }
1126
+ if (ordered.length === storyTasks.length)
1127
+ return ordered;
1128
+ const remaining = storyTasks.filter((task) => !seen.has(task.localId)).sort(priorityComparator);
1129
+ return [...ordered, ...remaining];
1130
+ }
1131
+ applyServiceDependencySequencing(plan, docs) {
1132
+ const graph = this.buildServiceDependencyGraph(plan, docs);
1133
+ if (!graph.services.length)
1134
+ return plan;
1135
+ const serviceRank = new Map(graph.services.map((service, index) => [service, index]));
1136
+ const resolveEntityService = (text) => this.resolveServiceMentionFromPhrase(text, graph.aliases);
1137
+ const epics = plan.epics.map((epic) => ({ ...epic }));
1138
+ const stories = plan.stories.map((story) => ({ ...story }));
1139
+ 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();
1142
+ for (const task of tasks) {
1143
+ const text = `${task.title ?? ""}\n${task.description ?? ""}`;
1144
+ taskServiceByLocalId.set(task.localId, resolveEntityService(text));
1145
+ }
1146
+ const tasksByStory = new Map();
1147
+ for (const task of tasks) {
1148
+ const bucket = tasksByStory.get(task.storyLocalId) ?? [];
1149
+ bucket.push(task);
1150
+ tasksByStory.set(task.storyLocalId, bucket);
1151
+ }
1152
+ for (const storyTasks of tasksByStory.values()) {
1153
+ const tasksByService = new Map();
1154
+ for (const task of storyTasks) {
1155
+ const service = taskServiceByLocalId.get(task.localId);
1156
+ if (!service)
1157
+ continue;
1158
+ const serviceTasks = tasksByService.get(service) ?? [];
1159
+ serviceTasks.push(task);
1160
+ tasksByService.set(service, serviceTasks);
1161
+ }
1162
+ for (const serviceTasks of tasksByService.values()) {
1163
+ serviceTasks.sort((a, b) => (a.priorityHint ?? Number.MAX_SAFE_INTEGER) - (b.priorityHint ?? Number.MAX_SAFE_INTEGER));
1164
+ }
1165
+ for (const task of storyTasks) {
1166
+ const service = taskServiceByLocalId.get(task.localId);
1167
+ if (!service)
1168
+ continue;
1169
+ const requiredServices = graph.dependencies.get(service);
1170
+ if (!requiredServices || requiredServices.size === 0)
1171
+ continue;
1172
+ for (const requiredService of requiredServices) {
1173
+ const candidate = tasksByService.get(requiredService)?.[0];
1174
+ if (!candidate || candidate.localId === task.localId)
1175
+ continue;
1176
+ if (!(task.dependsOnKeys ?? []).includes(candidate.localId)) {
1177
+ task.dependsOnKeys = uniqueStrings([...(task.dependsOnKeys ?? []), candidate.localId]);
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ const storyRankByLocalId = new Map();
1183
+ for (const story of stories) {
1184
+ const storyTasks = tasksByStory.get(story.localId) ?? [];
1185
+ const taskRanks = storyTasks
1186
+ .map((task) => serviceRank.get(taskServiceByLocalId.get(task.localId) ?? ""))
1187
+ .filter((value) => typeof value === "number");
1188
+ const storyTextRank = serviceRank.get(resolveEntityService(`${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`) ?? "");
1189
+ const rank = taskRanks.length > 0 ? Math.min(...taskRanks) : storyTextRank ?? Number.MAX_SAFE_INTEGER;
1190
+ storyRankByLocalId.set(story.localId, rank);
1191
+ }
1192
+ const epicRankByLocalId = new Map();
1193
+ for (const epic of epics) {
1194
+ const epicStories = stories.filter((story) => story.epicLocalId === epic.localId);
1195
+ const storyRanks = epicStories
1196
+ .map((story) => storyRankByLocalId.get(story.localId))
1197
+ .filter((value) => typeof value === "number");
1198
+ const epicTextRank = serviceRank.get(resolveEntityService(`${epic.title}\n${epic.description ?? ""}`) ?? "");
1199
+ const rank = storyRanks.length > 0 ? Math.min(...storyRanks) : epicTextRank ?? Number.MAX_SAFE_INTEGER;
1200
+ epicRankByLocalId.set(epic.localId, rank);
1201
+ }
1202
+ const isBootstrap = (value) => /bootstrap|foundation|structure/i.test(value);
1203
+ epics.sort((a, b) => {
1204
+ const bootstrapA = isBootstrap(`${a.title} ${a.description ?? ""}`);
1205
+ const bootstrapB = isBootstrap(`${b.title} ${b.description ?? ""}`);
1206
+ if (bootstrapA !== bootstrapB)
1207
+ return bootstrapA ? -1 : 1;
1208
+ const rankA = epicRankByLocalId.get(a.localId) ?? Number.MAX_SAFE_INTEGER;
1209
+ const rankB = epicRankByLocalId.get(b.localId) ?? Number.MAX_SAFE_INTEGER;
1210
+ if (rankA !== rankB)
1211
+ return rankA - rankB;
1212
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1213
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1214
+ if (priorityA !== priorityB)
1215
+ return priorityA - priorityB;
1216
+ return a.localId.localeCompare(b.localId);
1217
+ });
1218
+ epics.forEach((epic, index) => {
1219
+ epic.priorityHint = index + 1;
1220
+ });
1221
+ const storiesOrdered = [];
1222
+ const tasksOrdered = [];
1223
+ for (const epic of epics) {
1224
+ const epicStories = stories
1225
+ .filter((story) => story.epicLocalId === epic.localId)
1226
+ .sort((a, b) => {
1227
+ const bootstrapA = isBootstrap(`${a.title} ${a.description ?? ""}`);
1228
+ const bootstrapB = isBootstrap(`${b.title} ${b.description ?? ""}`);
1229
+ if (bootstrapA !== bootstrapB)
1230
+ 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;
1233
+ if (rankA !== rankB)
1234
+ return rankA - rankB;
1235
+ const priorityA = a.priorityHint ?? Number.MAX_SAFE_INTEGER;
1236
+ const priorityB = b.priorityHint ?? Number.MAX_SAFE_INTEGER;
1237
+ if (priorityA !== priorityB)
1238
+ return priorityA - priorityB;
1239
+ return a.localId.localeCompare(b.localId);
1240
+ });
1241
+ epicStories.forEach((story, index) => {
1242
+ story.priorityHint = index + 1;
1243
+ storiesOrdered.push(story);
1244
+ const storyTasks = tasksByStory.get(story.localId) ?? [];
1245
+ const orderedTasks = this.orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByLocalId);
1246
+ orderedTasks.forEach((task, taskIndex) => {
1247
+ task.priorityHint = taskIndex + 1;
1248
+ tasksOrdered.push(task);
1249
+ });
1250
+ });
1251
+ }
1252
+ const orderedStoryIds = new Set(storiesOrdered.map((story) => story.localId));
1253
+ for (const story of stories) {
1254
+ if (orderedStoryIds.has(story.localId))
1255
+ continue;
1256
+ storiesOrdered.push(story);
1257
+ }
1258
+ const orderedTaskIds = new Set(tasksOrdered.map((task) => task.localId));
1259
+ for (const task of tasks) {
1260
+ if (orderedTaskIds.has(task.localId))
1261
+ continue;
1262
+ tasksOrdered.push(task);
1263
+ }
1264
+ // Keep parent linkage intact even if malformed story references exist.
1265
+ for (const story of storiesOrdered) {
1266
+ if (!storyByLocalId.has(story.localId))
1267
+ continue;
1268
+ story.epicLocalId = storyByLocalId.get(story.localId)?.epicLocalId ?? story.epicLocalId;
1269
+ }
1270
+ return { epics, stories: storiesOrdered, tasks: tasksOrdered };
1271
+ }
1272
+ shouldInjectStructureBootstrap(plan, docs) {
1273
+ if (docs.length === 0)
1274
+ return false;
1275
+ return !plan.tasks.some((task) => /codebase structure|folder tree|scaffold|bootstrap|repository layout|project skeleton/i.test(`${task.title} ${task.description ?? ""}`));
1276
+ }
1277
+ injectStructureBootstrapPlan(plan, docs, projectKey) {
1278
+ if (!this.shouldInjectStructureBootstrap(plan, docs))
1279
+ return plan;
1280
+ const localIds = new Set([
1281
+ ...plan.epics.map((epic) => epic.localId),
1282
+ ...plan.stories.map((story) => story.localId),
1283
+ ...plan.tasks.map((task) => task.localId),
1284
+ ]);
1285
+ const epicLocalId = nextUniqueLocalId("bootstrap-epic", localIds);
1286
+ const storyLocalId = nextUniqueLocalId("bootstrap-story", localIds);
1287
+ const task1LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1288
+ const task2LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1289
+ const task3LocalId = nextUniqueLocalId("bootstrap-task", localIds);
1290
+ const structureTargets = this.extractStructureTargets(docs);
1291
+ const directoryPreview = structureTargets.directories.length
1292
+ ? structureTargets.directories.slice(0, 20).map((item) => `- ${item}`).join("\n")
1293
+ : "- Infer top-level source directories from SDS sections and create them.";
1294
+ const filePreview = structureTargets.files.length
1295
+ ? structureTargets.files.slice(0, 20).map((item) => `- ${item}`).join("\n")
1296
+ : "- Create minimal entrypoint/config placeholders required by the SDS-defined architecture.";
1297
+ const relatedDocs = docs
1298
+ .map((doc) => (doc.id ? `docdex:${doc.id}` : undefined))
1299
+ .filter((value) => Boolean(value))
1300
+ .slice(0, 12);
1301
+ const bootstrapEpic = {
1302
+ localId: epicLocalId,
1303
+ area: normalizeArea(projectKey) ?? "infra",
1304
+ title: "Codebase Foundation and Structure Setup",
1305
+ description: "Create the SDS-defined codebase scaffold first (folders/files/service boundaries) before feature implementation tasks.",
1306
+ acceptanceCriteria: [
1307
+ "Required folder tree exists for the planned architecture.",
1308
+ "Minimal entrypoint/config files exist for each discovered service/module.",
1309
+ "Service dependency assumptions are explicit and actionable in follow-up tasks.",
1310
+ ],
1311
+ relatedDocs,
1312
+ priorityHint: 1,
1313
+ stories: [],
1314
+ };
1315
+ const bootstrapStory = {
1316
+ localId: storyLocalId,
1317
+ epicLocalId,
1318
+ title: "Bootstrap repository structure from SDS",
1319
+ userStory: "As an engineer, I want a concrete codebase scaffold first so implementation tasks can target real modules instead of only tests.",
1320
+ description: [
1321
+ "Parse SDS/PDR/OpenAPI context and establish the expected folder/file tree.",
1322
+ "Start with dependencies-first service ordering (foundational components before dependents).",
1323
+ ].join("\n"),
1324
+ acceptanceCriteria: [
1325
+ "Repository scaffold matches documented architecture at a high level.",
1326
+ "Core service/module placeholders are committed as executable starting points.",
1327
+ "Follow-up tasks reference real directories/files under the scaffold.",
1328
+ ],
1329
+ relatedDocs,
1330
+ priorityHint: 1,
1331
+ tasks: [],
1332
+ };
1333
+ const bootstrapTasks = [
1334
+ {
1335
+ localId: task1LocalId,
1336
+ storyLocalId,
1337
+ epicLocalId,
1338
+ title: "Create SDS-aligned folder tree",
1339
+ type: "chore",
1340
+ description: [
1341
+ "Create the initial folder tree inferred from SDS and related docs.",
1342
+ "Target directories:",
1343
+ directoryPreview,
1344
+ ].join("\n"),
1345
+ estimatedStoryPoints: 2,
1346
+ priorityHint: 1,
1347
+ dependsOnKeys: [],
1348
+ relatedDocs,
1349
+ unitTests: [],
1350
+ componentTests: [],
1351
+ integrationTests: [],
1352
+ apiTests: [],
1353
+ },
1354
+ {
1355
+ localId: task2LocalId,
1356
+ storyLocalId,
1357
+ epicLocalId,
1358
+ title: "Create foundational file stubs for discovered modules",
1359
+ type: "chore",
1360
+ description: [
1361
+ "Create minimal file stubs/config entrypoints for the scaffolded modules/services.",
1362
+ "Target files:",
1363
+ filePreview,
1364
+ ].join("\n"),
1365
+ estimatedStoryPoints: 3,
1366
+ priorityHint: 2,
1367
+ dependsOnKeys: [task1LocalId],
1368
+ relatedDocs,
1369
+ unitTests: [],
1370
+ componentTests: [],
1371
+ integrationTests: [],
1372
+ apiTests: [],
1373
+ },
1374
+ {
1375
+ localId: task3LocalId,
1376
+ storyLocalId,
1377
+ epicLocalId,
1378
+ title: "Define service dependency baseline for implementation sequencing",
1379
+ type: "spike",
1380
+ description: "Document and codify service/module dependency direction so highly depended foundational services are implemented first.",
1381
+ estimatedStoryPoints: 2,
1382
+ priorityHint: 3,
1383
+ dependsOnKeys: [task2LocalId],
1384
+ relatedDocs,
1385
+ unitTests: [],
1386
+ componentTests: [],
1387
+ integrationTests: [],
1388
+ apiTests: [],
1389
+ },
1390
+ ];
1391
+ return {
1392
+ epics: [bootstrapEpic, ...plan.epics],
1393
+ stories: [bootstrapStory, ...plan.stories],
1394
+ tasks: [...bootstrapTasks, ...plan.tasks],
1395
+ };
1396
+ }
1397
+ enforceStoryScopedDependencies(plan) {
1398
+ const scopedLocalKey = (storyLocalId, localId) => `${storyLocalId}::${localId}`;
1399
+ const taskMap = new Map(plan.tasks.map((task) => [
1400
+ scopedLocalKey(task.storyLocalId, task.localId),
1401
+ {
1402
+ ...task,
1403
+ dependsOnKeys: uniqueStrings((task.dependsOnKeys ?? []).filter(Boolean)),
1404
+ },
1405
+ ]));
1406
+ const tasksByStory = new Map();
1407
+ for (const task of taskMap.values()) {
1408
+ const storyTasks = tasksByStory.get(task.storyLocalId) ?? [];
1409
+ storyTasks.push(task);
1410
+ tasksByStory.set(task.storyLocalId, storyTasks);
1411
+ }
1412
+ for (const storyTasks of tasksByStory.values()) {
1413
+ const localIds = new Set(storyTasks.map((task) => task.localId));
1414
+ const foundationTasks = storyTasks
1415
+ .filter((task) => classifyTask({
1416
+ title: task.title ?? "",
1417
+ description: task.description,
1418
+ type: task.type,
1419
+ }).foundation)
1420
+ .sort((a, b) => (a.priorityHint ?? Number.MAX_SAFE_INTEGER) - (b.priorityHint ?? Number.MAX_SAFE_INTEGER));
1421
+ const foundationAnchor = foundationTasks.find((task) => !(task.dependsOnKeys ?? []).some((dep) => localIds.has(dep)))?.localId ??
1422
+ foundationTasks[0]?.localId;
1423
+ for (const task of storyTasks) {
1424
+ const filtered = (task.dependsOnKeys ?? []).filter((dep) => dep !== task.localId && localIds.has(dep));
1425
+ const classification = classifyTask({
1426
+ title: task.title ?? "",
1427
+ description: task.description,
1428
+ type: task.type,
1429
+ });
1430
+ if (foundationAnchor &&
1431
+ foundationAnchor !== task.localId &&
1432
+ !classification.foundation &&
1433
+ !filtered.includes(foundationAnchor)) {
1434
+ filtered.push(foundationAnchor);
1435
+ }
1436
+ task.dependsOnKeys = uniqueStrings(filtered);
1437
+ }
1438
+ }
1439
+ return {
1440
+ ...plan,
1441
+ tasks: plan.tasks.map((task) => taskMap.get(scopedLocalKey(task.storyLocalId, task.localId)) ?? task),
1442
+ };
588
1443
  }
589
1444
  async buildQaPreflight() {
590
1445
  const preflight = {
@@ -737,6 +1592,8 @@ export class CreateTasksService {
737
1592
  "- Do NOT include final slugs; the system will assign keys.",
738
1593
  "- Use docdex handles when referencing docs.",
739
1594
  "- acceptanceCriteria must be an array of strings (5-10 items).",
1595
+ "- Prefer dependency-first sequencing: foundational codebase/service setup epics should precede dependent feature epics.",
1596
+ "- Keep output technology-agnostic and derived from docs; do not assume specific stacks unless docs state them.",
740
1597
  limits || "Use reasonable scope without over-generating epics.",
741
1598
  "Docs available:",
742
1599
  docSummary || "- (no docs provided; propose sensible epics).",
@@ -973,6 +1830,9 @@ export class CreateTasksService {
973
1830
  "- When known, include qa object with profiles_expected/requires/entrypoints/data_setup to guide QA.",
974
1831
  "- Do not hardcode ports. For QA entrypoints, use http://localhost:<PORT> placeholders or omit base_url when unknown.",
975
1832
  "- dependsOnKeys must reference localIds in this story.",
1833
+ "- Start from prerequisite codebase setup: add structure/bootstrap tasks before feature tasks when missing.",
1834
+ "- 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).",
976
1836
  "- Use docdex handles when citing docs.",
977
1837
  `Story context (key=${story.key ?? story.localId ?? "TBD"}):`,
978
1838
  story.description ?? story.userStory ?? "",
@@ -1132,13 +1992,15 @@ export class CreateTasksService {
1132
1992
  taskDetails.push({
1133
1993
  localId,
1134
1994
  key,
1995
+ storyLocalId: story.node.localId,
1135
1996
  storyKey: story.storyKey,
1136
1997
  epicKey: story.epicKey,
1137
1998
  plan: task,
1138
1999
  });
1139
2000
  }
1140
2001
  }
1141
- const localToKey = new Map(taskDetails.map((t) => [t.localId, t.key]));
2002
+ const scopedLocalKey = (storyLocalId, localId) => `${storyLocalId}::${localId}`;
2003
+ const localToKey = new Map(taskDetails.map((t) => [scopedLocalKey(t.storyLocalId, t.localId), t.key]));
1142
2004
  const taskInserts = [];
1143
2005
  const testCommandBuilder = new QaTestCommandBuilder(this.workspace.workspaceRoot);
1144
2006
  for (const task of taskDetails) {
@@ -1198,7 +2060,7 @@ export class CreateTasksService {
1198
2060
  blockers: qaBlockers.length ? qaBlockers : undefined,
1199
2061
  };
1200
2062
  const depSlugs = (task.plan.dependsOnKeys ?? [])
1201
- .map((dep) => localToKey.get(dep))
2063
+ .map((dep) => localToKey.get(scopedLocalKey(task.storyLocalId, dep)))
1202
2064
  .filter((value) => Boolean(value));
1203
2065
  const metadata = {
1204
2066
  doc_links: task.plan.relatedDocs ?? [],
@@ -1235,17 +2097,17 @@ export class CreateTasksService {
1235
2097
  for (const detail of taskDetails) {
1236
2098
  const row = taskRows.find((t) => t.key === detail.key);
1237
2099
  if (row) {
1238
- taskByLocal.set(detail.localId, row);
2100
+ taskByLocal.set(scopedLocalKey(detail.storyLocalId, detail.localId), row);
1239
2101
  }
1240
2102
  }
1241
2103
  const depKeys = new Set();
1242
2104
  const dependencies = [];
1243
2105
  for (const detail of taskDetails) {
1244
- const current = taskByLocal.get(detail.localId);
2106
+ const current = taskByLocal.get(scopedLocalKey(detail.storyLocalId, detail.localId));
1245
2107
  if (!current)
1246
2108
  continue;
1247
2109
  for (const dep of detail.plan.dependsOnKeys ?? []) {
1248
- const target = taskByLocal.get(dep);
2110
+ const target = taskByLocal.get(scopedLocalKey(detail.storyLocalId, dep));
1249
2111
  if (!target || target.id === current.id)
1250
2112
  continue;
1251
2113
  const depKey = `${current.id}|${target.id}|blocks`;
@@ -1340,13 +2202,18 @@ export class CreateTasksService {
1340
2202
  timestamp: new Date().toISOString(),
1341
2203
  details: { epics: epics.length },
1342
2204
  });
1343
- const plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
2205
+ let plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
1344
2206
  agentStream,
1345
2207
  jobId: job.id,
1346
2208
  commandRunId: commandRun.id,
1347
2209
  maxStoriesPerEpic: options.maxStoriesPerEpic,
1348
2210
  maxTasksPerStory: options.maxTasksPerStory,
1349
2211
  });
2212
+ plan = this.enforceStoryScopedDependencies(plan);
2213
+ plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
2214
+ plan = this.enforceStoryScopedDependencies(plan);
2215
+ plan = this.applyServiceDependencySequencing(plan, docs);
2216
+ plan = this.enforceStoryScopedDependencies(plan);
1350
2217
  await this.jobService.writeCheckpoint(job.id, {
1351
2218
  stage: "stories_generated",
1352
2219
  timestamp: new Date().toISOString(),
@@ -1468,11 +2335,14 @@ export class CreateTasksService {
1468
2335
  name: projectKey,
1469
2336
  description: `Workspace project ${projectKey}`,
1470
2337
  });
1471
- const plan = {
2338
+ let plan = {
1472
2339
  epics: epics,
1473
2340
  stories: stories,
1474
2341
  tasks: tasks,
1475
2342
  };
2343
+ plan = this.enforceStoryScopedDependencies(plan);
2344
+ plan = this.applyServiceDependencySequencing(plan, []);
2345
+ plan = this.enforceStoryScopedDependencies(plan);
1476
2346
  const loadRefinePlans = async () => {
1477
2347
  const candidates = [];
1478
2348
  if (options.refinePlanPath)