@mcoda/core 0.1.35 → 0.1.36

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.
@@ -14,7 +14,7 @@ import { QaTestCommandBuilder } from "../execution/QaTestCommandBuilder.js";
14
14
  import { createEpicKeyGenerator, createStoryKeyGenerator, createTaskKeyGenerator, } from "./KeyHelpers.js";
15
15
  import { collectSdsCoverageSignalsFromDocs, evaluateSdsCoverage, normalizeCoverageText, } from "./SdsCoverageModel.js";
16
16
  import { collectSdsImplementationSignals, extractStructuredPaths, filterImplementationStructuredPaths, headingLooksImplementationRelevant, isStructuredFilePath, normalizeHeadingCandidate, normalizeStructuredPathToken, stripManagedSdsPreflightBlock, } from "./SdsStructureSignals.js";
17
- import { TaskSufficiencyService } from "./TaskSufficiencyService.js";
17
+ import { TaskSufficiencyService, } from "./TaskSufficiencyService.js";
18
18
  import { SdsPreflightService } from "./SdsPreflightService.js";
19
19
  const formatBullets = (items, fallback) => {
20
20
  if (!items || items.length === 0)
@@ -832,6 +832,111 @@ const SERVICE_ARROW_PATTERN = /([A-Za-z][A-Za-z0-9 _/-]{1,80})\s*(?:->|=>|→)\s
832
832
  const SERVICE_HANDLE_PATTERN = /\b((?:svc|ui|worker)-[a-z0-9-*]+)\b/gi;
833
833
  const WAVE_LABEL_PATTERN = /\bwave\s*([0-9]{1,2})\b/i;
834
834
  const TOPOLOGY_HEADING_PATTERN = /\b(service|services|component|components|module|modules|interface|interfaces|runtime|runtimes|worker|workers|client|clients|gateway|gateways|server|servers|engine|engines|pipeline|pipelines|registry|registries|adapter|adapters|processor|processors|daemon|daemons|ops|operations|deployment|deployments|topology)\b/i;
835
+ const MARKDOWN_HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*$/;
836
+ const RUNTIME_COMPONENTS_HEADING_PATTERN = /\b(runtime components?|runtime topology|system components?|component topology|services?)\b/i;
837
+ const VERIFICATION_MATRIX_HEADING_PATTERN = /\b(verification matrix|validation matrix|test matrix|verification suites?)\b/i;
838
+ const ACCEPTANCE_SCENARIOS_HEADING_PATTERN = /\b(required )?acceptance scenarios?\b/i;
839
+ const BUILD_TARGET_RUNTIME_SEGMENTS = new Set([
840
+ "api",
841
+ "app",
842
+ "apps",
843
+ "bin",
844
+ "cli",
845
+ "client",
846
+ "clients",
847
+ "cmd",
848
+ "command",
849
+ "commands",
850
+ "engine",
851
+ "engines",
852
+ "feature",
853
+ "features",
854
+ "gateway",
855
+ "gateways",
856
+ "handler",
857
+ "handlers",
858
+ "module",
859
+ "modules",
860
+ "page",
861
+ "pages",
862
+ "processor",
863
+ "processors",
864
+ "route",
865
+ "routes",
866
+ "screen",
867
+ "screens",
868
+ "server",
869
+ "servers",
870
+ "service",
871
+ "services",
872
+ "src",
873
+ "ui",
874
+ "web",
875
+ "worker",
876
+ "workers",
877
+ ]);
878
+ const BUILD_TARGET_INTERFACE_SEGMENTS = new Set([
879
+ "contract",
880
+ "contracts",
881
+ "dto",
882
+ "dtos",
883
+ "interface",
884
+ "interfaces",
885
+ "proto",
886
+ "protocol",
887
+ "protocols",
888
+ "schema",
889
+ "schemas",
890
+ "spec",
891
+ "specs",
892
+ "type",
893
+ "types",
894
+ ]);
895
+ const BUILD_TARGET_DATA_SEGMENTS = new Set([
896
+ "cache",
897
+ "caches",
898
+ "data",
899
+ "db",
900
+ "ledger",
901
+ "migration",
902
+ "migrations",
903
+ "model",
904
+ "models",
905
+ "persistence",
906
+ "repository",
907
+ "repositories",
908
+ "storage",
909
+ ]);
910
+ const BUILD_TARGET_TEST_SEGMENTS = new Set([
911
+ "acceptance",
912
+ "e2e",
913
+ "integration",
914
+ "spec",
915
+ "specs",
916
+ "test",
917
+ "tests",
918
+ ]);
919
+ const BUILD_TARGET_OPS_SEGMENTS = new Set([
920
+ "deploy",
921
+ "deployment",
922
+ "deployments",
923
+ "helm",
924
+ "infra",
925
+ "k8s",
926
+ "ops",
927
+ "operation",
928
+ "operations",
929
+ "runbook",
930
+ "runbooks",
931
+ "script",
932
+ "scripts",
933
+ "systemd",
934
+ "terraform",
935
+ ]);
936
+ const BUILD_TARGET_DOC_SEGMENTS = new Set(["docs", "policy", "policies", "rfp", "pdr", "sds"]);
937
+ const MANIFEST_TARGET_BASENAME_PATTERN = /^(package\.json|pnpm-workspace\.yaml|pnpm-lock\.yaml|turbo\.json|tsconfig(?:\.[^.]+)?\.json|eslint(?:\.[^.]+)?\.(?:js|cjs|mjs|json)|prettier(?:\.[^.]+)?\.(?:js|cjs|mjs|json)|vite\.config\.[^.]+|webpack\.config\.[^.]+|rollup\.config\.[^.]+|cargo\.toml|pyproject\.toml|go\.mod|go\.sum|pom\.xml|build\.gradle(?:\.kts)?|settings\.gradle(?:\.kts)?|requirements\.txt|poetry\.lock|foundry\.toml|hardhat\.config\.[^.]+)$/i;
938
+ const SERVICE_ARTIFACT_BASENAME_PATTERN = /(?:\.service|\.socket|\.timer|(?:^|[.-])compose\.(?:ya?ml|json)$|docker-compose\.(?:ya?ml|json)$)$/i;
939
+ const GENERIC_IMPLEMENTATION_TASK_PATTERN = /update the concrete .* modules surfaced by the sds/i;
835
940
  const nextUniqueLocalId = (prefix, existing) => {
836
941
  let index = 1;
837
942
  let candidate = `${prefix}-${index}`;
@@ -917,6 +1022,48 @@ const TASK_SCHEMA_SNIPPET = `{
917
1022
  }
918
1023
  ]
919
1024
  }`;
1025
+ const FULL_PLAN_SCHEMA_SNIPPET = `{
1026
+ "epics": [
1027
+ {
1028
+ "localId": "e1",
1029
+ "area": "documented-area-label",
1030
+ "title": "Epic title",
1031
+ "description": "Epic description using the epic template",
1032
+ "acceptanceCriteria": ["criterion"],
1033
+ "relatedDocs": ["docdex:..."],
1034
+ "priorityHint": 50,
1035
+ "serviceIds": ["backend-api"],
1036
+ "tags": ["cross_service"],
1037
+ "stories": [
1038
+ {
1039
+ "localId": "us1",
1040
+ "title": "Story title",
1041
+ "userStory": "As a ...",
1042
+ "description": "Story description using the template",
1043
+ "acceptanceCriteria": ["criterion"],
1044
+ "relatedDocs": ["docdex:..."],
1045
+ "priorityHint": 50,
1046
+ "tasks": [
1047
+ {
1048
+ "localId": "t1",
1049
+ "title": "Task title",
1050
+ "type": "feature|bug|chore|spike",
1051
+ "description": "Task description using the template",
1052
+ "estimatedStoryPoints": 3,
1053
+ "priorityHint": 50,
1054
+ "dependsOnKeys": ["t0"],
1055
+ "relatedDocs": ["docdex:..."],
1056
+ "unitTests": ["unit test description"],
1057
+ "componentTests": ["component test description"],
1058
+ "integrationTests": ["integration test description"],
1059
+ "apiTests": ["api test description"]
1060
+ }
1061
+ ]
1062
+ }
1063
+ ]
1064
+ }
1065
+ ]
1066
+ }`;
920
1067
  export class CreateTasksService {
921
1068
  constructor(workspace, deps) {
922
1069
  this.workspace = workspace;
@@ -1779,11 +1926,18 @@ export class CreateTasksService {
1779
1926
  buildServiceDependencyGraph(plan, docs) {
1780
1927
  const aliases = new Map();
1781
1928
  const dependencies = new Map();
1929
+ const sourceBackedServices = new Set();
1782
1930
  const register = (value) => {
1783
1931
  if (!value)
1784
1932
  return undefined;
1785
1933
  return this.addServiceAlias(aliases, value);
1786
1934
  };
1935
+ const registerSourceBacked = (value) => {
1936
+ const canonical = register(value);
1937
+ if (canonical)
1938
+ sourceBackedServices.add(canonical);
1939
+ return canonical;
1940
+ };
1787
1941
  const docsText = docs
1788
1942
  .map((doc) => [doc.title, doc.path, doc.content, ...(doc.segments ?? []).map((segment) => segment.content)].filter(Boolean).join("\n"))
1789
1943
  .join("\n");
@@ -1805,14 +1959,25 @@ export class CreateTasksService {
1805
1959
  structureTokens.some((candidate) => candidate !== token && candidate.startsWith(`${token}/`))) {
1806
1960
  continue;
1807
1961
  }
1808
- register(this.deriveServiceFromPathToken(token));
1962
+ registerSourceBacked(this.deriveServiceFromPathToken(token));
1963
+ }
1964
+ for (const component of this.extractRuntimeComponentNames(docs)) {
1965
+ registerSourceBacked(component);
1809
1966
  }
1810
1967
  for (const match of docsText.matchAll(SERVICE_HANDLE_PATTERN))
1811
- register(match[1]);
1812
- for (const match of planText.matchAll(SERVICE_HANDLE_PATTERN))
1813
- register(match[1]);
1814
- for (const mention of this.extractServiceMentionsFromText(planText))
1815
- register(mention);
1968
+ registerSourceBacked(match[1]);
1969
+ const docsHaveRuntimeTopologySignals = sourceBackedServices.size > 0 &&
1970
+ (structureTargets.directories.length > 0 ||
1971
+ structureTargets.files.length > 0 ||
1972
+ this.collectDependencyStatements(docsText).length > 0 ||
1973
+ WAVE_LABEL_PATTERN.test(docsText) ||
1974
+ TOPOLOGY_HEADING_PATTERN.test(docsText));
1975
+ if (!docsHaveRuntimeTopologySignals) {
1976
+ for (const match of planText.matchAll(SERVICE_HANDLE_PATTERN))
1977
+ register(match[1]);
1978
+ for (const mention of this.extractServiceMentionsFromText(planText))
1979
+ register(mention);
1980
+ }
1816
1981
  const corpus = [docsText, planText].filter(Boolean);
1817
1982
  for (const text of corpus) {
1818
1983
  const statements = this.collectDependencyStatements(text);
@@ -1841,7 +2006,8 @@ export class CreateTasksService {
1841
2006
  const structureTargets = this.extractStructureTargets(docs);
1842
2007
  const structureServices = uniqueStrings([...structureTargets.directories, ...structureTargets.files]
1843
2008
  .map((token) => this.deriveServiceFromPathToken(token))
1844
- .filter((value) => Boolean(value))).slice(0, 24);
2009
+ .filter((value) => Boolean(value))
2010
+ .concat(this.extractRuntimeComponentNames(docs))).slice(0, 24);
1845
2011
  const topologyHeadings = this.extractSdsSectionCandidates(docs, 64)
1846
2012
  .filter((heading) => TOPOLOGY_HEADING_PATTERN.test(heading))
1847
2013
  .slice(0, 24);
@@ -1862,29 +2028,231 @@ export class CreateTasksService {
1862
2028
  waveMentions,
1863
2029
  };
1864
2030
  }
1865
- validateTopologyExtraction(projectKey, docs, graph) {
1866
- const topologySignals = this.summarizeTopologySignals(docs);
1867
- const hasServiceSignals = topologySignals.structureServices.length > 0 ||
1868
- topologySignals.topologyHeadings.length > 0 ||
1869
- topologySignals.dependencyPairs.length > 0;
1870
- if (hasServiceSignals && graph.services.length === 0) {
1871
- const signalSummary = uniqueStrings([
1872
- ...topologySignals.structureServices.map((service) => `structure:${service}`),
1873
- ...topologySignals.topologyHeadings.map((heading) => `heading:${heading}`),
1874
- ...topologySignals.dependencyPairs.map((pair) => `dependency:${pair}`),
1875
- ])
1876
- .slice(0, 8)
1877
- .join("; ");
1878
- throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". SDS includes runtime topology signals but no services were resolved. Signals: ${signalSummary || "unavailable"}`);
1879
- }
1880
- if (topologySignals.waveMentions.length > 0 && graph.startupWaves.length === 0) {
2031
+ buildSourceTopologyExpectation(docs) {
2032
+ const signalSummary = this.summarizeTopologySignals(docs);
2033
+ const docsOnlyGraph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
2034
+ return {
2035
+ runtimeBearing: docsOnlyGraph.services.length > 0 ||
2036
+ docsOnlyGraph.startupWaves.length > 0 ||
2037
+ signalSummary.dependencyPairs.length > 0,
2038
+ services: docsOnlyGraph.services,
2039
+ startupWaves: docsOnlyGraph.startupWaves.map((wave) => ({
2040
+ wave: wave.wave,
2041
+ services: [...wave.services],
2042
+ })),
2043
+ dependencyPairs: signalSummary.dependencyPairs,
2044
+ signalSummary,
2045
+ };
2046
+ }
2047
+ buildCanonicalNameInventory(docs) {
2048
+ const structureTargets = this.extractStructureTargets(docs);
2049
+ const paths = uniqueStrings([...structureTargets.directories, ...structureTargets.files]
2050
+ .map((token) => this.normalizeStructurePathToken(token))
2051
+ .filter((value) => Boolean(value))).sort((a, b) => a.length - b.length || a.localeCompare(b));
2052
+ const graph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
2053
+ const serviceAliases = new Map();
2054
+ for (const [service, aliases] of graph.aliases.entries()) {
2055
+ serviceAliases.set(service, new Set(aliases));
2056
+ }
2057
+ for (const service of graph.services) {
2058
+ const existing = serviceAliases.get(service) ?? new Set();
2059
+ existing.add(service);
2060
+ serviceAliases.set(service, existing);
2061
+ }
2062
+ return {
2063
+ paths,
2064
+ pathSet: new Set(paths),
2065
+ services: [...graph.services],
2066
+ serviceAliases,
2067
+ };
2068
+ }
2069
+ countCommonPrefixSegments(left, right) {
2070
+ let count = 0;
2071
+ while (count < left.length && count < right.length && left[count] === right[count]) {
2072
+ count += 1;
2073
+ }
2074
+ return count;
2075
+ }
2076
+ countCommonSuffixSegments(left, right, prefixFloor = 0) {
2077
+ let count = 0;
2078
+ while (count < left.length - prefixFloor &&
2079
+ count < right.length - prefixFloor &&
2080
+ left[left.length - 1 - count] === right[right.length - 1 - count]) {
2081
+ count += 1;
2082
+ }
2083
+ return count;
2084
+ }
2085
+ tokenizeCanonicalName(value) {
2086
+ return this.normalizeServiceLookupKey(value)
2087
+ .split(" ")
2088
+ .map((token) => token.trim())
2089
+ .filter(Boolean);
2090
+ }
2091
+ namesSemanticallyCollide(candidate, canonical) {
2092
+ const candidateTokens = this.tokenizeCanonicalName(candidate);
2093
+ const canonicalTokens = this.tokenizeCanonicalName(canonical);
2094
+ if (candidateTokens.length === 0 || canonicalTokens.length === 0)
2095
+ return false;
2096
+ const canonicalSet = new Set(canonicalTokens);
2097
+ const shared = candidateTokens.filter((token) => canonicalSet.has(token));
2098
+ if (shared.length === 0)
2099
+ return false;
2100
+ if (shared.length === Math.min(candidateTokens.length, canonicalTokens.length))
2101
+ return true;
2102
+ const candidateNormalized = candidateTokens.join(" ");
2103
+ const canonicalNormalized = canonicalTokens.join(" ");
2104
+ return (candidateNormalized.includes(canonicalNormalized) || canonicalNormalized.includes(candidateNormalized));
2105
+ }
2106
+ findCanonicalPathConflict(candidatePath, inventory) {
2107
+ if (!candidatePath || inventory.pathSet.has(candidatePath))
2108
+ return undefined;
2109
+ const candidateParts = candidatePath.split("/").filter(Boolean);
2110
+ let bestMatch;
2111
+ for (const canonicalPath of inventory.paths) {
2112
+ if (candidatePath === canonicalPath)
2113
+ continue;
2114
+ const canonicalParts = canonicalPath.split("/").filter(Boolean);
2115
+ const sharedPrefix = this.countCommonPrefixSegments(candidateParts, canonicalParts);
2116
+ if (sharedPrefix === 0)
2117
+ continue;
2118
+ const sharedSuffix = this.countCommonSuffixSegments(candidateParts, canonicalParts, sharedPrefix);
2119
+ if (sharedSuffix === 0)
2120
+ continue;
2121
+ const candidateCore = candidateParts.slice(sharedPrefix, candidateParts.length - sharedSuffix);
2122
+ const canonicalCore = canonicalParts.slice(sharedPrefix, canonicalParts.length - sharedSuffix);
2123
+ if (candidateCore.length !== 1 || canonicalCore.length !== 1)
2124
+ continue;
2125
+ const candidateSegment = candidateCore[0] ?? "";
2126
+ const canonicalSegment = canonicalCore[0] ?? "";
2127
+ if (!this.namesSemanticallyCollide(candidateSegment, canonicalSegment))
2128
+ continue;
2129
+ const candidateService = this.deriveServiceFromPathToken(candidatePath);
2130
+ const canonicalService = this.deriveServiceFromPathToken(canonicalPath);
2131
+ if (candidateService &&
2132
+ canonicalService &&
2133
+ candidateService !== canonicalService &&
2134
+ !this.namesSemanticallyCollide(candidateService, canonicalService)) {
2135
+ continue;
2136
+ }
2137
+ const score = sharedPrefix + sharedSuffix;
2138
+ if (!bestMatch || score > bestMatch.score || (score === bestMatch.score && canonicalPath.length > bestMatch.canonicalPath.length)) {
2139
+ bestMatch = { canonicalPath, canonicalService, score };
2140
+ }
2141
+ }
2142
+ return bestMatch ? { canonicalPath: bestMatch.canonicalPath, canonicalService: bestMatch.canonicalService } : undefined;
2143
+ }
2144
+ collectCanonicalPlanSources(plan) {
2145
+ const sources = [];
2146
+ for (const epic of plan.epics) {
2147
+ sources.push({
2148
+ location: `epic:${epic.localId}`,
2149
+ text: [epic.title, epic.description, ...(epic.acceptanceCriteria ?? []), ...(epic.serviceIds ?? []), ...(epic.tags ?? [])]
2150
+ .filter(Boolean)
2151
+ .join("\n"),
2152
+ });
2153
+ }
2154
+ for (const story of plan.stories) {
2155
+ sources.push({
2156
+ location: `story:${story.epicLocalId}/${story.localId}`,
2157
+ text: [story.title, story.userStory, story.description, ...(story.acceptanceCriteria ?? [])]
2158
+ .filter(Boolean)
2159
+ .join("\n"),
2160
+ });
2161
+ }
2162
+ for (const task of plan.tasks) {
2163
+ sources.push({
2164
+ location: `task:${task.epicLocalId}/${task.storyLocalId}/${task.localId}`,
2165
+ text: [
2166
+ task.title,
2167
+ task.description,
2168
+ ...(task.relatedDocs ?? []),
2169
+ ...(task.unitTests ?? []),
2170
+ ...(task.componentTests ?? []),
2171
+ ...(task.integrationTests ?? []),
2172
+ ...(task.apiTests ?? []),
2173
+ ]
2174
+ .filter(Boolean)
2175
+ .join("\n"),
2176
+ });
2177
+ }
2178
+ return sources.filter((source) => source.text.trim().length > 0);
2179
+ }
2180
+ assertCanonicalNameConsistency(projectKey, docs, plan) {
2181
+ const inventory = this.buildCanonicalNameInventory(docs);
2182
+ if (inventory.paths.length === 0 && inventory.services.length === 0)
2183
+ return;
2184
+ const conflicts = new Map();
2185
+ for (const source of this.collectCanonicalPlanSources(plan)) {
2186
+ const candidatePaths = uniqueStrings(filterImplementationStructuredPaths(extractStructuredPaths(source.text, 256))
2187
+ .map((token) => this.normalizeStructurePathToken(token))
2188
+ .filter((value) => Boolean(value)));
2189
+ for (const candidatePath of candidatePaths) {
2190
+ if (inventory.pathSet.has(candidatePath))
2191
+ continue;
2192
+ const conflict = this.findCanonicalPathConflict(candidatePath, inventory);
2193
+ if (!conflict)
2194
+ continue;
2195
+ const key = `${source.location}|${candidatePath}|${conflict.canonicalPath}`;
2196
+ conflicts.set(key, {
2197
+ location: source.location,
2198
+ candidate: candidatePath,
2199
+ canonical: conflict.canonicalPath,
2200
+ });
2201
+ }
2202
+ }
2203
+ if (conflicts.size === 0)
2204
+ return;
2205
+ const summary = Array.from(conflicts.values())
2206
+ .slice(0, 8)
2207
+ .map((conflict) => `${conflict.location}: ${conflict.candidate} -> ${conflict.canonical}`)
2208
+ .join("; ");
2209
+ throw new Error(`create-tasks failed canonical name validation for project "${projectKey}". Undocumented alternate implementation paths conflict with source-backed canonical names: ${summary}`);
2210
+ }
2211
+ formatTopologySignalSummary(signalSummary) {
2212
+ return uniqueStrings([
2213
+ ...signalSummary.structureServices.map((service) => `structure:${service}`),
2214
+ ...signalSummary.topologyHeadings.map((heading) => `heading:${heading}`),
2215
+ ...signalSummary.dependencyPairs.map((pair) => `dependency:${pair}`),
2216
+ ...signalSummary.waveMentions.map((wave) => `wave:${wave}`),
2217
+ ])
2218
+ .slice(0, 10)
2219
+ .join("; ");
2220
+ }
2221
+ validateTopologyExtraction(projectKey, expectation, graph) {
2222
+ const topologySignals = expectation.signalSummary;
2223
+ if (!expectation.runtimeBearing)
2224
+ return topologySignals;
2225
+ if (graph.services.length === 0) {
2226
+ throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". SDS includes runtime topology signals but no services were resolved. Signals: ${this.formatTopologySignalSummary(topologySignals) || "unavailable"}`);
2227
+ }
2228
+ const missingServices = expectation.services.filter((service) => !graph.services.includes(service));
2229
+ if (missingServices.length > 0) {
2230
+ throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". Final planning artifacts lost source-backed services: ${missingServices.slice(0, 8).join(", ")}.`);
2231
+ }
2232
+ if (expectation.startupWaves.length > 0 && graph.startupWaves.length === 0) {
1881
2233
  throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". SDS includes startup wave signals but no startup waves were resolved. Signals: ${topologySignals.waveMentions.slice(0, 6).join("; ")}`);
1882
2234
  }
2235
+ const graphServicesByWave = new Map(graph.startupWaves.map((wave) => [wave.wave, new Set(wave.services)]));
2236
+ const missingWaves = expectation.startupWaves
2237
+ .map((wave) => wave.wave)
2238
+ .filter((wave) => !graphServicesByWave.has(wave));
2239
+ if (missingWaves.length > 0) {
2240
+ throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". Final planning artifacts lost source-backed startup waves: ${missingWaves.slice(0, 8).join(", ")}.`);
2241
+ }
2242
+ const missingWaveServices = expectation.startupWaves.flatMap((wave) => {
2243
+ const actualServices = graphServicesByWave.get(wave.wave);
2244
+ return wave.services
2245
+ .filter((service) => !(actualServices?.has(service) ?? false))
2246
+ .map((service) => `wave ${wave.wave}:${service}`);
2247
+ });
2248
+ if (missingWaveServices.length > 0) {
2249
+ throw new Error(`create-tasks failed internal topology extraction for project "${projectKey}". Final planning artifacts lost source-backed startup wave services: ${missingWaveServices.slice(0, 8).join(", ")}.`);
2250
+ }
1883
2251
  return topologySignals;
1884
2252
  }
1885
- derivePlanningArtifacts(projectKey, docs, plan) {
2253
+ derivePlanningArtifacts(projectKey, docs, plan, expectation = this.buildSourceTopologyExpectation(docs)) {
1886
2254
  const discoveryGraph = this.buildServiceDependencyGraph(plan, docs);
1887
- const topologySignals = this.validateTopologyExtraction(projectKey, docs, discoveryGraph);
2255
+ const topologySignals = this.validateTopologyExtraction(projectKey, expectation, discoveryGraph);
1888
2256
  const serviceCatalog = this.buildServiceCatalogArtifact(projectKey, docs, discoveryGraph);
1889
2257
  const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
1890
2258
  const projectBuildPlan = this.buildProjectPlanArtifact(projectKey, docs, discoveryGraph, projectBuildMethod);
@@ -2279,6 +2647,570 @@ export class CreateTasksService {
2279
2647
  buildMethod,
2280
2648
  };
2281
2649
  }
2650
+ scoreServiceUnitForText(unit, text, graph) {
2651
+ const normalizedText = this.normalizeServiceLookupKey(text);
2652
+ if (!normalizedText)
2653
+ return 0;
2654
+ const tokens = normalizedText
2655
+ .split(" ")
2656
+ .map((token) => token.trim())
2657
+ .filter((token) => token.length >= 3);
2658
+ if (tokens.length === 0)
2659
+ return 0;
2660
+ const unitCorpus = this.normalizeServiceLookupKey([
2661
+ unit.serviceName,
2662
+ ...unit.aliases,
2663
+ ...unit.directories,
2664
+ ...unit.files,
2665
+ ...unit.headings,
2666
+ ...unit.dependsOnServiceIds,
2667
+ ].join("\n"));
2668
+ const overlap = tokens.filter((token) => unitCorpus.includes(token)).length;
2669
+ let score = overlap * 10;
2670
+ const direct = this.resolveServiceMentionFromPhrase(text, graph.aliases);
2671
+ if (direct && unit.serviceName === direct)
2672
+ score += 100;
2673
+ if (normalizedText.includes(this.normalizeServiceLookupKey(unit.serviceName)))
2674
+ score += 25;
2675
+ if (unit.isFoundational)
2676
+ score += 4;
2677
+ return score;
2678
+ }
2679
+ buildSdsServiceUnits(docs, catalog, graph) {
2680
+ const units = new Map();
2681
+ const serviceById = new Map(catalog.services.map((service) => [service.id, service]));
2682
+ const serviceByName = new Map(catalog.services.map((service) => [service.name, service]));
2683
+ const orderedServiceIds = catalog.services.map((service) => service.id);
2684
+ const primaryFoundationalServiceId = catalog.services.find((service) => service.isFoundational)?.id ?? catalog.services[0]?.id;
2685
+ const opsServiceId = catalog.services.find((service) => /\b(ops|operation|deploy|infra|runtime|platform)\b/.test(this.normalizeServiceLookupKey([service.name, ...service.aliases].join(" "))))?.id ?? primaryFoundationalServiceId;
2686
+ const coverageSignals = collectSdsCoverageSignalsFromDocs(docs.map((doc) => ({ content: doc.content })), { headingLimit: 200, folderLimit: 240 });
2687
+ const structureTargets = this.extractStructureTargets(docs);
2688
+ const runtimeComponents = this.extractRuntimeComponentNames(docs);
2689
+ const ensureUnit = (serviceId) => {
2690
+ if (!serviceId)
2691
+ return undefined;
2692
+ const service = serviceById.get(serviceId);
2693
+ if (!service)
2694
+ return undefined;
2695
+ const existing = units.get(serviceId);
2696
+ if (existing)
2697
+ return existing;
2698
+ const unit = {
2699
+ serviceId: service.id,
2700
+ serviceName: service.name,
2701
+ aliases: uniqueStrings([service.name, ...service.aliases]),
2702
+ startupWave: service.startupWave,
2703
+ dependsOnServiceIds: [...service.dependsOnServiceIds],
2704
+ directories: [],
2705
+ files: [],
2706
+ headings: [],
2707
+ isFoundational: service.isFoundational,
2708
+ };
2709
+ units.set(serviceId, unit);
2710
+ return unit;
2711
+ };
2712
+ for (const serviceId of orderedServiceIds)
2713
+ ensureUnit(serviceId);
2714
+ const selectUnitForText = (text, options) => {
2715
+ const directName = this.resolveServiceMentionFromPhrase(text, graph.aliases);
2716
+ if (directName) {
2717
+ const direct = serviceByName.get(directName);
2718
+ if (direct)
2719
+ return ensureUnit(direct.id);
2720
+ }
2721
+ const pathMatch = options?.pathLike ? this.deriveServiceFromPathToken(text) : undefined;
2722
+ if (pathMatch) {
2723
+ const fromPath = serviceByName.get(pathMatch);
2724
+ if (fromPath)
2725
+ return ensureUnit(fromPath.id);
2726
+ }
2727
+ let bestUnit;
2728
+ let bestScore = 0;
2729
+ for (const unit of units.values()) {
2730
+ const score = this.scoreServiceUnitForText(unit, text, graph);
2731
+ if (score > bestScore) {
2732
+ bestScore = score;
2733
+ bestUnit = unit;
2734
+ }
2735
+ }
2736
+ if (bestUnit && bestScore > 0)
2737
+ return bestUnit;
2738
+ const normalizedText = this.normalizeServiceLookupKey(text);
2739
+ if (/\b(deploy|deployment|startup|rollback|recovery|observability|quality|release|failover|runbook|environment|secret|compute|operations?)\b/.test(normalizedText)) {
2740
+ return ensureUnit(opsServiceId);
2741
+ }
2742
+ return ensureUnit(primaryFoundationalServiceId);
2743
+ };
2744
+ for (const directory of structureTargets.directories) {
2745
+ const unit = selectUnitForText(directory, { pathLike: true });
2746
+ if (!unit)
2747
+ continue;
2748
+ unit.directories.push(directory);
2749
+ }
2750
+ for (const file of structureTargets.files) {
2751
+ const unit = selectUnitForText(file, { pathLike: true });
2752
+ if (!unit)
2753
+ continue;
2754
+ unit.files.push(file);
2755
+ const parent = path.dirname(file).replace(/\\/g, "/");
2756
+ if (parent && parent !== ".")
2757
+ unit.directories.push(parent);
2758
+ }
2759
+ for (const heading of coverageSignals.sectionHeadings) {
2760
+ const unit = selectUnitForText(heading);
2761
+ if (!unit)
2762
+ continue;
2763
+ unit.headings.push(normalizeHeadingCandidate(heading));
2764
+ }
2765
+ for (const component of runtimeComponents) {
2766
+ const unit = selectUnitForText(component);
2767
+ if (!unit)
2768
+ continue;
2769
+ unit.headings.push(normalizeHeadingCandidate(component));
2770
+ }
2771
+ return catalog.services
2772
+ .map((service) => units.get(service.id))
2773
+ .filter((unit) => Boolean(unit))
2774
+ .map((unit) => ({
2775
+ ...unit,
2776
+ aliases: uniqueStrings(unit.aliases).sort((a, b) => a.localeCompare(b)),
2777
+ directories: uniqueStrings(unit.directories).sort((a, b) => a.length - b.length || a.localeCompare(b)),
2778
+ files: uniqueStrings(unit.files).sort((a, b) => a.length - b.length || a.localeCompare(b)),
2779
+ headings: uniqueStrings(unit.headings),
2780
+ }));
2781
+ }
2782
+ buildSdsDrivenPlan(projectKey, docs, catalog, graph) {
2783
+ const units = this.buildSdsServiceUnits(docs, catalog, graph);
2784
+ const verificationSuites = this.extractVerificationSuites(docs);
2785
+ const acceptanceScenarios = this.extractAcceptanceScenarios(docs);
2786
+ const localIds = new Set();
2787
+ const epics = [];
2788
+ const stories = [];
2789
+ const tasks = [];
2790
+ const rootPreview = (items, fallback) => items.length > 0 ? items.slice(0, 8).map((item) => `- ${item}`).join("\n") : `- ${fallback}`;
2791
+ const chunk = (items, size) => {
2792
+ const chunks = [];
2793
+ for (let index = 0; index < items.length; index += size) {
2794
+ chunks.push(items.slice(index, index + size));
2795
+ }
2796
+ return chunks;
2797
+ };
2798
+ const toDisplayName = (value) => value
2799
+ .split(/\s+/)
2800
+ .map((token) => (token ? token[0].toUpperCase() + token.slice(1) : token))
2801
+ .join(" ");
2802
+ const defaultArea = normalizeArea(projectKey) ?? "core";
2803
+ for (const unit of units) {
2804
+ const epicLocalId = nextUniqueLocalId(`svc-${unit.serviceId}`, localIds);
2805
+ const epicTitle = `Build ${toDisplayName(unit.serviceName)}`;
2806
+ epics.push({
2807
+ localId: epicLocalId,
2808
+ area: defaultArea,
2809
+ title: epicTitle,
2810
+ description: [
2811
+ `Implement the ${unit.serviceName} build slice from the SDS.`,
2812
+ unit.startupWave !== undefined ? `Startup wave: ${unit.startupWave}.` : undefined,
2813
+ unit.dependsOnServiceIds.length > 0
2814
+ ? `Dependencies: ${unit.dependsOnServiceIds.join(", ")}.`
2815
+ : "Dependencies: foundational or none.",
2816
+ unit.headings.length > 0
2817
+ ? `Covered SDS sections: ${unit.headings.slice(0, 5).join("; ")}.`
2818
+ : undefined,
2819
+ ]
2820
+ .filter(Boolean)
2821
+ .join("\n"),
2822
+ acceptanceCriteria: [
2823
+ `${unit.serviceName} structure and implementation surfaces are represented in the backlog.`,
2824
+ `${unit.serviceName} sequencing stays aligned to SDS dependency and startup ordering.`,
2825
+ `${unit.serviceName} validation work exists for the implemented scope.`,
2826
+ ],
2827
+ relatedDocs: docs
2828
+ .map((doc) => (doc.id ? `docdex:${doc.id}` : undefined))
2829
+ .filter((value) => Boolean(value))
2830
+ .slice(0, 12),
2831
+ priorityHint: epics.length + 1,
2832
+ serviceIds: [unit.serviceId],
2833
+ tags: unit.isFoundational ? ["foundational"] : [],
2834
+ stories: [],
2835
+ });
2836
+ if (unit.directories.length > 0 || unit.files.length > 0) {
2837
+ const storyLocalId = nextUniqueLocalId(`svc-${unit.serviceId}-structure`, localIds);
2838
+ stories.push({
2839
+ localId: storyLocalId,
2840
+ epicLocalId,
2841
+ title: `Establish ${toDisplayName(unit.serviceName)} structure`,
2842
+ userStory: `As an engineer, I need the ${unit.serviceName} scaffold in place before implementation continues.`,
2843
+ description: [
2844
+ `Create the SDS-defined folder and file scaffold for ${unit.serviceName}.`,
2845
+ "Use the documented target tree as the implementation baseline.",
2846
+ ].join("\n"),
2847
+ acceptanceCriteria: [
2848
+ `Required ${unit.serviceName} directories exist.`,
2849
+ `Required ${unit.serviceName} file entrypoints are present or explicitly queued in dependent tasks.`,
2850
+ `Follow-up tasks can reference real ${unit.serviceName} implementation paths.`,
2851
+ ],
2852
+ relatedDocs: [],
2853
+ priorityHint: 1,
2854
+ tasks: [],
2855
+ });
2856
+ const directoryChunks = chunk(unit.directories, 6);
2857
+ const fileChunks = chunk(unit.files, 5);
2858
+ if (directoryChunks.length > 0) {
2859
+ tasks.push({
2860
+ localId: nextUniqueLocalId(`svc-${unit.serviceId}-structure-task`, localIds),
2861
+ storyLocalId,
2862
+ epicLocalId,
2863
+ title: `Create ${unit.serviceName} directory scaffold`,
2864
+ type: "chore",
2865
+ description: [
2866
+ `Create the initial ${unit.serviceName} repository structure required by the SDS.`,
2867
+ "Target directories:",
2868
+ rootPreview(directoryChunks[0] ?? [], "Infer the concrete runtime directories from the SDS build tree."),
2869
+ ].join("\n"),
2870
+ estimatedStoryPoints: 2,
2871
+ priorityHint: 1,
2872
+ dependsOnKeys: [],
2873
+ relatedDocs: [],
2874
+ unitTests: [],
2875
+ componentTests: [],
2876
+ integrationTests: [],
2877
+ apiTests: [],
2878
+ });
2879
+ }
2880
+ if (fileChunks.length > 0) {
2881
+ const structureFileTargets = this.selectBuildTargets(unit, [`${unit.serviceName} structure entrypoints`], "structure", 5);
2882
+ tasks.push({
2883
+ localId: nextUniqueLocalId(`svc-${unit.serviceId}-structure-task`, localIds),
2884
+ storyLocalId,
2885
+ epicLocalId,
2886
+ title: `Create ${unit.serviceName} runtime entrypoints`,
2887
+ type: "feature",
2888
+ description: [
2889
+ `Create or stub the first concrete ${unit.serviceName} implementation files from the SDS folder tree.`,
2890
+ "Target files:",
2891
+ rootPreview(structureFileTargets.length > 0 ? structureFileTargets : fileChunks[0] ?? [], "Create the primary runtime entrypoints and implementation surfaces."),
2892
+ ].join("\n"),
2893
+ estimatedStoryPoints: 3,
2894
+ priorityHint: 2,
2895
+ dependsOnKeys: tasks.filter((task) => task.storyLocalId === storyLocalId).map((task) => task.localId),
2896
+ relatedDocs: [],
2897
+ unitTests: [],
2898
+ componentTests: [],
2899
+ integrationTests: [],
2900
+ apiTests: [],
2901
+ });
2902
+ }
2903
+ }
2904
+ if (unit.headings.length > 0 || unit.files.length > 0) {
2905
+ const storyLocalId = nextUniqueLocalId(`svc-${unit.serviceId}-implementation`, localIds);
2906
+ stories.push({
2907
+ localId: storyLocalId,
2908
+ epicLocalId,
2909
+ title: `Implement ${toDisplayName(unit.serviceName)} capabilities`,
2910
+ userStory: `As a delivery team, we need the ${unit.serviceName} capability set implemented from the SDS.`,
2911
+ description: [
2912
+ `Implement the ${unit.serviceName} behavior defined across the SDS sections assigned to this build slice.`,
2913
+ unit.headings.length > 0 ? `Primary SDS sections: ${unit.headings.slice(0, 6).join("; ")}.` : undefined,
2914
+ ]
2915
+ .filter(Boolean)
2916
+ .join("\n"),
2917
+ acceptanceCriteria: [
2918
+ `${unit.serviceName} covers its assigned SDS capability sections.`,
2919
+ `${unit.serviceName} implementation targets are concrete and source-backed.`,
2920
+ `${unit.serviceName} leaves no generic placeholder work inside this story.`,
2921
+ ],
2922
+ relatedDocs: [],
2923
+ priorityHint: 2,
2924
+ tasks: [],
2925
+ });
2926
+ const headingChunks = chunk(unit.headings.length > 0 ? unit.headings : [`${unit.serviceName} runtime implementation`], 2);
2927
+ headingChunks.slice(0, 6).forEach((group, index) => {
2928
+ const targetSlice = this.selectBuildTargets(unit, group, "implementation", 3);
2929
+ tasks.push({
2930
+ localId: nextUniqueLocalId(`svc-${unit.serviceId}-implementation-task`, localIds),
2931
+ storyLocalId,
2932
+ epicLocalId,
2933
+ title: `Implement ${group[0] ?? unit.serviceName}`,
2934
+ type: "feature",
2935
+ description: [
2936
+ `Implement the ${unit.serviceName} scope required by these SDS sections: ${group.join("; ")}.`,
2937
+ "Primary implementation targets:",
2938
+ rootPreview(targetSlice, `Extend the SDS-defined ${unit.serviceName} runtime surfaces captured in this build slice.`),
2939
+ unit.dependsOnServiceIds.length > 0
2940
+ ? `Keep dependency direction aligned to: ${unit.dependsOnServiceIds.join(", ")}.`
2941
+ : "Keep this slice buildable without introducing undocumented dependencies.",
2942
+ ].join("\n"),
2943
+ estimatedStoryPoints: Math.min(8, 3 + Math.max(0, Math.max(1, targetSlice.length) - 1)),
2944
+ priorityHint: index + 1,
2945
+ dependsOnKeys: [],
2946
+ relatedDocs: [],
2947
+ unitTests: targetSlice.length > 0 ? [`Cover ${unit.serviceName} implementation behavior for ${targetSlice[0]}.`] : [],
2948
+ componentTests: [],
2949
+ integrationTests: [],
2950
+ apiTests: [],
2951
+ });
2952
+ });
2953
+ }
2954
+ if (unit.dependsOnServiceIds.length > 0) {
2955
+ const storyLocalId = nextUniqueLocalId(`svc-${unit.serviceId}-integration`, localIds);
2956
+ stories.push({
2957
+ localId: storyLocalId,
2958
+ epicLocalId,
2959
+ title: `Integrate ${toDisplayName(unit.serviceName)} dependencies`,
2960
+ userStory: `As an engineer, I need ${unit.serviceName} wired to its SDS-defined dependencies and interfaces.`,
2961
+ description: [
2962
+ `Implement dependency and interface wiring for ${unit.serviceName}.`,
2963
+ `SDS dependency chain: ${unit.dependsOnServiceIds.join(", ")}.`,
2964
+ ].join("\n"),
2965
+ acceptanceCriteria: [
2966
+ `${unit.serviceName} dependency integration is explicit and ordered.`,
2967
+ `${unit.serviceName} uses only documented runtime dependencies.`,
2968
+ ],
2969
+ relatedDocs: [],
2970
+ priorityHint: 3,
2971
+ tasks: [],
2972
+ });
2973
+ unit.dependsOnServiceIds.slice(0, 4).forEach((dependencyId, index) => {
2974
+ tasks.push({
2975
+ localId: nextUniqueLocalId(`svc-${unit.serviceId}-integration-task`, localIds),
2976
+ storyLocalId,
2977
+ epicLocalId,
2978
+ title: `Wire ${unit.serviceName} to ${dependencyId}`,
2979
+ type: "feature",
2980
+ description: [
2981
+ `Implement the SDS-defined dependency direction from ${unit.serviceName} to ${dependencyId}.`,
2982
+ "Update runtime interfaces, configuration reads, or orchestration flow as required.",
2983
+ ].join("\n"),
2984
+ estimatedStoryPoints: 2,
2985
+ priorityHint: index + 1,
2986
+ dependsOnKeys: [],
2987
+ relatedDocs: [],
2988
+ unitTests: [],
2989
+ componentTests: [],
2990
+ integrationTests: [`Validate ${unit.serviceName} integration with ${dependencyId}.`],
2991
+ apiTests: [],
2992
+ });
2993
+ });
2994
+ }
2995
+ const verificationStoryLocalId = nextUniqueLocalId(`svc-${unit.serviceId}-verification`, localIds);
2996
+ stories.push({
2997
+ localId: verificationStoryLocalId,
2998
+ epicLocalId,
2999
+ title: `Verify ${toDisplayName(unit.serviceName)} readiness`,
3000
+ userStory: `As a reviewer, I need concrete verification evidence for ${unit.serviceName}.`,
3001
+ description: [
3002
+ `Add the validation work needed to prove ${unit.serviceName} against the SDS.`,
3003
+ unit.headings.length > 0 ? `Verification should cover: ${unit.headings.slice(0, 4).join("; ")}.` : undefined,
3004
+ ]
3005
+ .filter(Boolean)
3006
+ .join("\n"),
3007
+ acceptanceCriteria: [
3008
+ `${unit.serviceName} has deterministic validation coverage.`,
3009
+ `${unit.serviceName} evidence maps back to SDS responsibilities.`,
3010
+ ],
3011
+ relatedDocs: [],
3012
+ priorityHint: 4,
3013
+ tasks: [],
3014
+ });
3015
+ tasks.push({
3016
+ localId: nextUniqueLocalId(`svc-${unit.serviceId}-verification-task`, localIds),
3017
+ storyLocalId: verificationStoryLocalId,
3018
+ epicLocalId,
3019
+ title: `Add ${unit.serviceName} focused test coverage`,
3020
+ type: "chore",
3021
+ description: [
3022
+ `Add or update the smallest deterministic validation surface that proves ${unit.serviceName}.`,
3023
+ "Cover the implemented runtime paths, dependency behavior, and SDS acceptance expectations for this build slice.",
3024
+ "Primary verification targets:",
3025
+ rootPreview(this.selectBuildTargets(unit, [...unit.headings, `${unit.serviceName} verification`], "verification", 3), `Capture service-local validation against ${unit.serviceName} runtime surfaces.`),
3026
+ ].join("\n"),
3027
+ estimatedStoryPoints: 2,
3028
+ priorityHint: 1,
3029
+ dependsOnKeys: [],
3030
+ relatedDocs: [],
3031
+ unitTests: [`Validate ${unit.serviceName} internal behavior.`],
3032
+ componentTests: unit.files.some((file) => /\b(ui|web|component|page|screen)\b/i.test(file))
3033
+ ? [`Validate ${unit.serviceName} component-level behavior.`]
3034
+ : [],
3035
+ integrationTests: [`Validate ${unit.serviceName} end-to-end dependency behavior.`],
3036
+ apiTests: [],
3037
+ });
3038
+ }
3039
+ if (verificationSuites.length > 0 || acceptanceScenarios.length > 0) {
3040
+ const releaseEpicLocalId = nextUniqueLocalId("release-verification", localIds);
3041
+ epics.push({
3042
+ localId: releaseEpicLocalId,
3043
+ area: defaultArea,
3044
+ title: "Verify Release Readiness",
3045
+ description: [
3046
+ "Execute the named verification suites and acceptance scenarios documented in the SDS release gate.",
3047
+ verificationSuites.length > 0
3048
+ ? `Named suites: ${verificationSuites.map((suite) => suite.name).slice(0, 8).join("; ")}.`
3049
+ : undefined,
3050
+ acceptanceScenarios.length > 0
3051
+ ? `Acceptance scenarios: ${acceptanceScenarios.length} documented launch scenarios must be green.`
3052
+ : undefined,
3053
+ ]
3054
+ .filter(Boolean)
3055
+ .join("\n"),
3056
+ acceptanceCriteria: [
3057
+ "Every named verification suite from the SDS is represented as executable backlog work.",
3058
+ "Every required acceptance scenario from the SDS is represented as executable backlog work.",
3059
+ "Release verification evidence maps back to the SDS release gate.",
3060
+ ],
3061
+ relatedDocs: docs
3062
+ .map((doc) => (doc.id ? `docdex:${doc.id}` : undefined))
3063
+ .filter((value) => Boolean(value))
3064
+ .slice(0, 12),
3065
+ priorityHint: epics.length + 1,
3066
+ serviceIds: uniqueStrings(units.map((unit) => unit.serviceId)).slice(0, 12),
3067
+ tags: [CROSS_SERVICE_TAG],
3068
+ stories: [],
3069
+ });
3070
+ if (verificationSuites.length > 0) {
3071
+ const verificationStoryLocalId = nextUniqueLocalId("release-verification-suites", localIds);
3072
+ stories.push({
3073
+ localId: verificationStoryLocalId,
3074
+ epicLocalId: releaseEpicLocalId,
3075
+ title: "Execute named verification suites",
3076
+ userStory: "As a release reviewer, I need every named SDS verification suite to exist as executable backlog work.",
3077
+ description: "Create the exact named verification suites from the SDS verification matrix and map them to deterministic evidence.",
3078
+ acceptanceCriteria: [
3079
+ "All named SDS verification suites have backlog tasks with explicit scope.",
3080
+ "Suite tasks reference the exact SDS suite names instead of generic test placeholders.",
3081
+ ],
3082
+ relatedDocs: [],
3083
+ priorityHint: 1,
3084
+ tasks: [],
3085
+ });
3086
+ verificationSuites.forEach((suite, index) => {
3087
+ const draft = this.buildVerificationSuiteTaskDraft(suite, "the release slice");
3088
+ tasks.push({
3089
+ localId: nextUniqueLocalId("release-verification-suite-task", localIds),
3090
+ storyLocalId: verificationStoryLocalId,
3091
+ epicLocalId: releaseEpicLocalId,
3092
+ title: `Execute suite: ${suite.name}`,
3093
+ type: "chore",
3094
+ description: draft.description,
3095
+ estimatedStoryPoints: /\b(end-to-end|acceptance|drill|integration)\b/i.test(suite.name) ? 3 : 2,
3096
+ priorityHint: index + 1,
3097
+ dependsOnKeys: [],
3098
+ relatedDocs: [],
3099
+ unitTests: draft.unitTests,
3100
+ componentTests: draft.componentTests,
3101
+ integrationTests: draft.integrationTests,
3102
+ apiTests: draft.apiTests,
3103
+ });
3104
+ });
3105
+ }
3106
+ if (acceptanceScenarios.length > 0) {
3107
+ const scenarioStoryLocalId = nextUniqueLocalId("release-acceptance-scenarios", localIds);
3108
+ stories.push({
3109
+ localId: scenarioStoryLocalId,
3110
+ epicLocalId: releaseEpicLocalId,
3111
+ title: "Run required acceptance scenarios",
3112
+ userStory: "As a launch approver, I need every required acceptance scenario executed before release.",
3113
+ description: "Create one executable backlog task per SDS acceptance scenario so launch approval is tied to concrete scenario evidence.",
3114
+ acceptanceCriteria: [
3115
+ "All numbered SDS acceptance scenarios exist as backlog tasks.",
3116
+ "Scenario tasks preserve the SDS launch wording closely enough for release review.",
3117
+ ],
3118
+ relatedDocs: [],
3119
+ priorityHint: 2,
3120
+ tasks: [],
3121
+ });
3122
+ acceptanceScenarios.forEach((scenario) => {
3123
+ tasks.push({
3124
+ localId: nextUniqueLocalId("release-acceptance-scenario-task", localIds),
3125
+ storyLocalId: scenarioStoryLocalId,
3126
+ epicLocalId: releaseEpicLocalId,
3127
+ title: `Validate acceptance scenario ${scenario.index}: ${scenario.title}`,
3128
+ type: "chore",
3129
+ description: [
3130
+ `Execute SDS acceptance scenario ${scenario.index}.`,
3131
+ scenario.details,
3132
+ "Capture deterministic pass/fail evidence and link it to the release gate.",
3133
+ ].join("\n"),
3134
+ estimatedStoryPoints: 2,
3135
+ priorityHint: scenario.index,
3136
+ dependsOnKeys: [],
3137
+ relatedDocs: [],
3138
+ unitTests: [],
3139
+ componentTests: [],
3140
+ integrationTests: [`Execute acceptance scenario ${scenario.index} end to end.`],
3141
+ apiTests: [],
3142
+ });
3143
+ });
3144
+ }
3145
+ }
3146
+ return { epics, stories, tasks };
3147
+ }
3148
+ hasStrongSdsPlanningEvidence(docs, catalog, expectation) {
3149
+ const sdsDocs = docs.filter((doc) => looksLikeSdsDoc(doc));
3150
+ if (sdsDocs.length === 0)
3151
+ return false;
3152
+ const coverageSignals = collectSdsCoverageSignalsFromDocs(docs.map((doc) => ({ content: doc.content })), { headingLimit: 200, folderLimit: 240 });
3153
+ const structureTargets = this.extractStructureTargets(docs);
3154
+ const structureSignalCount = structureTargets.directories.length + structureTargets.files.length;
3155
+ const headingSignalCount = coverageSignals.sectionHeadings.length;
3156
+ const topologySignalCount = expectation.services.length +
3157
+ expectation.startupWaves.length +
3158
+ expectation.dependencyPairs.length +
3159
+ expectation.signalSummary.topologyHeadings.length +
3160
+ expectation.signalSummary.waveMentions.length;
3161
+ return (structureSignalCount >= 4 ||
3162
+ headingSignalCount >= 8 ||
3163
+ catalog.services.length >= 2 ||
3164
+ topologySignalCount >= 4);
3165
+ }
3166
+ taskUsesOnlyWeakImplementationTargets(task, docs) {
3167
+ if (!/\bimplement\b/i.test(`${task.title} ${task.description ?? ""}`))
3168
+ return false;
3169
+ const inventory = this.buildCanonicalNameInventory(docs);
3170
+ if (!inventory.paths.some((candidate) => this.isStrongImplementationTarget(candidate)))
3171
+ return false;
3172
+ const candidateTargets = uniqueStrings(filterImplementationStructuredPaths(extractStructuredPaths(`${task.title}\n${task.description ?? ""}`, 64))
3173
+ .map((token) => this.normalizeStructurePathToken(token))
3174
+ .filter((value) => Boolean(value)));
3175
+ if (candidateTargets.length === 0)
3176
+ return false;
3177
+ return candidateTargets.every((target) => !this.isStrongImplementationTarget(target));
3178
+ }
3179
+ planLooksTooWeakForSds(plan, docs, catalog, expectation) {
3180
+ if (!this.hasStrongSdsPlanningEvidence(docs, catalog, expectation))
3181
+ return false;
3182
+ const genericTitles = plan.tasks.filter((task) => /initial planning|draft backlog|review inputs|baseline project scaffolding|integrate core dependencies|validate baseline behavior/i.test(`${task.title} ${task.description ?? ""}`)).length;
3183
+ const genericImplementationTasks = plan.tasks.filter((task) => GENERIC_IMPLEMENTATION_TASK_PATTERN.test(`${task.title} ${task.description ?? ""}`)).length;
3184
+ const weakImplementationTargetTasks = plan.tasks.filter((task) => this.taskUsesOnlyWeakImplementationTargets(task, docs)).length;
3185
+ const verificationSuites = this.extractVerificationSuites(docs);
3186
+ const acceptanceScenarios = this.extractAcceptanceScenarios(docs);
3187
+ const planCorpus = this.normalizeServiceLookupKey([
3188
+ ...plan.epics.map((epic) => `${epic.title}\n${epic.description ?? ""}`),
3189
+ ...plan.stories.map((story) => `${story.title}\n${story.description ?? ""}\n${story.userStory ?? ""}`),
3190
+ ...plan.tasks.map((task) => `${task.title}\n${task.description ?? ""}`),
3191
+ ].join("\n"));
3192
+ const coveredVerificationSuites = verificationSuites.filter((suite) => planCorpus.includes(this.normalizeServiceLookupKey(suite.name))).length;
3193
+ const coveredAcceptanceScenarios = acceptanceScenarios.filter((scenario) => planCorpus.includes(`scenario ${scenario.index}`) ||
3194
+ planCorpus.includes(this.normalizeServiceLookupKey(scenario.title))).length;
3195
+ const coveredServiceIds = new Set(plan.epics.flatMap((epic) => normalizeStringArray(epic.serviceIds)));
3196
+ return (plan.epics.length === 0 ||
3197
+ plan.stories.length === 0 ||
3198
+ plan.tasks.length === 0 ||
3199
+ genericTitles >= Math.min(2, plan.tasks.length) ||
3200
+ genericImplementationTasks > 0 ||
3201
+ weakImplementationTargetTasks > 0 ||
3202
+ (verificationSuites.length > 0 && coveredVerificationSuites === 0) ||
3203
+ (acceptanceScenarios.length > 0 && coveredAcceptanceScenarios < Math.min(3, acceptanceScenarios.length)) ||
3204
+ (genericTitles > 0 &&
3205
+ catalog.services.length > 1 &&
3206
+ coveredServiceIds.size > 0 &&
3207
+ coveredServiceIds.size < Math.min(catalog.services.length, 2)));
3208
+ }
3209
+ backlogMostlyAlignedEnough(audit) {
3210
+ if (!audit)
3211
+ return true;
3212
+ return audit.satisfied;
3213
+ }
2282
3214
  orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope) {
2283
3215
  const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
2284
3216
  const indegree = new Map();
@@ -2937,6 +3869,336 @@ export class CreateTasksService {
2937
3869
  }
2938
3870
  return uniqueStrings(sections).slice(0, limit);
2939
3871
  }
3872
+ collectSdsSections(docs) {
3873
+ const sections = [];
3874
+ for (const doc of docs) {
3875
+ if (!looksLikeSdsDoc(doc))
3876
+ continue;
3877
+ const content = stripManagedSdsPreflightBlock(doc.content ?? "") ?? "";
3878
+ const lines = content.split(/\r?\n/);
3879
+ let currentHeading;
3880
+ let currentBody = [];
3881
+ let inCodeFence = false;
3882
+ const flush = () => {
3883
+ if (!currentHeading)
3884
+ return;
3885
+ sections.push({ heading: currentHeading, body: [...currentBody] });
3886
+ };
3887
+ for (const rawLine of lines) {
3888
+ const line = rawLine ?? "";
3889
+ const trimmed = line.trim();
3890
+ if (/^```/.test(trimmed)) {
3891
+ inCodeFence = !inCodeFence;
3892
+ currentBody.push(line);
3893
+ continue;
3894
+ }
3895
+ if (!inCodeFence) {
3896
+ const headingMatch = trimmed.match(MARKDOWN_HEADING_PATTERN);
3897
+ if (headingMatch) {
3898
+ flush();
3899
+ currentHeading = normalizeHeadingCandidate(headingMatch[2] ?? "");
3900
+ currentBody = [];
3901
+ continue;
3902
+ }
3903
+ }
3904
+ if (currentHeading)
3905
+ currentBody.push(line);
3906
+ }
3907
+ flush();
3908
+ }
3909
+ return sections;
3910
+ }
3911
+ normalizeRuntimeComponentCandidate(rawValue) {
3912
+ let candidate = rawValue.trim();
3913
+ if (!candidate)
3914
+ return undefined;
3915
+ const backtickMatch = candidate.match(/`([^`]+)`/);
3916
+ if (backtickMatch?.[1]) {
3917
+ candidate = backtickMatch[1];
3918
+ }
3919
+ const colonHead = candidate.split(/:\s+/, 2)[0]?.trim();
3920
+ if (colonHead && colonHead.split(/\s+/).length <= 5) {
3921
+ candidate = colonHead;
3922
+ }
3923
+ const dashHead = candidate.split(/\s+[—-]\s+/, 2)[0]?.trim();
3924
+ if (dashHead && dashHead.split(/\s+/).length <= 5) {
3925
+ candidate = dashHead;
3926
+ }
3927
+ candidate = candidate.replace(/\([^)]*\)/g, " ").replace(/[.;,]+$/, "").trim();
3928
+ if (!candidate)
3929
+ return undefined;
3930
+ const normalized = this.normalizeTextServiceName(candidate) ?? this.normalizeServiceName(candidate);
3931
+ if (!normalized)
3932
+ return undefined;
3933
+ if (normalized.split(" ").length > 4)
3934
+ return undefined;
3935
+ return normalized;
3936
+ }
3937
+ extractRuntimeComponentNames(docs) {
3938
+ const components = new Set();
3939
+ for (const section of this.collectSdsSections(docs)) {
3940
+ if (!RUNTIME_COMPONENTS_HEADING_PATTERN.test(section.heading))
3941
+ continue;
3942
+ let inCodeFence = false;
3943
+ for (const rawLine of section.body) {
3944
+ const trimmed = rawLine.trim();
3945
+ if (/^```/.test(trimmed)) {
3946
+ inCodeFence = !inCodeFence;
3947
+ continue;
3948
+ }
3949
+ if (inCodeFence)
3950
+ continue;
3951
+ const listMatch = trimmed.match(/^(?:[-*]|\d+[.)])\s+(.+)$/);
3952
+ if (!listMatch?.[1])
3953
+ continue;
3954
+ const candidate = this.normalizeRuntimeComponentCandidate(listMatch[1]);
3955
+ if (candidate)
3956
+ components.add(candidate);
3957
+ }
3958
+ }
3959
+ return Array.from(components);
3960
+ }
3961
+ extractVerificationSuites(docs) {
3962
+ const suites = [];
3963
+ const seen = new Set();
3964
+ for (const section of this.collectSdsSections(docs)) {
3965
+ if (!VERIFICATION_MATRIX_HEADING_PATTERN.test(section.heading))
3966
+ continue;
3967
+ for (const rawLine of section.body) {
3968
+ const trimmed = rawLine.trim();
3969
+ if (!trimmed.startsWith("|"))
3970
+ continue;
3971
+ if (/^\|\s*-+\s*\|/i.test(trimmed))
3972
+ continue;
3973
+ const cells = trimmed
3974
+ .split("|")
3975
+ .map((cell) => cell.trim())
3976
+ .filter(Boolean);
3977
+ if (cells.length < 2)
3978
+ continue;
3979
+ if (/verification suite/i.test(cells[0] ?? ""))
3980
+ continue;
3981
+ const name = normalizeHeadingCandidate(cells[0] ?? "");
3982
+ if (!name)
3983
+ continue;
3984
+ const key = this.normalizeServiceLookupKey(name);
3985
+ if (!key || seen.has(key))
3986
+ continue;
3987
+ seen.add(key);
3988
+ suites.push({
3989
+ name,
3990
+ scope: cells[1] || undefined,
3991
+ sourceCoverage: cells[2] || undefined,
3992
+ });
3993
+ }
3994
+ }
3995
+ return suites;
3996
+ }
3997
+ extractAcceptanceScenarios(docs) {
3998
+ const scenarios = [];
3999
+ const seen = new Set();
4000
+ for (const section of this.collectSdsSections(docs)) {
4001
+ if (!ACCEPTANCE_SCENARIOS_HEADING_PATTERN.test(section.heading))
4002
+ continue;
4003
+ for (const rawLine of section.body) {
4004
+ const trimmed = rawLine.trim();
4005
+ const match = trimmed.match(/^(\d+)\.\s+(.+)$/);
4006
+ if (!match?.[1] || !match[2])
4007
+ continue;
4008
+ const index = Number.parseInt(match[1], 10);
4009
+ if (!Number.isFinite(index) || seen.has(index))
4010
+ continue;
4011
+ const details = match[2].trim();
4012
+ const title = normalizeHeadingCandidate(details.split(/:\s+/, 2)[0] ?? details) || `Scenario ${index}`;
4013
+ scenarios.push({ index, title, details });
4014
+ seen.add(index);
4015
+ }
4016
+ }
4017
+ return scenarios.sort((left, right) => left.index - right.index);
4018
+ }
4019
+ classifyBuildTarget(target) {
4020
+ const normalized = this.normalizeStructurePathToken(target) ?? target.replace(/\\/g, "/").trim();
4021
+ const segments = normalized
4022
+ .toLowerCase()
4023
+ .split("/")
4024
+ .map((segment) => segment.trim())
4025
+ .filter(Boolean);
4026
+ const basename = segments[segments.length - 1] ?? normalized.toLowerCase();
4027
+ const isFile = isStructuredFilePath(basename);
4028
+ const isServiceArtifact = SERVICE_ARTIFACT_BASENAME_PATTERN.test(basename);
4029
+ if (segments.some((segment) => BUILD_TARGET_DOC_SEGMENTS.has(segment))) {
4030
+ return { normalized, basename, segments, isFile, kind: "doc", isServiceArtifact };
4031
+ }
4032
+ if (MANIFEST_TARGET_BASENAME_PATTERN.test(basename) || isServiceArtifact) {
4033
+ return { normalized, basename, segments, isFile, kind: "manifest", isServiceArtifact };
4034
+ }
4035
+ if (segments.some((segment) => BUILD_TARGET_TEST_SEGMENTS.has(segment))) {
4036
+ return { normalized, basename, segments, isFile, kind: "test", isServiceArtifact };
4037
+ }
4038
+ if (segments.some((segment) => BUILD_TARGET_OPS_SEGMENTS.has(segment))) {
4039
+ return { normalized, basename, segments, isFile, kind: "ops", isServiceArtifact };
4040
+ }
4041
+ if (segments.some((segment) => BUILD_TARGET_INTERFACE_SEGMENTS.has(segment))) {
4042
+ return { normalized, basename, segments, isFile, kind: "interface", isServiceArtifact };
4043
+ }
4044
+ if (segments.some((segment) => BUILD_TARGET_DATA_SEGMENTS.has(segment))) {
4045
+ return { normalized, basename, segments, isFile, kind: "data", isServiceArtifact };
4046
+ }
4047
+ if (segments.some((segment) => BUILD_TARGET_RUNTIME_SEGMENTS.has(segment))) {
4048
+ return { normalized, basename, segments, isFile, kind: "runtime", isServiceArtifact };
4049
+ }
4050
+ return { normalized, basename, segments, isFile, kind: "unknown", isServiceArtifact };
4051
+ }
4052
+ isStrongImplementationTarget(target) {
4053
+ const classification = this.classifyBuildTarget(target);
4054
+ return (classification.kind === "runtime" ||
4055
+ classification.kind === "interface" ||
4056
+ classification.kind === "data" ||
4057
+ classification.kind === "test" ||
4058
+ classification.kind === "ops");
4059
+ }
4060
+ deriveBuildFocusProfile(texts) {
4061
+ const corpus = this.normalizeServiceLookupKey(texts.join("\n"));
4062
+ return {
4063
+ wantsOps: /\b(deploy|deployment|startup|release|rollback|recovery|rotation|drill|runbook|failover|proxy|operations?|runtime)\b/.test(corpus),
4064
+ wantsVerification: /\b(verify|verification|acceptance|scenario|quality|suite|test|tests|matrix|gate|drill)\b/.test(corpus),
4065
+ wantsData: /\b(data|storage|cache|ledger|pipeline|db|database|persistence)\b/.test(corpus),
4066
+ wantsInterface: /\b(contract|policy|provider|gateway|rpc|api|interface|schema|oracle|protocol)\b/.test(corpus),
4067
+ };
4068
+ }
4069
+ selectBuildTargets(unit, focusTexts, purpose, limit) {
4070
+ const candidates = uniqueStrings([...unit.files, ...unit.directories])
4071
+ .map((candidate) => this.normalizeStructurePathToken(candidate) ?? candidate.replace(/\\/g, "/").trim())
4072
+ .filter(Boolean);
4073
+ if (candidates.length === 0)
4074
+ return [];
4075
+ const focusCorpus = this.normalizeServiceLookupKey([unit.serviceName, ...unit.aliases, ...focusTexts].filter(Boolean).join("\n"));
4076
+ const focusTokens = focusCorpus
4077
+ .split(" ")
4078
+ .map((token) => token.trim())
4079
+ .filter((token) => token.length >= 3);
4080
+ const focusProfile = this.deriveBuildFocusProfile(focusTexts);
4081
+ const scored = candidates
4082
+ .map((target) => {
4083
+ const classification = this.classifyBuildTarget(target);
4084
+ const normalizedTarget = this.normalizeServiceLookupKey(target.replace(/\//g, " "));
4085
+ const overlap = focusTokens.filter((token) => normalizedTarget.includes(token)).length;
4086
+ let score = overlap * 25 + (classification.isFile ? 12 : 0);
4087
+ if (purpose === "structure") {
4088
+ if (classification.kind === "runtime" || classification.kind === "interface")
4089
+ score += 90;
4090
+ else if (classification.kind === "data")
4091
+ score += 75;
4092
+ else if (classification.kind === "ops")
4093
+ score += 30;
4094
+ else if (classification.kind === "manifest")
4095
+ score += 10;
4096
+ else if (classification.kind === "doc")
4097
+ score -= 80;
4098
+ }
4099
+ else if (purpose === "implementation") {
4100
+ if (classification.kind === "runtime")
4101
+ score += classification.isFile ? 170 : 140;
4102
+ else if (classification.kind === "interface")
4103
+ score += classification.isFile ? 160 : 135;
4104
+ else if (classification.kind === "data")
4105
+ score += classification.isFile ? 150 : 125;
4106
+ else if (classification.kind === "test")
4107
+ score += 70;
4108
+ else if (classification.kind === "ops")
4109
+ score += focusProfile.wantsOps ? 140 : 25;
4110
+ else if (classification.kind === "manifest")
4111
+ score -= 140;
4112
+ else if (classification.kind === "doc")
4113
+ score -= 180;
4114
+ }
4115
+ else {
4116
+ if (classification.kind === "test")
4117
+ score += 170;
4118
+ else if (classification.kind === "runtime")
4119
+ score += 120;
4120
+ else if (classification.kind === "interface")
4121
+ score += 105;
4122
+ else if (classification.kind === "data")
4123
+ score += 95;
4124
+ else if (classification.kind === "ops")
4125
+ score += focusProfile.wantsOps ? 160 : 90;
4126
+ else if (classification.kind === "manifest")
4127
+ score -= 120;
4128
+ else if (classification.kind === "doc")
4129
+ score -= 180;
4130
+ }
4131
+ if (focusProfile.wantsOps && classification.kind === "ops")
4132
+ score += 60;
4133
+ if (focusProfile.wantsVerification && classification.kind === "test")
4134
+ score += 60;
4135
+ if (focusProfile.wantsData && classification.kind === "data")
4136
+ score += 55;
4137
+ if (focusProfile.wantsInterface && classification.kind === "interface")
4138
+ score += 55;
4139
+ if (focusProfile.wantsInterface && classification.kind === "runtime")
4140
+ score += 20;
4141
+ return { target, classification, score };
4142
+ })
4143
+ .sort((left, right) => right.score - left.score || left.target.length - right.target.length || left.target.localeCompare(right.target));
4144
+ const strongExists = scored.some((entry) => entry.score > 0 &&
4145
+ (entry.classification.kind === "runtime" ||
4146
+ entry.classification.kind === "interface" ||
4147
+ entry.classification.kind === "data" ||
4148
+ entry.classification.kind === "test" ||
4149
+ entry.classification.kind === "ops"));
4150
+ const filtered = scored.filter((entry) => {
4151
+ if (entry.score <= 0)
4152
+ return false;
4153
+ if (!strongExists)
4154
+ return true;
4155
+ if (purpose === "structure")
4156
+ return entry.classification.kind !== "doc";
4157
+ if (entry.classification.kind === "manifest")
4158
+ return false;
4159
+ return entry.classification.kind !== "doc";
4160
+ });
4161
+ const ranked = (filtered.length > 0 ? filtered : scored.filter((entry) => entry.score > 0)).map((entry) => entry.target);
4162
+ return uniqueStrings(ranked).slice(0, Math.max(1, limit));
4163
+ }
4164
+ buildVerificationSuiteTaskDraft(suite, serviceName) {
4165
+ const normalized = this.normalizeServiceLookupKey([suite.name, suite.scope, suite.sourceCoverage].filter(Boolean).join(" "));
4166
+ const unitTests = [];
4167
+ const componentTests = [];
4168
+ const integrationTests = [];
4169
+ const apiTests = [];
4170
+ if (/\bunit\b/.test(normalized)) {
4171
+ unitTests.push(`Execute the named suite "${suite.name}" for ${serviceName}.`);
4172
+ }
4173
+ if (/\b(component|ui|render|client)\b/.test(normalized)) {
4174
+ componentTests.push(`Execute the named suite "${suite.name}" against the ${serviceName} surface.`);
4175
+ }
4176
+ if (/\b(integration|acceptance|end to end|end-to-end|drill|replay|failover)\b/.test(normalized)) {
4177
+ integrationTests.push(`Execute the named suite "${suite.name}" end to end for ${serviceName}.`);
4178
+ }
4179
+ if (/\b(api|gateway|rpc|provider)\b/.test(normalized)) {
4180
+ apiTests.push(`Execute the named suite "${suite.name}" against the ${serviceName} API/provider surface.`);
4181
+ }
4182
+ if (unitTests.length === 0 &&
4183
+ componentTests.length === 0 &&
4184
+ integrationTests.length === 0 &&
4185
+ apiTests.length === 0) {
4186
+ integrationTests.push(`Execute the named suite "${suite.name}" and capture deterministic evidence.`);
4187
+ }
4188
+ return {
4189
+ description: [
4190
+ `Implement and wire the named verification suite "${suite.name}" for ${serviceName}.`,
4191
+ suite.scope ? `Scope: ${suite.scope}` : undefined,
4192
+ suite.sourceCoverage ? `Source coverage: ${suite.sourceCoverage}` : undefined,
4193
+ ]
4194
+ .filter(Boolean)
4195
+ .join("\n"),
4196
+ unitTests,
4197
+ componentTests,
4198
+ integrationTests,
4199
+ apiTests,
4200
+ };
4201
+ }
2940
4202
  buildSdsCoverageHints(docs) {
2941
4203
  const hints = this.extractSdsSectionCandidates(docs, SDS_COVERAGE_HINT_HEADING_LIMIT);
2942
4204
  if (hints.length === 0)
@@ -3007,6 +4269,212 @@ export class CreateTasksService {
3007
4269
  }
3008
4270
  return { docSummary: blocks.join("\n\n") || "(no docs)", warnings };
3009
4271
  }
4272
+ buildCreateTasksAgentMission(projectKey) {
4273
+ return [
4274
+ `You are the orchestration agent for mcoda create-tasks on project ${projectKey}.`,
4275
+ "Your job in this run is to turn the SDS and supporting docs into an executable backlog that is enough to build the documented product.",
4276
+ "You must understand the services/tools that need to exist, define implementation epics, define the user stories needed to finish each epic, and define the concrete tasks needed to finish each story.",
4277
+ "Keep every phase aligned to the SDS folder tree, runtime topology, dependency order, startup waves, verification suites, acceptance scenarios, and named implementation targets.",
4278
+ "Use only canonical documented names for services, modules, interfaces, commands, schemas, files, and runtime artifacts.",
4279
+ "Do not invent stack choices, rename documented targets, emit placeholder work, or defer SDS gaps to a later manual pass.",
4280
+ "If coverage gaps remain, refine the backlog itself until the backlog is enough to build the product.",
4281
+ ].join("\n");
4282
+ }
4283
+ schemaSnippetForAction(action) {
4284
+ switch (action) {
4285
+ case "epics":
4286
+ return EPIC_SCHEMA_SNIPPET;
4287
+ case "stories":
4288
+ return STORY_SCHEMA_SNIPPET;
4289
+ case "tasks":
4290
+ return TASK_SCHEMA_SNIPPET;
4291
+ case "full_plan":
4292
+ return FULL_PLAN_SCHEMA_SNIPPET;
4293
+ default:
4294
+ return FULL_PLAN_SCHEMA_SNIPPET;
4295
+ }
4296
+ }
4297
+ buildPlanOutline(plan, options) {
4298
+ const maxEpics = options?.maxEpics ?? 16;
4299
+ const maxStoriesPerEpic = options?.maxStoriesPerEpic ?? 8;
4300
+ const maxTasksPerStory = options?.maxTasksPerStory ?? 10;
4301
+ const summary = {
4302
+ epics: plan.epics.slice(0, maxEpics).map((epic) => ({
4303
+ localId: epic.localId,
4304
+ title: epic.title,
4305
+ area: epic.area,
4306
+ serviceIds: normalizeStringArray(epic.serviceIds),
4307
+ tags: normalizeStringArray(epic.tags),
4308
+ acceptanceCriteria: (epic.acceptanceCriteria ?? []).slice(0, 8),
4309
+ stories: plan.stories
4310
+ .filter((story) => story.epicLocalId === epic.localId)
4311
+ .slice(0, maxStoriesPerEpic)
4312
+ .map((story) => ({
4313
+ localId: story.localId,
4314
+ title: story.title,
4315
+ userStory: story.userStory,
4316
+ acceptanceCriteria: (story.acceptanceCriteria ?? []).slice(0, 8),
4317
+ tasks: plan.tasks
4318
+ .filter((task) => task.epicLocalId === epic.localId && task.storyLocalId === story.localId)
4319
+ .slice(0, maxTasksPerStory)
4320
+ .map((task) => ({
4321
+ localId: task.localId,
4322
+ title: task.title,
4323
+ type: task.type,
4324
+ dependsOnKeys: normalizeStringArray(task.dependsOnKeys),
4325
+ description: task.description,
4326
+ unitTests: normalizeStringArray(task.unitTests),
4327
+ componentTests: normalizeStringArray(task.componentTests),
4328
+ integrationTests: normalizeStringArray(task.integrationTests),
4329
+ apiTests: normalizeStringArray(task.apiTests),
4330
+ })),
4331
+ })),
4332
+ })),
4333
+ };
4334
+ return JSON.stringify(summary, null, 2);
4335
+ }
4336
+ parseFullPlan(output, options) {
4337
+ const parsed = extractJson(output);
4338
+ if (!parsed || !Array.isArray(parsed.epics) || parsed.epics.length === 0) {
4339
+ throw new Error("Agent did not return a full backlog plan in expected format");
4340
+ }
4341
+ return this.materializePlanFromSeed(parsed, options);
4342
+ }
4343
+ async normalizeGeneratedPlan(params) {
4344
+ const normalizedPlanEpics = this.alignEpicsToServiceCatalog(params.plan.epics, params.serviceCatalog, params.unknownEpicServicePolicy);
4345
+ for (const warning of normalizedPlanEpics.warnings) {
4346
+ await this.jobService.appendLog(params.jobId, `[create-tasks] ${warning}\n`);
4347
+ }
4348
+ let plan = {
4349
+ ...params.plan,
4350
+ epics: normalizedPlanEpics.epics.map((epic, index) => ({
4351
+ ...epic,
4352
+ localId: epic.localId ?? `e${index + 1}`,
4353
+ stories: [],
4354
+ })),
4355
+ };
4356
+ plan = this.enforceStoryScopedDependencies(plan);
4357
+ plan = this.injectStructureBootstrapPlan(plan, params.docs, params.serviceCatalog.projectKey);
4358
+ plan = this.enforceStoryScopedDependencies(plan);
4359
+ this.validatePlanLocalIdentifiers(plan);
4360
+ plan = this.applyServiceDependencySequencing(plan, params.docs);
4361
+ plan = this.enforceStoryScopedDependencies(plan);
4362
+ this.validatePlanLocalIdentifiers(plan);
4363
+ this.derivePlanningArtifacts(params.serviceCatalog.projectKey, params.docs, plan, params.sourceTopologyExpectation);
4364
+ return plan;
4365
+ }
4366
+ buildRefinementPrompt(params) {
4367
+ const serviceCatalogSummary = this.buildServiceCatalogPromptSummary(params.serviceCatalog);
4368
+ const planOutline = this.buildPlanOutline(params.currentPlan, params.options);
4369
+ const refinementLimits = [
4370
+ params.options.maxEpics ? `- Limit epics to ${params.options.maxEpics}.` : "",
4371
+ params.options.maxStoriesPerEpic ? `- Limit stories per epic to ${params.options.maxStoriesPerEpic}.` : "",
4372
+ params.options.maxTasksPerStory ? `- Limit tasks per story to ${params.options.maxTasksPerStory}.` : "",
4373
+ ]
4374
+ .filter(Boolean)
4375
+ .join("\n");
4376
+ const plannedGapBundles = params.audit.plannedGapBundles.length > 0
4377
+ ? JSON.stringify(params.audit.plannedGapBundles.slice(0, 48), null, 2)
4378
+ : "[]";
4379
+ return [
4380
+ this.buildCreateTasksAgentMission(params.projectKey),
4381
+ `Refinement iteration ${params.iteration}. The current backlog did not yet satisfy the SDS sufficiency audit.`,
4382
+ "Return a complete replacement backlog as valid JSON only matching:",
4383
+ FULL_PLAN_SCHEMA_SNIPPET,
4384
+ "Refinement rules:",
4385
+ "- Return the full revised backlog, not a delta.",
4386
+ "- Preserve already-good backlog slices unless a stricter SDS-aligned replacement is required.",
4387
+ "- Every epic must map to one or more serviceIds from the phase-0 service catalog.",
4388
+ "- Every story must contain concrete tasks, and every task must stay scoped to its own story.",
4389
+ "- Every task must be implementation-concrete, name real targets when the SDS exposes them, and include unit/component/integration/api test arrays ([] when not applicable).",
4390
+ "- Fix the specific missing SDS coverage items listed below. Do not claim coverage unless the backlog contains executable work for them.",
4391
+ "- Maintain dependency-first sequencing from foundational/runtime prerequisites through verification and acceptance evidence.",
4392
+ refinementLimits || "- Use reasonable scope without over-generating backlog items.",
4393
+ "Why this revision is required:",
4394
+ formatBullets(params.reasons, "SDS coverage gaps remain."),
4395
+ "Current backlog outline:",
4396
+ planOutline,
4397
+ "Remaining section headings:",
4398
+ formatBullets(params.audit.remainingSectionHeadings, "none"),
4399
+ "Remaining folder entries:",
4400
+ formatBullets(params.audit.remainingFolderEntries, "none"),
4401
+ "Actionable gap bundles (anchor + concrete implementation targets):",
4402
+ plannedGapBundles,
4403
+ "Project construction method:",
4404
+ params.projectBuildMethod,
4405
+ "Phase 0 service catalog (allowed serviceIds):",
4406
+ serviceCatalogSummary,
4407
+ "Docs available:",
4408
+ params.docSummary || "- (no docs provided; propose sensible refinements).",
4409
+ ].join("\n\n");
4410
+ }
4411
+ async refinePlanWithAgent(params) {
4412
+ const prompt = this.buildRefinementPrompt({
4413
+ projectKey: params.projectKey,
4414
+ currentPlan: params.currentPlan,
4415
+ audit: params.audit,
4416
+ reasons: params.reasons,
4417
+ docSummary: params.docSummary,
4418
+ projectBuildMethod: params.projectBuildMethod,
4419
+ serviceCatalog: params.serviceCatalog,
4420
+ options: params.options,
4421
+ iteration: params.iteration,
4422
+ });
4423
+ const { output } = await this.invokeAgentWithRetry(params.agent, prompt, "full_plan", params.agentStream, params.jobId, params.commandRunId, {
4424
+ refinementIteration: params.iteration,
4425
+ remainingGapCount: params.audit.remainingGaps.total,
4426
+ remainingSectionCount: params.audit.remainingSectionHeadings.length,
4427
+ remainingFolderCount: params.audit.remainingFolderEntries.length,
4428
+ });
4429
+ const refinedPlan = this.parseFullPlan(output, params.options);
4430
+ return this.normalizeGeneratedPlan({
4431
+ plan: refinedPlan,
4432
+ docs: params.docs,
4433
+ serviceCatalog: params.serviceCatalog,
4434
+ sourceTopologyExpectation: params.sourceTopologyExpectation,
4435
+ unknownEpicServicePolicy: params.unknownEpicServicePolicy,
4436
+ jobId: params.jobId,
4437
+ });
4438
+ }
4439
+ async runTaskSufficiencyAudit(params) {
4440
+ if (!this.taskSufficiencyFactory) {
4441
+ return { warnings: [] };
4442
+ }
4443
+ let audit;
4444
+ let error;
4445
+ let closeError;
4446
+ try {
4447
+ const sufficiencyService = await this.taskSufficiencyFactory(this.workspace);
4448
+ try {
4449
+ audit = await sufficiencyService.runAudit({
4450
+ workspace: params.workspace,
4451
+ projectKey: params.projectKey,
4452
+ sourceCommand: params.sourceCommand,
4453
+ dryRun: params.dryRun,
4454
+ });
4455
+ }
4456
+ finally {
4457
+ try {
4458
+ await sufficiencyService.close();
4459
+ }
4460
+ catch (caught) {
4461
+ closeError = caught?.message ?? String(caught);
4462
+ await this.jobService.appendLog(params.jobId, `Task sufficiency audit close warning: ${closeError}\n`);
4463
+ }
4464
+ }
4465
+ }
4466
+ catch (caught) {
4467
+ error = caught?.message ?? String(caught);
4468
+ }
4469
+ return {
4470
+ audit,
4471
+ error,
4472
+ warnings: uniqueStrings([
4473
+ ...(audit?.warnings ?? []),
4474
+ ...(closeError ? [`Task sufficiency audit close warning: ${closeError}`] : []),
4475
+ ]),
4476
+ };
4477
+ }
3010
4478
  buildPrompt(projectKey, docSummary, projectBuildMethod, serviceCatalog, options) {
3011
4479
  const serviceCatalogSummary = this.buildServiceCatalogPromptSummary(serviceCatalog);
3012
4480
  const limits = [
@@ -3017,12 +4485,14 @@ export class CreateTasksService {
3017
4485
  .filter(Boolean)
3018
4486
  .join(" ");
3019
4487
  const prompt = [
3020
- `You are assisting in phase 1 of 3 for project ${projectKey}: generate epics only.`,
3021
- "Process is strict and direct: build plan -> epics -> stories -> tasks.",
4488
+ this.buildCreateTasksAgentMission(projectKey),
4489
+ `You are assisting in phase 1 of 3 for project ${projectKey}: understand the documented services/tools and generate epics only.`,
4490
+ "Process is strict and direct: understand services/tools -> epics -> stories -> tasks -> sufficiency refinement.",
3022
4491
  "This step outputs only epics derived from the build plan and docs.",
3023
4492
  "Return strictly valid JSON (no prose) matching:",
3024
4493
  EPIC_SCHEMA_SNIPPET,
3025
4494
  "Rules:",
4495
+ "- First reason through which documented services/tools must be created or changed, then express that understanding through executable implementation epics.",
3026
4496
  "- Do NOT include final slugs; the system will assign keys.",
3027
4497
  "- Use docdex handles when referencing docs.",
3028
4498
  "- acceptanceCriteria must be an array of strings (5-10 items).",
@@ -3238,7 +4708,7 @@ export class CreateTasksService {
3238
4708
  const attempt = 2;
3239
4709
  const fixPrompt = [
3240
4710
  "Rewrite the previous response into valid JSON matching the expected schema.",
3241
- `Schema hint:\n${action === "epics" ? EPIC_SCHEMA_SNIPPET : action === "stories" ? STORY_SCHEMA_SNIPPET : TASK_SCHEMA_SNIPPET}`,
4711
+ `Schema hint:\n${this.schemaSnippetForAction(action)}`,
3242
4712
  "Return JSON only; no prose.",
3243
4713
  `Original content:\n${output}`,
3244
4714
  ].join("\n\n");
@@ -3342,8 +4812,9 @@ export class CreateTasksService {
3342
4812
  }))
3343
4813
  .filter((e) => e.title);
3344
4814
  }
3345
- async generateStoriesForEpic(agent, epic, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
4815
+ async generateStoriesForEpic(agent, projectKey, epic, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
3346
4816
  const prompt = [
4817
+ this.buildCreateTasksAgentMission(projectKey),
3347
4818
  `Generate user stories for epic "${epic.title}" (phase 2 of 3).`,
3348
4819
  "This phase is stories-only. Do not generate tasks yet.",
3349
4820
  "Return JSON only matching:",
@@ -3353,6 +4824,7 @@ export class CreateTasksService {
3353
4824
  "- acceptanceCriteria must be an array of strings.",
3354
4825
  "- Use docdex handles when citing docs.",
3355
4826
  "- Keep stories direct and implementation-oriented; avoid placeholder-only narrative sections.",
4827
+ "- Define the minimum set of user stories that, when completed, will finish this epic according to the SDS and construction method.",
3356
4828
  "- Keep story sequencing aligned with the project construction method.",
3357
4829
  "- Preserve canonical documented names for modules, services, interfaces, commands, schemas, and files exactly as written.",
3358
4830
  `Epic context (key=${epic.key ?? epic.localId ?? "TBD"}):`,
@@ -3383,7 +4855,7 @@ export class CreateTasksService {
3383
4855
  }))
3384
4856
  .filter((s) => s.title);
3385
4857
  }
3386
- async generateTasksForStory(agent, epic, story, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
4858
+ async generateTasksForStory(agent, projectKey, epic, story, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
3387
4859
  const parseTestList = (value) => {
3388
4860
  if (!Array.isArray(value))
3389
4861
  return [];
@@ -3393,6 +4865,7 @@ export class CreateTasksService {
3393
4865
  .filter(Boolean);
3394
4866
  };
3395
4867
  const prompt = [
4868
+ this.buildCreateTasksAgentMission(projectKey),
3396
4869
  `Generate tasks for story "${story.title}" (Epic: ${epic.title}, phase 3 of 3).`,
3397
4870
  "This phase is tasks-only for the given story.",
3398
4871
  "Return JSON only matching:",
@@ -3411,6 +4884,7 @@ export class CreateTasksService {
3411
4884
  "- Start from prerequisite codebase setup: add structure/bootstrap tasks before feature tasks when missing.",
3412
4885
  "- Keep dependencies strictly inside this story; never reference tasks from other stories/epics.",
3413
4886
  "- Order tasks from foundational prerequisites to dependents based on documented dependency direction and startup constraints.",
4887
+ "- Generate the concrete work that would actually complete this story in one automated backlog pass; do not leave implied implementation gaps behind.",
3414
4888
  "- Avoid placeholder wording (TBD, TODO, to be defined, generic follow-up phrases).",
3415
4889
  "- Avoid documentation-only or glossary-only tasks unless story acceptance explicitly requires them.",
3416
4890
  "- Preserve canonical documented names for modules, services, interfaces, commands, schemas, and files exactly as written.",
@@ -3549,7 +5023,7 @@ export class CreateTasksService {
3549
5023
  },
3550
5024
  ];
3551
5025
  }
3552
- async generatePlanFromAgent(epics, agent, docSummary, options) {
5026
+ async generatePlanFromAgent(projectKey, epics, agent, docSummary, options) {
3553
5027
  const planEpics = epics.map((epic, idx) => ({
3554
5028
  ...epic,
3555
5029
  localId: epic.localId ?? `e${idx + 1}`,
@@ -3561,7 +5035,7 @@ export class CreateTasksService {
3561
5035
  let stories = [];
3562
5036
  let usedFallbackStories = false;
3563
5037
  try {
3564
- stories = await this.generateStoriesForEpic(agent, { ...epic }, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
5038
+ stories = await this.generateStoriesForEpic(agent, projectKey, { ...epic }, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
3565
5039
  }
3566
5040
  catch (error) {
3567
5041
  usedFallbackStories = true;
@@ -3594,7 +5068,7 @@ export class CreateTasksService {
3594
5068
  }
3595
5069
  else {
3596
5070
  try {
3597
- tasks = await this.generateTasksForStory(agent, { key: story.epicLocalId, title: story.title }, story, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
5071
+ tasks = await this.generateTasksForStory(agent, projectKey, { key: story.epicLocalId, title: story.title }, story, docSummary, options.projectBuildMethod, options.agentStream, options.jobId, options.commandRunId);
3598
5072
  }
3599
5073
  catch (error) {
3600
5074
  await this.jobService.appendLog(options.jobId, `Task generation failed for story "${story.title}" (${storyScope}). Using deterministic fallback tasks. Reason: ${error.message ?? String(error)}\n`);
@@ -3652,31 +5126,62 @@ export class CreateTasksService {
3652
5126
  throw new Error(`create-tasks produced inconsistent coverage artifacts for project "${projectKey}". coverage-report.json diverged from task sufficiency coverage.`);
3653
5127
  }
3654
5128
  }
5129
+ assertPlanningArtifactConsistency(projectKey, buildPlan, serviceCatalog) {
5130
+ const sort = (values) => [...values].sort((left, right) => left.localeCompare(right));
5131
+ const catalogSourceDocs = sort(uniqueStrings(serviceCatalog.sourceDocs));
5132
+ const buildPlanSourceDocs = sort(uniqueStrings(buildPlan.sourceDocs));
5133
+ if (JSON.stringify(catalogSourceDocs) !== JSON.stringify(buildPlanSourceDocs)) {
5134
+ throw new Error(`create-tasks produced inconsistent planning artifacts for project "${projectKey}". build-plan.json and services.json disagree on source docs.`);
5135
+ }
5136
+ const catalogServiceNames = serviceCatalog.services.map((service) => service.name);
5137
+ const catalogServiceIds = serviceCatalog.services.map((service) => service.id);
5138
+ const expectedBuildPlanServiceNames = catalogServiceNames.slice(0, buildPlan.services.length);
5139
+ const expectedBuildPlanServiceIds = catalogServiceIds.slice(0, buildPlan.serviceIds.length);
5140
+ if (JSON.stringify(buildPlan.services) !== JSON.stringify(expectedBuildPlanServiceNames) ||
5141
+ JSON.stringify(buildPlan.serviceIds) !== JSON.stringify(expectedBuildPlanServiceIds)) {
5142
+ throw new Error(`create-tasks produced inconsistent planning artifacts for project "${projectKey}". build-plan.json and services.json disagree on service identity ordering.`);
5143
+ }
5144
+ const catalogServicesByName = new Map(serviceCatalog.services.map((service) => [service.name, service]));
5145
+ const unknownWaveServices = buildPlan.startupWaves.flatMap((wave) => wave.services
5146
+ .filter((serviceName) => !catalogServicesByName.has(serviceName))
5147
+ .map((serviceName) => `wave ${wave.wave}:${serviceName}`));
5148
+ if (unknownWaveServices.length > 0) {
5149
+ throw new Error(`create-tasks produced inconsistent planning artifacts for project "${projectKey}". build-plan.json references services missing from services.json: ${unknownWaveServices.slice(0, 8).join(", ")}.`);
5150
+ }
5151
+ }
3655
5152
  async loadExpectedCoverageFromSufficiencyReport(reportPath) {
3656
5153
  if (!reportPath)
3657
5154
  return undefined;
5155
+ let raw;
3658
5156
  try {
3659
- const raw = await fs.readFile(reportPath, "utf8");
3660
- const parsed = JSON.parse(raw);
3661
- const finalCoverage = parsed.finalCoverage;
3662
- if (!finalCoverage)
3663
- return undefined;
3664
- if (typeof finalCoverage.coverageRatio !== "number" ||
3665
- typeof finalCoverage.totalSignals !== "number" ||
3666
- !Array.isArray(finalCoverage.missingSectionHeadings) ||
3667
- !Array.isArray(finalCoverage.missingFolderEntries)) {
3668
- return undefined;
3669
- }
3670
- return {
3671
- coverageRatio: finalCoverage.coverageRatio,
3672
- totalSignals: finalCoverage.totalSignals,
3673
- missingSectionHeadings: finalCoverage.missingSectionHeadings.filter((value) => typeof value === "string"),
3674
- missingFolderEntries: finalCoverage.missingFolderEntries.filter((value) => typeof value === "string"),
3675
- };
5157
+ raw = await fs.readFile(reportPath, "utf8");
3676
5158
  }
3677
- catch {
3678
- return undefined;
5159
+ catch (error) {
5160
+ const message = error?.message ?? String(error);
5161
+ throw new Error(`create-tasks failed to load task sufficiency coverage report from "${reportPath}": ${message}`);
5162
+ }
5163
+ let parsed;
5164
+ try {
5165
+ parsed = JSON.parse(raw);
5166
+ }
5167
+ catch (error) {
5168
+ const message = error?.message ?? String(error);
5169
+ throw new Error(`create-tasks failed to parse task sufficiency coverage report from "${reportPath}": ${message}`);
5170
+ }
5171
+ const finalCoverage = parsed.finalCoverage;
5172
+ if (!finalCoverage ||
5173
+ typeof finalCoverage.coverageRatio !== "number" ||
5174
+ typeof finalCoverage.totalSignals !== "number" ||
5175
+ !Array.isArray(finalCoverage.missingSectionHeadings) ||
5176
+ !Array.isArray(finalCoverage.missingFolderEntries)) {
5177
+ throw new Error(`create-tasks failed to load task sufficiency coverage report from "${reportPath}": finalCoverage is incomplete.`);
3679
5178
  }
5179
+ return {
5180
+ coverageRatio: finalCoverage.coverageRatio,
5181
+ totalSignals: finalCoverage.totalSignals,
5182
+ missingSectionHeadings: finalCoverage.missingSectionHeadings.filter((value) => typeof value === "string"),
5183
+ missingFolderEntries: finalCoverage.missingFolderEntries.filter((value) => typeof value === "string"),
5184
+ };
3680
5185
  }
3681
5186
  buildSdsCoverageReport(projectKey, docs, plan, existingAnchors = new Set()) {
3682
5187
  const coverageSignals = collectSdsCoverageSignalsFromDocs(docs, {
@@ -3934,6 +5439,7 @@ export class CreateTasksService {
3934
5439
  await fs.mkdir(baseDir, { recursive: true });
3935
5440
  const releaseLock = await this.acquirePlanArtifactLock(baseDir);
3936
5441
  try {
5442
+ this.assertCanonicalNameConsistency(projectKey, docs, plan);
3937
5443
  const write = async (file, data) => {
3938
5444
  const target = path.join(baseDir, file);
3939
5445
  await this.writeJsonArtifactAtomic(target, data);
@@ -3951,6 +5457,7 @@ export class CreateTasksService {
3951
5457
  await write("epics.json", plan.epics);
3952
5458
  await write("stories.json", plan.stories);
3953
5459
  await write("tasks.json", plan.tasks);
5460
+ this.assertPlanningArtifactConsistency(projectKey, buildPlan, serviceCatalog);
3954
5461
  const coverageReport = this.buildSdsCoverageReport(projectKey, docs, plan, options?.existingCoverageAnchors ?? new Set());
3955
5462
  if (options?.expectedCoverage) {
3956
5463
  this.assertCoverageConsistency(projectKey, coverageReport, options.expectedCoverage);
@@ -4333,8 +5840,17 @@ export class CreateTasksService {
4333
5840
  const docs = await this.prepareDocs(preflightDocInputs);
4334
5841
  const { docSummary, warnings: indexedDocWarnings } = this.buildDocContext(docs);
4335
5842
  const docWarnings = uniqueStrings([...(sdsPreflight?.warnings ?? []), ...indexedDocWarnings]);
4336
- const initialArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, { epics: [], stories: [], tasks: [] });
5843
+ const sourceTopologyExpectation = this.buildSourceTopologyExpectation(docs);
5844
+ const initialArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, { epics: [], stories: [], tasks: [] }, sourceTopologyExpectation);
4337
5845
  const { discoveryGraph, topologySignals, serviceCatalog, projectBuildMethod, projectBuildPlan } = initialArtifacts;
5846
+ const sdsDrivenPlan = this.buildSdsDrivenPlan(options.projectKey, docs, serviceCatalog, discoveryGraph);
5847
+ const deterministicFallbackPlan = this.hasStrongSdsPlanningEvidence(docs, serviceCatalog, sourceTopologyExpectation)
5848
+ ? sdsDrivenPlan
5849
+ : this.materializePlanFromSeed(this.fallbackPlan(options.projectKey, docs), {
5850
+ maxEpics: options.maxEpics,
5851
+ maxStoriesPerEpic: options.maxStoriesPerEpic,
5852
+ maxTasksPerStory: options.maxTasksPerStory,
5853
+ });
4338
5854
  const { prompt } = this.buildPrompt(options.projectKey, docSummary, projectBuildMethod, serviceCatalog, options);
4339
5855
  const qaPreflight = await this.buildQaPreflight();
4340
5856
  const qaOverrides = this.buildQaOverrides(options);
@@ -4396,7 +5912,7 @@ export class CreateTasksService {
4396
5912
  timestamp: new Date().toISOString(),
4397
5913
  details: { epics: epics.length, source: "agent" },
4398
5914
  });
4399
- plan = await this.generatePlanFromAgent(epics, agent, docSummary, {
5915
+ plan = await this.generatePlanFromAgent(options.projectKey, epics, agent, docSummary, {
4400
5916
  agentStream,
4401
5917
  jobId: job.id,
4402
5918
  commandRunId: commandRun.id,
@@ -4411,38 +5927,41 @@ export class CreateTasksService {
4411
5927
  /unknown service ids|phase-0 service references/i.test(fallbackReason)) {
4412
5928
  throw error;
4413
5929
  }
4414
- planSource = "fallback";
4415
- await this.jobService.appendLog(job.id, `Agent planning failed, using deterministic fallback plan: ${fallbackReason}\n`);
4416
- plan = this.materializePlanFromSeed(this.fallbackPlan(options.projectKey, docs), {
4417
- maxEpics: options.maxEpics,
4418
- maxStoriesPerEpic: options.maxStoriesPerEpic,
4419
- maxTasksPerStory: options.maxTasksPerStory,
4420
- });
5930
+ await this.jobService.appendLog(job.id, `Agent planning failed, using deterministic planner fallback: ${fallbackReason}\n`);
5931
+ planSource = deterministicFallbackPlan === sdsDrivenPlan ? "sds" : "fallback";
5932
+ plan = deterministicFallbackPlan;
4421
5933
  await this.jobService.writeCheckpoint(job.id, {
4422
5934
  stage: "epics_generated",
4423
5935
  timestamp: new Date().toISOString(),
4424
5936
  details: { epics: plan.epics.length, source: planSource, reason: fallbackReason },
4425
5937
  });
4426
5938
  }
4427
- const normalizedPlanEpics = this.alignEpicsToServiceCatalog(plan.epics, serviceCatalog, unknownEpicServicePolicy);
4428
- for (const warning of normalizedPlanEpics.warnings) {
4429
- await this.jobService.appendLog(job.id, `[create-tasks] ${warning}\n`);
5939
+ plan = await this.normalizeGeneratedPlan({
5940
+ plan,
5941
+ docs,
5942
+ serviceCatalog,
5943
+ sourceTopologyExpectation,
5944
+ unknownEpicServicePolicy,
5945
+ jobId: job.id,
5946
+ });
5947
+ if (this.planLooksTooWeakForSds(plan, docs, serviceCatalog, sourceTopologyExpectation)) {
5948
+ fallbackReason = [
5949
+ fallbackReason,
5950
+ `generated backlog was too weak for SDS-first acceptance (epics=${plan.epics.length}, tasks=${plan.tasks.length})`,
5951
+ ]
5952
+ .filter(Boolean)
5953
+ .join("; ");
5954
+ planSource = "sds";
5955
+ plan = await this.normalizeGeneratedPlan({
5956
+ plan: sdsDrivenPlan,
5957
+ docs,
5958
+ serviceCatalog,
5959
+ sourceTopologyExpectation,
5960
+ unknownEpicServicePolicy,
5961
+ jobId: job.id,
5962
+ });
5963
+ await this.jobService.appendLog(job.id, `create-tasks replaced the weak generated backlog with the SDS-first deterministic plan. Reason: ${fallbackReason}\n`);
4430
5964
  }
4431
- plan = {
4432
- ...plan,
4433
- epics: normalizedPlanEpics.epics.map((epic, index) => ({
4434
- ...epic,
4435
- localId: epic.localId ?? `e${index + 1}`,
4436
- stories: [],
4437
- })),
4438
- };
4439
- plan = this.enforceStoryScopedDependencies(plan);
4440
- plan = this.injectStructureBootstrapPlan(plan, docs, options.projectKey);
4441
- plan = this.enforceStoryScopedDependencies(plan);
4442
- this.validatePlanLocalIdentifiers(plan);
4443
- plan = this.applyServiceDependencySequencing(plan, docs);
4444
- plan = this.enforceStoryScopedDependencies(plan);
4445
- this.validatePlanLocalIdentifiers(plan);
4446
5965
  await this.jobService.writeCheckpoint(job.id, {
4447
5966
  stage: "stories_generated",
4448
5967
  timestamp: new Date().toISOString(),
@@ -4468,91 +5987,155 @@ export class CreateTasksService {
4468
5987
  await this.seedPriorities(options.projectKey);
4469
5988
  let sufficiencyAudit;
4470
5989
  let sufficiencyAuditError;
4471
- if (this.taskSufficiencyFactory) {
4472
- let sufficiencyCloseError;
4473
- try {
4474
- const sufficiencyService = await this.taskSufficiencyFactory(this.workspace);
4475
- try {
4476
- sufficiencyAudit = await sufficiencyService.runAudit({
4477
- workspace: options.workspace,
4478
- projectKey: options.projectKey,
4479
- sourceCommand: "create-tasks",
4480
- });
4481
- }
4482
- finally {
4483
- try {
4484
- await sufficiencyService.close();
4485
- }
4486
- catch (closeError) {
4487
- sufficiencyCloseError = closeError?.message ?? String(closeError);
4488
- await this.jobService.appendLog(job.id, `Task sufficiency audit close warning: ${sufficiencyCloseError}\n`);
4489
- }
4490
- }
4491
- }
4492
- catch (error) {
4493
- sufficiencyAuditError = error?.message ?? String(error);
4494
- }
5990
+ const sufficiencyWarnings = [];
5991
+ let refinementAttempt = 0;
5992
+ while (true) {
5993
+ const auditResult = await this.runTaskSufficiencyAudit({
5994
+ workspace: options.workspace,
5995
+ projectKey: options.projectKey,
5996
+ sourceCommand: "create-tasks",
5997
+ dryRun: true,
5998
+ jobId: job.id,
5999
+ });
6000
+ sufficiencyAudit = auditResult.audit;
6001
+ sufficiencyAuditError = auditResult.error;
6002
+ sufficiencyWarnings.push(...auditResult.warnings);
4495
6003
  if (!sufficiencyAudit) {
4496
- const message = `create-tasks blocked: task sufficiency audit failed (${sufficiencyAuditError ?? "unknown error"}).`;
4497
- await this.jobService.writeCheckpoint(job.id, {
4498
- stage: "task_sufficiency_audit",
4499
- timestamp: new Date().toISOString(),
4500
- details: {
4501
- status: "failed",
4502
- error: message,
4503
- jobId: undefined,
4504
- commandRunId: undefined,
4505
- satisfied: false,
4506
- dryRun: undefined,
4507
- totalTasksAdded: undefined,
4508
- totalTasksUpdated: undefined,
4509
- finalCoverageRatio: undefined,
4510
- reportPath: undefined,
4511
- remainingSectionCount: undefined,
4512
- remainingFolderCount: undefined,
4513
- remainingGapCount: undefined,
4514
- warnings: [],
4515
- },
4516
- });
4517
- throw new Error(message);
6004
+ break;
4518
6005
  }
4519
- const sufficiencyWarnings = uniqueStrings([
4520
- ...(sufficiencyAudit.warnings ?? []),
4521
- ...(sufficiencyCloseError ? [`Task sufficiency audit close warning: ${sufficiencyCloseError}`] : []),
4522
- ]);
4523
- if (!sufficiencyAudit.satisfied) {
4524
- sufficiencyAuditError = `SDS coverage target not reached (coverage=${sufficiencyAudit.finalCoverageRatio}, remaining gaps=${sufficiencyAudit.remainingGaps.total}).`;
6006
+ if (sufficiencyAudit.satisfied) {
6007
+ sufficiencyAuditError = undefined;
6008
+ break;
6009
+ }
6010
+ const refinementReasons = [
6011
+ `SDS coverage target not reached (coverage=${sufficiencyAudit.finalCoverageRatio}, remaining gaps=${sufficiencyAudit.remainingGaps.total}).`,
6012
+ ...(sufficiencyAudit.remainingSectionHeadings.length > 0
6013
+ ? [
6014
+ `Remaining section headings: ${sufficiencyAudit.remainingSectionHeadings.slice(0, 12).join(", ")}`,
6015
+ ]
6016
+ : []),
6017
+ ...(sufficiencyAudit.remainingFolderEntries.length > 0
6018
+ ? [
6019
+ `Remaining folder entries: ${sufficiencyAudit.remainingFolderEntries.slice(0, 12).join(", ")}`,
6020
+ ]
6021
+ : []),
6022
+ ];
6023
+ if (!agent || refinementAttempt >= CreateTasksService.MAX_AGENT_REFINEMENT_ATTEMPTS) {
6024
+ sufficiencyAuditError = refinementReasons[0];
6025
+ break;
4525
6026
  }
6027
+ refinementAttempt += 1;
4526
6028
  await this.jobService.writeCheckpoint(job.id, {
4527
- stage: "task_sufficiency_audit",
6029
+ stage: "backlog_refinement",
4528
6030
  timestamp: new Date().toISOString(),
4529
6031
  details: {
4530
- status: sufficiencyAudit.satisfied ? "succeeded" : "blocked",
4531
- error: sufficiencyAuditError,
4532
- jobId: sufficiencyAudit.jobId,
4533
- commandRunId: sufficiencyAudit.commandRunId,
4534
- satisfied: sufficiencyAudit.satisfied,
4535
- dryRun: sufficiencyAudit.dryRun,
4536
- totalTasksAdded: sufficiencyAudit.totalTasksAdded,
4537
- totalTasksUpdated: sufficiencyAudit.totalTasksUpdated,
4538
- finalCoverageRatio: sufficiencyAudit.finalCoverageRatio,
4539
- reportPath: sufficiencyAudit.reportPath,
6032
+ iteration: refinementAttempt,
6033
+ remainingGapCount: sufficiencyAudit.remainingGaps.total,
4540
6034
  remainingSectionCount: sufficiencyAudit.remainingSectionHeadings.length,
4541
6035
  remainingFolderCount: sufficiencyAudit.remainingFolderEntries.length,
4542
- remainingGapCount: sufficiencyAudit.remainingGaps.total,
4543
- warnings: sufficiencyWarnings,
6036
+ plannedGapBundleCount: sufficiencyAudit.plannedGapBundles.length,
6037
+ unresolvedBundleCount: sufficiencyAudit.unresolvedBundles.length,
4544
6038
  },
4545
6039
  });
4546
- if (!sufficiencyAudit.satisfied) {
4547
- throw new Error(`create-tasks blocked: task sufficiency audit did not reach full coverage. Report: ${sufficiencyAudit.reportPath}`);
6040
+ try {
6041
+ plan = await this.refinePlanWithAgent({
6042
+ agent,
6043
+ currentPlan: plan,
6044
+ audit: sufficiencyAudit,
6045
+ reasons: refinementReasons,
6046
+ docs,
6047
+ docSummary,
6048
+ projectKey: options.projectKey,
6049
+ projectBuildMethod,
6050
+ serviceCatalog,
6051
+ sourceTopologyExpectation,
6052
+ unknownEpicServicePolicy,
6053
+ options,
6054
+ agentStream,
6055
+ jobId: job.id,
6056
+ commandRunId: commandRun.id,
6057
+ iteration: refinementAttempt,
6058
+ });
6059
+ planSource = "agent";
6060
+ await this.persistPlanToDb(project.id, options.projectKey, plan, job.id, commandRun.id, {
6061
+ force: true,
6062
+ resetKeys: true,
6063
+ qaPreflight,
6064
+ qaOverrides,
6065
+ });
6066
+ await this.seedPriorities(options.projectKey);
6067
+ await this.jobService.appendLog(job.id, `create-tasks refinement iteration ${refinementAttempt} replaced the backlog with ${plan.epics.length} epics, ${plan.stories.length} stories, and ${plan.tasks.length} tasks.\n`);
6068
+ }
6069
+ catch (error) {
6070
+ const message = error?.message ?? String(error);
6071
+ await this.jobService.appendLog(job.id, `create-tasks refinement iteration ${refinementAttempt} failed: ${message}\n`);
6072
+ sufficiencyAuditError = refinementReasons[0];
6073
+ if (refinementAttempt >= CreateTasksService.MAX_AGENT_REFINEMENT_ATTEMPTS) {
6074
+ break;
6075
+ }
4548
6076
  }
4549
6077
  }
6078
+ if (!sufficiencyAudit) {
6079
+ const message = `create-tasks blocked: task sufficiency audit failed (${sufficiencyAuditError ?? "unknown error"}).`;
6080
+ await this.jobService.writeCheckpoint(job.id, {
6081
+ stage: "task_sufficiency_audit",
6082
+ timestamp: new Date().toISOString(),
6083
+ details: {
6084
+ status: "failed",
6085
+ error: message,
6086
+ jobId: undefined,
6087
+ commandRunId: undefined,
6088
+ satisfied: false,
6089
+ dryRun: undefined,
6090
+ totalTasksAdded: undefined,
6091
+ totalTasksUpdated: undefined,
6092
+ finalCoverageRatio: undefined,
6093
+ reportPath: undefined,
6094
+ remainingSectionCount: undefined,
6095
+ remainingFolderCount: undefined,
6096
+ remainingGapCount: undefined,
6097
+ plannedGapBundleCount: undefined,
6098
+ warnings: [],
6099
+ },
6100
+ });
6101
+ throw new Error(message);
6102
+ }
6103
+ const uniqueSufficiencyWarnings = uniqueStrings(sufficiencyWarnings);
6104
+ sufficiencyAudit = {
6105
+ ...sufficiencyAudit,
6106
+ warnings: uniqueSufficiencyWarnings,
6107
+ };
6108
+ if (!sufficiencyAudit.satisfied) {
6109
+ sufficiencyAuditError = `SDS coverage target not reached (coverage=${sufficiencyAudit.finalCoverageRatio}, remaining gaps=${sufficiencyAudit.remainingGaps.total}).`;
6110
+ }
6111
+ await this.jobService.writeCheckpoint(job.id, {
6112
+ stage: "task_sufficiency_audit",
6113
+ timestamp: new Date().toISOString(),
6114
+ details: {
6115
+ status: sufficiencyAudit.satisfied ? "succeeded" : "blocked",
6116
+ error: sufficiencyAuditError,
6117
+ jobId: sufficiencyAudit.jobId,
6118
+ commandRunId: sufficiencyAudit.commandRunId,
6119
+ satisfied: sufficiencyAudit.satisfied,
6120
+ dryRun: sufficiencyAudit.dryRun,
6121
+ totalTasksAdded: sufficiencyAudit.totalTasksAdded,
6122
+ totalTasksUpdated: sufficiencyAudit.totalTasksUpdated,
6123
+ finalCoverageRatio: sufficiencyAudit.finalCoverageRatio,
6124
+ reportPath: sufficiencyAudit.reportPath,
6125
+ remainingSectionCount: sufficiencyAudit.remainingSectionHeadings.length,
6126
+ remainingFolderCount: sufficiencyAudit.remainingFolderEntries.length,
6127
+ remainingGapCount: sufficiencyAudit.remainingGaps.total,
6128
+ plannedGapBundleCount: sufficiencyAudit.plannedGapBundles.length,
6129
+ unresolvedBundleCount: sufficiencyAudit.unresolvedBundles.length,
6130
+ warnings: uniqueSufficiencyWarnings,
6131
+ },
6132
+ });
4550
6133
  if ((sufficiencyAudit?.totalTasksAdded ?? 0) > 0) {
4551
6134
  await this.seedPriorities(options.projectKey);
4552
6135
  }
4553
6136
  const finalBacklog = await this.loadPersistedBacklog(project.id);
4554
6137
  const finalPlan = this.buildPlanFromPersistedBacklog(finalBacklog);
4555
- const finalArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, finalPlan);
6138
+ const finalArtifacts = this.derivePlanningArtifacts(options.projectKey, docs, finalPlan, sourceTopologyExpectation);
4556
6139
  const finalCoverageAnchors = this.collectCoverageAnchorsFromBacklog(finalBacklog);
4557
6140
  const expectedCoverage = await this.loadExpectedCoverageFromSufficiencyReport(sufficiencyAudit?.reportPath);
4558
6141
  await this.writePlanArtifacts(options.projectKey, finalPlan, docSummary, docs, finalArtifacts.projectBuildPlan, finalArtifacts.serviceCatalog, {
@@ -4570,8 +6153,13 @@ export class CreateTasksService {
4570
6153
  dependencies: finalBacklog.dependencies.length,
4571
6154
  services: finalArtifacts.serviceCatalog.services.length,
4572
6155
  startupWaves: finalArtifacts.projectBuildPlan.startupWaves.length,
6156
+ acceptedWithResidualSectionGaps: false,
4573
6157
  },
4574
6158
  });
6159
+ const acceptedWithResidualSectionGaps = false;
6160
+ if (sufficiencyAudit && !sufficiencyAudit.satisfied) {
6161
+ throw new Error(`create-tasks blocked: task sufficiency audit did not reach full coverage. Report: ${sufficiencyAudit.reportPath}`);
6162
+ }
4575
6163
  await this.jobService.updateJobStatus(job.id, "completed", {
4576
6164
  payload: {
4577
6165
  epicsCreated: finalBacklog.epics.length,
@@ -4615,6 +6203,8 @@ export class CreateTasksService {
4615
6203
  remainingSectionCount: sufficiencyAudit.remainingSectionHeadings.length,
4616
6204
  remainingFolderCount: sufficiencyAudit.remainingFolderEntries.length,
4617
6205
  remainingGapCount: sufficiencyAudit.remainingGaps.total,
6206
+ unresolvedBundleCount: sufficiencyAudit.unresolvedBundles.length,
6207
+ acceptedWithResidualSectionGaps,
4618
6208
  warnings: sufficiencyAudit.warnings,
4619
6209
  }
4620
6210
  : undefined,
@@ -4812,3 +6402,4 @@ export class CreateTasksService {
4812
6402
  }
4813
6403
  CreateTasksService.MAX_BUSY_RETRIES = 6;
4814
6404
  CreateTasksService.BUSY_BACKOFF_MS = 500;
6405
+ CreateTasksService.MAX_AGENT_REFINEMENT_ATTEMPTS = 3;