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