@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.
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +26 -2
- package/dist/services/estimate/EstimateService.d.ts +2 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -1
- package/dist/services/estimate/EstimateService.js +54 -0
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +172 -8
- package/dist/services/planning/CreateTasksService.d.ts +23 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +954 -9
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +17 -4
- package/dist/services/shared/ProjectGuidance.js +1 -1
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|