@mcoda/core 0.1.26 → 0.1.28

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.
@@ -13,6 +13,7 @@ import { TaskOrderingService } from "../backlog/TaskOrderingService.js";
13
13
  import { QaTestCommandBuilder } from "../execution/QaTestCommandBuilder.js";
14
14
  import { createEpicKeyGenerator, createStoryKeyGenerator, createTaskKeyGenerator, } from "./KeyHelpers.js";
15
15
  import { TaskSufficiencyService } from "./TaskSufficiencyService.js";
16
+ import { SdsPreflightService } from "./SdsPreflightService.js";
16
17
  const formatBullets = (items, fallback) => {
17
18
  if (!items || items.length === 0)
18
19
  return `- ${fallback}`;
@@ -257,6 +258,14 @@ const extractMarkdownHeadings = (value, limit) => {
257
258
  !line.startsWith("*")) {
258
259
  headings.push(line);
259
260
  }
261
+ else {
262
+ const numberedHeading = line.match(/^(\d+(?:\.\d+)+)\s+(.+)$/);
263
+ if (numberedHeading) {
264
+ const headingText = `${numberedHeading[1]} ${numberedHeading[2]}`.trim();
265
+ if (/[a-z]/i.test(headingText))
266
+ headings.push(headingText);
267
+ }
268
+ }
260
269
  if (headings.length >= limit)
261
270
  break;
262
271
  }
@@ -398,58 +407,133 @@ const extractJsonObjects = (value) => {
398
407
  }
399
408
  return results;
400
409
  };
410
+ const normalizeAgentFailoverEvents = (value) => {
411
+ if (!Array.isArray(value))
412
+ return [];
413
+ const events = [];
414
+ for (const entry of value) {
415
+ if (!isPlainObject(entry))
416
+ continue;
417
+ if (typeof entry.type !== "string" || entry.type.trim().length === 0)
418
+ continue;
419
+ events.push({ ...entry });
420
+ }
421
+ return events;
422
+ };
423
+ const mergeAgentFailoverEvents = (left, right) => {
424
+ if (!left.length)
425
+ return right;
426
+ if (!right.length)
427
+ return left;
428
+ const seen = new Set();
429
+ const merged = [];
430
+ const signature = (event) => [
431
+ event.type ?? "",
432
+ event.fromAgentId ?? "",
433
+ event.toAgentId ?? "",
434
+ event.at ?? "",
435
+ event.until ?? "",
436
+ event.durationMs ?? "",
437
+ ].join("|");
438
+ for (const event of [...left, ...right]) {
439
+ const key = signature(event);
440
+ if (seen.has(key))
441
+ continue;
442
+ seen.add(key);
443
+ merged.push(event);
444
+ }
445
+ return merged;
446
+ };
447
+ const mergeAgentInvocationMetadata = (current, incoming) => {
448
+ if (!current && !incoming)
449
+ return undefined;
450
+ if (!incoming)
451
+ return current;
452
+ if (!current)
453
+ return { ...incoming };
454
+ const merged = { ...current, ...incoming };
455
+ const currentEvents = normalizeAgentFailoverEvents(current.failoverEvents);
456
+ const incomingEvents = normalizeAgentFailoverEvents(incoming.failoverEvents);
457
+ if (currentEvents.length > 0 || incomingEvents.length > 0) {
458
+ merged.failoverEvents = mergeAgentFailoverEvents(currentEvents, incomingEvents);
459
+ }
460
+ return merged;
461
+ };
462
+ const summarizeAgentFailoverEvent = (event) => {
463
+ const type = String(event.type ?? "unknown");
464
+ if (type === "switch_agent") {
465
+ const from = typeof event.fromAgentId === "string" ? event.fromAgentId : "unknown";
466
+ const to = typeof event.toAgentId === "string" ? event.toAgentId : "unknown";
467
+ return `switch_agent ${from} -> ${to}`;
468
+ }
469
+ if (type === "sleep_until_reset") {
470
+ const duration = typeof event.durationMs === "number" && Number.isFinite(event.durationMs)
471
+ ? `${Math.round(event.durationMs / 1000)}s`
472
+ : "unknown duration";
473
+ const until = typeof event.until === "string" ? event.until : "unknown";
474
+ return `sleep_until_reset ${duration} (until ${until})`;
475
+ }
476
+ if (type === "stream_restart_after_limit") {
477
+ const from = typeof event.fromAgentId === "string" ? event.fromAgentId : "unknown";
478
+ return `stream_restart_after_limit from ${from}`;
479
+ }
480
+ return type;
481
+ };
482
+ const resolveTerminalFailoverAgentId = (events, fallbackAgentId) => {
483
+ for (let index = events.length - 1; index >= 0; index -= 1) {
484
+ const event = events[index];
485
+ if (event?.type !== "switch_agent")
486
+ continue;
487
+ if (typeof event.toAgentId === "string" && event.toAgentId.trim().length > 0) {
488
+ return event.toAgentId;
489
+ }
490
+ }
491
+ return fallbackAgentId;
492
+ };
493
+ const compactNarrative = (value, fallback, maxLines = 5) => {
494
+ if (!value || value.trim().length === 0)
495
+ return fallback;
496
+ const lines = value
497
+ .split(/\r?\n/)
498
+ .map((line) => line
499
+ .replace(/^[*-]\s+/, "")
500
+ .replace(/^#+\s+/, "")
501
+ .replace(/^\*+\s*\*\*(.+?)\*\*\s*$/, "$1")
502
+ .trim())
503
+ .filter(Boolean)
504
+ .filter((line) => !/^(in scope|out of scope|key flows?|non-functional requirements|dependencies|risks|acceptance criteria|related docs?)\s*:/i.test(line))
505
+ .slice(0, maxLines);
506
+ return lines.length > 0 ? lines.join("\n") : fallback;
507
+ };
401
508
  const buildEpicDescription = (epicKey, title, description, acceptance, relatedDocs) => {
509
+ const context = compactNarrative(description, `Deliver ${title} with implementation-ready scope and sequencing aligned to SDS guidance.`, 6);
402
510
  return [
403
511
  `* **Epic Key**: ${epicKey}`,
404
512
  `* **Epic Title**: ${title}`,
405
513
  "* **Context / Problem**",
406
514
  "",
407
- ensureNonEmpty(description, "Summarize the problem, users, and constraints for this epic."),
408
- "* **Goals & Outcomes**",
409
- formatBullets(acceptance, "List measurable outcomes for this epic."),
410
- "* **In Scope**",
411
- "- Clarify during refinement; derived from RFP/PDR/SDS.",
412
- "* **Out of Scope**",
413
- "- To be defined; exclude unrelated systems.",
414
- "* **Key Flows / Scenarios**",
415
- "- Outline primary user flows for this epic.",
416
- "* **Non-functional Requirements**",
417
- "- Performance, security, reliability expectations go here.",
418
- "* **Dependencies & Constraints**",
419
- "- Capture upstream/downstream systems and blockers.",
420
- "* **Risks & Open Questions**",
421
- "- Identify risks and unknowns to resolve.",
515
+ context,
422
516
  "* **Acceptance Criteria**",
423
- formatBullets(acceptance, "Provide 5–10 testable acceptance criteria."),
517
+ formatBullets(acceptance, "Define measurable and testable outcomes for this epic."),
424
518
  "* **Related Documentation / References**",
425
- formatBullets(relatedDocs, "Link relevant docdex entries and sections."),
519
+ formatBullets(relatedDocs, "Link SDS/PDR/OpenAPI references used by this epic."),
426
520
  ].join("\n");
427
521
  };
428
522
  const buildStoryDescription = (storyKey, title, userStory, description, acceptanceCriteria, relatedDocs) => {
523
+ const userStoryText = compactNarrative(userStory, `As a user, I want ${title} so that it delivers clear product value.`, 3);
524
+ const contextText = compactNarrative(description, `Implement ${title} with concrete scope and dependency context.`, 5);
429
525
  return [
430
526
  `* **Story Key**: ${storyKey}`,
431
527
  "* **User Story**",
432
528
  "",
433
- ensureNonEmpty(userStory, `As a user, I want ${title} so that it delivers value.`),
529
+ userStoryText,
434
530
  "* **Context**",
435
531
  "",
436
- ensureNonEmpty(description, "Context for systems, dependencies, and scope."),
437
- "* **Preconditions / Assumptions**",
438
- "- Confirm required data, environments, and access.",
439
- "* **Main Flow**",
440
- "- Outline the happy path for this story.",
441
- "* **Alternative / Error Flows**",
442
- "- Capture error handling and non-happy paths.",
443
- "* **UX / UI Notes**",
444
- "- Enumerate screens/states if applicable.",
445
- "* **Data & Integrations**",
446
- "- Note key entities, APIs, queues, or third-party dependencies.",
532
+ contextText,
447
533
  "* **Acceptance Criteria**",
448
534
  formatBullets(acceptanceCriteria, "List testable outcomes for this story."),
449
- "* **Non-functional Requirements**",
450
- "- Add story-specific performance/reliability/security expectations.",
451
535
  "* **Related Documentation / References**",
452
- formatBullets(relatedDocs, "Docdex handles, OpenAPI endpoints, code modules."),
536
+ formatBullets(relatedDocs, "Docdex handles, OpenAPI endpoints, and code modules."),
453
537
  ].join("\n");
454
538
  };
455
539
  const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, relatedDocs, dependencies, tests, qa) => {
@@ -463,7 +547,7 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
463
547
  })
464
548
  .join("\n");
465
549
  };
466
- const objectiveText = ensureNonEmpty(description, `Deliver ${title} for story ${storyKey}.`);
550
+ const objectiveText = compactNarrative(description, `Deliver ${title} for story ${storyKey}.`, 3);
467
551
  const implementationLines = extractActionableLines(description, 4);
468
552
  const riskLines = extractRiskLines(description, 3);
469
553
  const testsDefined = (tests.unitTests?.length ?? 0) +
@@ -482,14 +566,14 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
482
566
  qa?.blockers?.length ? "- Remaining QA blockers are explicit and actionable." : "- QA blockers are resolved or not present.",
483
567
  ];
484
568
  const defaultImplementationPlan = [
485
- `- Implement ${title} with file/module-level changes aligned to the objective.`,
569
+ `Implement ${title} with concrete file/module-level changes aligned to the objective.`,
486
570
  dependencies.length
487
- ? `- Respect dependency order before completion: ${dependencies.join(", ")}.`
488
- : "- Validate assumptions and finalize concrete implementation steps before coding.",
571
+ ? `Respect dependency order before completion: ${dependencies.join(", ")}.`
572
+ : "Finalize concrete implementation steps before coding and keep scope bounded.",
489
573
  ];
490
574
  const defaultRisks = dependencies.length
491
- ? [`- Delivery depends on upstream tasks: ${dependencies.join(", ")}.`]
492
- : ["- Keep implementation aligned to SDS/OpenAPI contracts to avoid drift."];
575
+ ? [`Delivery depends on upstream tasks: ${dependencies.join(", ")}.`]
576
+ : ["Keep implementation aligned to SDS/OpenAPI contracts to avoid drift."];
493
577
  return [
494
578
  `* **Task Key**: ${taskKey}`,
495
579
  "* **Objective**",
@@ -500,7 +584,7 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
500
584
  `- Epic: ${epicKey}`,
501
585
  `- Story: ${storyKey}`,
502
586
  "* **Inputs**",
503
- formatBullets(relatedDocs, "Docdex excerpts, SDS/PDR/RFP sections, OpenAPI endpoints."),
587
+ formatBullets(relatedDocs, "No explicit external references."),
504
588
  "* **Implementation Plan**",
505
589
  formatBullets(implementationLines, defaultImplementationPlan.join(" ")),
506
590
  "* **Definition of Done**",
@@ -519,11 +603,11 @@ const buildTaskDescription = (taskKey, title, description, storyKey, epicKey, re
519
603
  "* **QA Blockers**",
520
604
  formatBullets(qa?.blockers, "None known."),
521
605
  "* **Dependencies**",
522
- formatBullets(dependencies, "Enumerate prerequisite tasks by key."),
606
+ formatBullets(dependencies, "None."),
523
607
  "* **Risks & Gotchas**",
524
608
  formatBullets(riskLines, defaultRisks.join(" ")),
525
609
  "* **Related Documentation / References**",
526
- formatBullets(relatedDocs, "Docdex handles or file paths to consult."),
610
+ formatBullets(relatedDocs, "None."),
527
611
  ].join("\n");
528
612
  };
529
613
  const collectFilesRecursively = async (target) => {
@@ -729,6 +813,7 @@ export class CreateTasksService {
729
813
  this.ratingService = deps.ratingService;
730
814
  this.taskOrderingFactory = deps.taskOrderingFactory ?? TaskOrderingService.create;
731
815
  this.taskSufficiencyFactory = deps.taskSufficiencyFactory ?? TaskSufficiencyService.create;
816
+ this.sdsPreflightFactory = deps.sdsPreflightFactory ?? SdsPreflightService.create;
732
817
  }
733
818
  static async create(workspace) {
734
819
  const repo = await GlobalRepository.create();
@@ -750,6 +835,7 @@ export class CreateTasksService {
750
835
  workspaceRepo,
751
836
  routingService,
752
837
  taskSufficiencyFactory: TaskSufficiencyService.create,
838
+ sdsPreflightFactory: SdsPreflightService.create,
753
839
  });
754
840
  }
755
841
  async close() {
@@ -838,6 +924,20 @@ export class CreateTasksService {
838
924
  const resolved = path.isAbsolute(input) ? input : path.join(this.workspace.workspaceRoot, input);
839
925
  return path.resolve(resolved).toLowerCase();
840
926
  }
927
+ mergeDocInputs(primary, extras) {
928
+ const merged = [];
929
+ const seen = new Set();
930
+ for (const input of [...primary, ...extras]) {
931
+ if (!input?.trim())
932
+ continue;
933
+ const key = this.normalizeDocInputForSet(input);
934
+ if (seen.has(key))
935
+ continue;
936
+ seen.add(key);
937
+ merged.push(input);
938
+ }
939
+ return merged;
940
+ }
841
941
  docIdentity(doc) {
842
942
  const pathKey = `${doc.path ?? ""}`.trim().toLowerCase();
843
943
  const idKey = `${doc.id ?? ""}`.trim().toLowerCase();
@@ -1489,6 +1589,21 @@ export class CreateTasksService {
1489
1589
  "5) Keep task dependencies story-scoped while preserving epic/story/task ordering by this build method.",
1490
1590
  ].join("\n");
1491
1591
  }
1592
+ buildProjectPlanArtifact(projectKey, docs, graph, buildMethod) {
1593
+ const sourceDocs = docs
1594
+ .map((doc) => doc.path ?? (doc.id ? `docdex:${doc.id}` : doc.title ?? "doc"))
1595
+ .filter((value) => Boolean(value))
1596
+ .slice(0, 24);
1597
+ return {
1598
+ projectKey,
1599
+ generatedAt: new Date().toISOString(),
1600
+ sourceDocs,
1601
+ startupWaves: graph.startupWaves.slice(0, 12),
1602
+ services: graph.services.slice(0, 40),
1603
+ foundationalDependencies: graph.foundationalDependencies.slice(0, 16),
1604
+ buildMethod,
1605
+ };
1606
+ }
1492
1607
  orderStoryTasksByDependencies(storyTasks, serviceRank, taskServiceByScope) {
1493
1608
  const byLocalId = new Map(storyTasks.map((task) => [task.localId, task]));
1494
1609
  const indegree = new Map();
@@ -2093,8 +2208,11 @@ export class CreateTasksService {
2093
2208
  const segmentHeadings = (doc.segments ?? [])
2094
2209
  .map((segment) => segment.heading?.trim())
2095
2210
  .filter((heading) => Boolean(heading));
2211
+ const segmentContentHeadings = (doc.segments ?? [])
2212
+ .flatMap((segment) => extractMarkdownHeadings(segment.content ?? "", Math.max(6, Math.ceil(limit / 4))))
2213
+ .slice(0, limit);
2096
2214
  const contentHeadings = extractMarkdownHeadings(doc.content ?? "", limit);
2097
- for (const heading of [...segmentHeadings, ...contentHeadings]) {
2215
+ for (const heading of [...segmentHeadings, ...segmentContentHeadings, ...contentHeadings]) {
2098
2216
  const normalized = heading.replace(/[`*_]/g, "").trim();
2099
2217
  if (!normalized)
2100
2218
  continue;
@@ -2187,17 +2305,18 @@ export class CreateTasksService {
2187
2305
  .filter(Boolean)
2188
2306
  .join(" ");
2189
2307
  const prompt = [
2190
- `You are assisting in creating EPICS ONLY for project ${projectKey}.`,
2191
- "Follow mcoda SDS epic template:",
2192
- "- Context/Problem; Goals & Outcomes; In Scope; Out of Scope; Key Flows; Non-functional Requirements; Dependencies & Constraints; Risks & Open Questions; Acceptance Criteria; Related Documentation.",
2308
+ `You are assisting in phase 1 of 3 for project ${projectKey}: generate epics only.`,
2309
+ "Process is strict and direct: build plan -> epics -> stories -> tasks.",
2310
+ "This step outputs only epics derived from the build plan and docs.",
2193
2311
  "Return strictly valid JSON (no prose) matching:",
2194
2312
  EPIC_SCHEMA_SNIPPET,
2195
2313
  "Rules:",
2196
2314
  "- Do NOT include final slugs; the system will assign keys.",
2197
2315
  "- Use docdex handles when referencing docs.",
2198
2316
  "- acceptanceCriteria must be an array of strings (5-10 items).",
2199
- "- Prefer dependency-first sequencing: foundational codebase/service setup epics should precede dependent feature epics.",
2200
- "- Keep output technology-agnostic and derived from docs; do not assume specific stacks unless docs state them.",
2317
+ "- Keep epics actionable and implementation-oriented; avoid glossary/admin-only epics.",
2318
+ "- Prefer dependency-first sequencing: foundational setup epics before dependent feature epics.",
2319
+ "- Keep output derived from docs; do not assume stacks unless docs state them.",
2201
2320
  "Project construction method to follow:",
2202
2321
  projectBuildMethod,
2203
2322
  limits || "Use reasonable scope without over-generating epics.",
@@ -2337,6 +2456,7 @@ export class CreateTasksService {
2337
2456
  async invokeAgentWithRetry(agent, prompt, action, stream, jobId, commandRunId, metadata) {
2338
2457
  const startedAt = Date.now();
2339
2458
  let output = "";
2459
+ let invocationMetadata;
2340
2460
  const logChunk = async (chunk) => {
2341
2461
  if (!chunk)
2342
2462
  return;
@@ -2344,17 +2464,51 @@ export class CreateTasksService {
2344
2464
  if (stream)
2345
2465
  process.stdout.write(chunk);
2346
2466
  };
2467
+ const baseInvocationMetadata = {
2468
+ command: "create-tasks",
2469
+ action,
2470
+ phase: `create_tasks_${action}`,
2471
+ };
2472
+ const logFailoverEvents = async (events) => {
2473
+ if (!events.length)
2474
+ return;
2475
+ for (const event of events) {
2476
+ await this.jobService.appendLog(jobId, `[create-tasks] agent failover (${action}): ${summarizeAgentFailoverEvent(event)}\n`);
2477
+ }
2478
+ };
2479
+ const resolveUsageAgent = async (events) => {
2480
+ const agentId = resolveTerminalFailoverAgentId(events, agent.id);
2481
+ if (agentId === agent.id) {
2482
+ return { id: agent.id, defaultModel: agent.defaultModel };
2483
+ }
2484
+ try {
2485
+ const resolved = await this.agentService.resolveAgent(agentId);
2486
+ return { id: resolved.id, defaultModel: resolved.defaultModel };
2487
+ }
2488
+ catch (error) {
2489
+ await this.jobService.appendLog(jobId, `[create-tasks] unable to resolve failover agent (${agentId}) for usage accounting: ${error instanceof Error ? error.message : String(error)}\n`);
2490
+ return { id: agent.id, defaultModel: agent.defaultModel };
2491
+ }
2492
+ };
2347
2493
  try {
2348
2494
  if (stream) {
2349
- const gen = await this.agentService.invokeStream(agent.id, { input: prompt });
2495
+ const gen = await this.agentService.invokeStream(agent.id, {
2496
+ input: prompt,
2497
+ metadata: baseInvocationMetadata,
2498
+ });
2350
2499
  for await (const chunk of gen) {
2351
2500
  output += chunk.output ?? "";
2501
+ invocationMetadata = mergeAgentInvocationMetadata(invocationMetadata, chunk.metadata);
2352
2502
  await logChunk(chunk.output);
2353
2503
  }
2354
2504
  }
2355
2505
  else {
2356
- const result = await this.agentService.invoke(agent.id, { input: prompt });
2506
+ const result = await this.agentService.invoke(agent.id, {
2507
+ input: prompt,
2508
+ metadata: baseInvocationMetadata,
2509
+ });
2357
2510
  output = result.output ?? "";
2511
+ invocationMetadata = mergeAgentInvocationMetadata(invocationMetadata, result.metadata);
2358
2512
  await logChunk(output);
2359
2513
  }
2360
2514
  }
@@ -2371,10 +2525,22 @@ export class CreateTasksService {
2371
2525
  `Original content:\n${output}`,
2372
2526
  ].join("\n\n");
2373
2527
  try {
2374
- const fix = await this.agentService.invoke(agent.id, { input: fixPrompt });
2528
+ let retryInvocationMetadata;
2529
+ const fix = await this.agentService.invoke(agent.id, {
2530
+ input: fixPrompt,
2531
+ metadata: {
2532
+ ...baseInvocationMetadata,
2533
+ attempt: 2,
2534
+ stage: "json_repair",
2535
+ },
2536
+ });
2375
2537
  output = fix.output ?? "";
2538
+ retryInvocationMetadata = mergeAgentInvocationMetadata(retryInvocationMetadata, fix.metadata);
2376
2539
  parsed = extractJson(output);
2377
2540
  if (parsed) {
2541
+ const failoverEvents = normalizeAgentFailoverEvents(retryInvocationMetadata?.failoverEvents);
2542
+ await logFailoverEvents(failoverEvents);
2543
+ const usageAgent = await resolveUsageAgent(failoverEvents);
2378
2544
  const promptTokens = estimateTokens(prompt);
2379
2545
  const completionTokens = estimateTokens(output);
2380
2546
  const durationSeconds = (Date.now() - startedAt) / 1000;
@@ -2383,8 +2549,8 @@ export class CreateTasksService {
2383
2549
  workspaceId: this.workspace.workspaceId,
2384
2550
  jobId,
2385
2551
  commandRunId,
2386
- agentId: agent.id,
2387
- modelName: agent.defaultModel,
2552
+ agentId: usageAgent.id,
2553
+ modelName: usageAgent.defaultModel,
2388
2554
  promptTokens,
2389
2555
  completionTokens,
2390
2556
  tokensPrompt: promptTokens,
@@ -2395,6 +2561,7 @@ export class CreateTasksService {
2395
2561
  action: `create_tasks_${action}`,
2396
2562
  phase: `create_tasks_${action}`,
2397
2563
  attempt,
2564
+ failoverEvents: failoverEvents.length > 0 ? failoverEvents : undefined,
2398
2565
  ...(metadata ?? {}),
2399
2566
  },
2400
2567
  });
@@ -2408,6 +2575,9 @@ export class CreateTasksService {
2408
2575
  if (!parsed) {
2409
2576
  throw new Error(`Agent output was not valid JSON for ${action}`);
2410
2577
  }
2578
+ const failoverEvents = normalizeAgentFailoverEvents(invocationMetadata?.failoverEvents);
2579
+ await logFailoverEvents(failoverEvents);
2580
+ const usageAgent = await resolveUsageAgent(failoverEvents);
2411
2581
  const promptTokens = estimateTokens(prompt);
2412
2582
  const completionTokens = estimateTokens(output);
2413
2583
  const durationSeconds = (Date.now() - startedAt) / 1000;
@@ -2416,8 +2586,8 @@ export class CreateTasksService {
2416
2586
  workspaceId: this.workspace.workspaceId,
2417
2587
  jobId,
2418
2588
  commandRunId,
2419
- agentId: agent.id,
2420
- modelName: agent.defaultModel,
2589
+ agentId: usageAgent.id,
2590
+ modelName: usageAgent.defaultModel,
2421
2591
  promptTokens,
2422
2592
  completionTokens,
2423
2593
  tokensPrompt: promptTokens,
@@ -2428,6 +2598,7 @@ export class CreateTasksService {
2428
2598
  action: `create_tasks_${action}`,
2429
2599
  phase: `create_tasks_${action}`,
2430
2600
  attempt: 1,
2601
+ failoverEvents: failoverEvents.length > 0 ? failoverEvents : undefined,
2431
2602
  ...(metadata ?? {}),
2432
2603
  },
2433
2604
  });
@@ -2453,14 +2624,15 @@ export class CreateTasksService {
2453
2624
  }
2454
2625
  async generateStoriesForEpic(agent, epic, docSummary, projectBuildMethod, stream, jobId, commandRunId) {
2455
2626
  const prompt = [
2456
- `Generate user stories for epic "${epic.title}".`,
2457
- "Use the User Story template: User Story; Context; Preconditions; Main Flow; Alternative/Error Flows; UX/UI; Data & Integrations; Acceptance Criteria; NFR; Related Docs.",
2627
+ `Generate user stories for epic "${epic.title}" (phase 2 of 3).`,
2628
+ "This phase is stories-only. Do not generate tasks yet.",
2458
2629
  "Return JSON only matching:",
2459
2630
  STORY_SCHEMA_SNIPPET,
2460
2631
  "Rules:",
2461
2632
  "- No tasks in this step.",
2462
2633
  "- acceptanceCriteria must be an array of strings.",
2463
2634
  "- Use docdex handles when citing docs.",
2635
+ "- Keep stories direct and implementation-oriented; avoid placeholder-only narrative sections.",
2464
2636
  "- Keep story sequencing aligned with the project construction method.",
2465
2637
  `Epic context (key=${epic.key ?? epic.localId ?? "TBD"}):`,
2466
2638
  epic.description ?? "(no description provided)",
@@ -2498,8 +2670,8 @@ export class CreateTasksService {
2498
2670
  .filter(Boolean);
2499
2671
  };
2500
2672
  const prompt = [
2501
- `Generate tasks for story "${story.title}" (Epic: ${epic.title}).`,
2502
- "Use the Task template: Objective; Context; Inputs; Implementation Plan; DoD; Testing & QA; Dependencies; Risks; References.",
2673
+ `Generate tasks for story "${story.title}" (Epic: ${epic.title}, phase 3 of 3).`,
2674
+ "This phase is tasks-only for the given story.",
2503
2675
  "Return JSON only matching:",
2504
2676
  TASK_SCHEMA_SNIPPET,
2505
2677
  "Rules:",
@@ -2517,6 +2689,7 @@ export class CreateTasksService {
2517
2689
  "- Keep dependencies strictly inside this story; never reference tasks from other stories/epics.",
2518
2690
  "- Order tasks from foundational prerequisites to dependents based on documented dependency direction and startup constraints.",
2519
2691
  "- Avoid placeholder wording (TBD, TODO, to be defined, generic follow-up phrases).",
2692
+ "- Avoid documentation-only or glossary-only tasks unless story acceptance explicitly requires them.",
2520
2693
  "- Use docdex handles when citing docs.",
2521
2694
  "- If OPENAPI_HINTS are present in Docs, align tasks with hinted service/capability/stage/test_requirements.",
2522
2695
  "- If SDS_COVERAGE_HINTS are present in Docs, cover the relevant SDS sections in implementation tasks.",
@@ -2766,14 +2939,15 @@ export class CreateTasksService {
2766
2939
  : ["Coverage is heading-based heuristic match between SDS sections and generated epic/story/task corpus."],
2767
2940
  };
2768
2941
  }
2769
- async writePlanArtifacts(projectKey, plan, docSummary, docs) {
2942
+ async writePlanArtifacts(projectKey, plan, docSummary, docs, buildPlan) {
2770
2943
  const baseDir = path.join(this.workspace.mcodaDir, "tasks", projectKey);
2771
2944
  await fs.mkdir(baseDir, { recursive: true });
2772
2945
  const write = async (file, data) => {
2773
2946
  const target = path.join(baseDir, file);
2774
2947
  await fs.writeFile(target, JSON.stringify(data, null, 2), "utf8");
2775
2948
  };
2776
- await write("plan.json", { projectKey, generatedAt: new Date().toISOString(), docSummary, ...plan });
2949
+ await write("plan.json", { projectKey, generatedAt: new Date().toISOString(), docSummary, buildPlan, ...plan });
2950
+ await write("build-plan.json", buildPlan);
2777
2951
  await write("epics.json", plan.epics);
2778
2952
  await write("stories.json", plan.stories);
2779
2953
  await write("tasks.json", plan.tasks);
@@ -3021,6 +3195,7 @@ export class CreateTasksService {
3021
3195
  inputs: options.inputs,
3022
3196
  agent: options.agentName,
3023
3197
  agentStream,
3198
+ sdsPreflightCommit: options.sdsPreflightCommit === true,
3024
3199
  },
3025
3200
  });
3026
3201
  let lastError;
@@ -3031,10 +3206,113 @@ export class CreateTasksService {
3031
3206
  name: options.projectKey,
3032
3207
  description: `Workspace project ${options.projectKey}`,
3033
3208
  });
3034
- const docs = await this.prepareDocs(options.inputs);
3035
- const { docSummary, warnings: docWarnings } = this.buildDocContext(docs);
3209
+ let sdsPreflight;
3210
+ let sdsPreflightError;
3211
+ if (this.sdsPreflightFactory) {
3212
+ let sdsPreflightCloseError;
3213
+ try {
3214
+ const preflightService = await this.sdsPreflightFactory(this.workspace);
3215
+ try {
3216
+ sdsPreflight = await preflightService.runPreflight({
3217
+ workspace: options.workspace,
3218
+ projectKey: options.projectKey,
3219
+ inputPaths: options.inputs,
3220
+ sdsPaths: options.inputs,
3221
+ writeArtifacts: true,
3222
+ applyToSds: true,
3223
+ commitAppliedChanges: options.sdsPreflightCommit === true,
3224
+ commitMessage: options.sdsPreflightCommitMessage,
3225
+ });
3226
+ }
3227
+ finally {
3228
+ try {
3229
+ await preflightService.close();
3230
+ }
3231
+ catch (closeError) {
3232
+ sdsPreflightCloseError = closeError?.message ?? String(closeError);
3233
+ await this.jobService.appendLog(job.id, `SDS preflight close warning: ${sdsPreflightCloseError}\n`);
3234
+ }
3235
+ }
3236
+ }
3237
+ catch (error) {
3238
+ sdsPreflightError = error?.message ?? String(error);
3239
+ }
3240
+ if (!sdsPreflight) {
3241
+ const message = `create-tasks blocked: SDS preflight failed before backlog generation (${sdsPreflightError ?? "unknown error"}).`;
3242
+ await this.jobService.writeCheckpoint(job.id, {
3243
+ stage: "sds_preflight",
3244
+ timestamp: new Date().toISOString(),
3245
+ details: {
3246
+ status: "failed",
3247
+ error: message,
3248
+ readyForPlanning: false,
3249
+ qualityStatus: undefined,
3250
+ sourceSdsCount: 0,
3251
+ issueCount: 0,
3252
+ blockingIssueCount: 0,
3253
+ questionCount: 0,
3254
+ requiredQuestionCount: 0,
3255
+ reportPath: undefined,
3256
+ openQuestionsPath: undefined,
3257
+ gapAddendumPath: undefined,
3258
+ warnings: [],
3259
+ },
3260
+ });
3261
+ throw new Error(message);
3262
+ }
3263
+ const preflightWarnings = uniqueStrings([
3264
+ ...(sdsPreflight.warnings ?? []),
3265
+ ...(sdsPreflightCloseError ? [`SDS preflight close warning: ${sdsPreflightCloseError}`] : []),
3266
+ ]);
3267
+ const blockingReasons = [];
3268
+ if (sdsPreflight.qualityStatus === "fail") {
3269
+ blockingReasons.push("SDS quality gates failed.");
3270
+ }
3271
+ if (sdsPreflight.blockingIssueCount > 0) {
3272
+ blockingReasons.push(`Blocking SDS issues: ${sdsPreflight.blockingIssueCount}.`);
3273
+ }
3274
+ if (sdsPreflight.requiredQuestionCount > 0) {
3275
+ blockingReasons.push(`Required open questions remaining: ${sdsPreflight.requiredQuestionCount}.`);
3276
+ }
3277
+ if (!sdsPreflight.readyForPlanning) {
3278
+ blockingReasons.push("SDS preflight reported planning context is not ready.");
3279
+ }
3280
+ if (blockingReasons.length > 0) {
3281
+ sdsPreflightError = blockingReasons.join(" ");
3282
+ }
3283
+ await this.jobService.writeCheckpoint(job.id, {
3284
+ stage: "sds_preflight",
3285
+ timestamp: new Date().toISOString(),
3286
+ details: {
3287
+ status: blockingReasons.length > 0 ? "blocked" : "succeeded",
3288
+ error: sdsPreflightError,
3289
+ readyForPlanning: sdsPreflight.readyForPlanning,
3290
+ qualityStatus: sdsPreflight.qualityStatus,
3291
+ sourceSdsCount: sdsPreflight.sourceSdsPaths.length,
3292
+ issueCount: sdsPreflight.issueCount,
3293
+ blockingIssueCount: sdsPreflight.blockingIssueCount,
3294
+ questionCount: sdsPreflight.questionCount,
3295
+ requiredQuestionCount: sdsPreflight.requiredQuestionCount,
3296
+ reportPath: sdsPreflight.reportPath,
3297
+ openQuestionsPath: sdsPreflight.openQuestionsPath,
3298
+ gapAddendumPath: sdsPreflight.gapAddendumPath,
3299
+ appliedToSds: sdsPreflight.appliedToSds,
3300
+ appliedSdsCount: sdsPreflight.appliedSdsPaths.length,
3301
+ commitHash: sdsPreflight.commitHash,
3302
+ warnings: preflightWarnings,
3303
+ },
3304
+ });
3305
+ if (blockingReasons.length > 0) {
3306
+ throw new Error(`create-tasks blocked by SDS preflight. ${blockingReasons.join(" ")} Report: ${sdsPreflight.reportPath}`);
3307
+ }
3308
+ }
3309
+ const preflightDocInputs = this.mergeDocInputs(options.inputs, sdsPreflight ? [...sdsPreflight.sourceSdsPaths, ...sdsPreflight.generatedDocPaths] : []);
3310
+ const docs = await this.prepareDocs(preflightDocInputs);
3311
+ const { docSummary, warnings: indexedDocWarnings } = this.buildDocContext(docs);
3312
+ const docWarnings = uniqueStrings([...(sdsPreflight?.warnings ?? []), ...indexedDocWarnings]);
3036
3313
  const discoveryGraph = this.buildServiceDependencyGraph({ epics: [], stories: [], tasks: [] }, docs);
3037
3314
  const projectBuildMethod = this.buildProjectConstructionMethod(docs, discoveryGraph);
3315
+ const projectBuildPlan = this.buildProjectPlanArtifact(options.projectKey, docs, discoveryGraph, projectBuildMethod);
3038
3316
  const { prompt } = this.buildPrompt(options.projectKey, docs, projectBuildMethod, options);
3039
3317
  const qaPreflight = await this.buildQaPreflight();
3040
3318
  const qaOverrides = this.buildQaOverrides(options);
@@ -3043,6 +3321,15 @@ export class CreateTasksService {
3043
3321
  timestamp: new Date().toISOString(),
3044
3322
  details: { count: docs.length, warnings: docWarnings, startupWaves: discoveryGraph.startupWaves.slice(0, 8) },
3045
3323
  });
3324
+ await this.jobService.writeCheckpoint(job.id, {
3325
+ stage: "build_plan_defined",
3326
+ timestamp: new Date().toISOString(),
3327
+ details: {
3328
+ sourceDocs: projectBuildPlan.sourceDocs.length,
3329
+ services: projectBuildPlan.services.length,
3330
+ startupWaves: projectBuildPlan.startupWaves.length,
3331
+ },
3332
+ });
3046
3333
  await this.jobService.writeCheckpoint(job.id, {
3047
3334
  stage: "qa_preflight",
3048
3335
  timestamp: new Date().toISOString(),
@@ -3102,7 +3389,7 @@ export class CreateTasksService {
3102
3389
  timestamp: new Date().toISOString(),
3103
3390
  details: { tasks: plan.tasks.length, source: planSource, fallbackReason },
3104
3391
  });
3105
- const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary, docs);
3392
+ const { folder } = await this.writePlanArtifacts(options.projectKey, plan, docSummary, docs, projectBuildPlan);
3106
3393
  await this.jobService.writeCheckpoint(job.id, {
3107
3394
  stage: "plan_written",
3108
3395
  timestamp: new Date().toISOString(),
@@ -3118,57 +3405,83 @@ export class CreateTasksService {
3118
3405
  let sufficiencyAudit;
3119
3406
  let sufficiencyAuditError;
3120
3407
  if (this.taskSufficiencyFactory) {
3408
+ let sufficiencyCloseError;
3121
3409
  try {
3122
3410
  const sufficiencyService = await this.taskSufficiencyFactory(this.workspace);
3123
3411
  try {
3124
- try {
3125
- sufficiencyAudit = await sufficiencyService.runAudit({
3126
- workspace: options.workspace,
3127
- projectKey: options.projectKey,
3128
- sourceCommand: "create-tasks",
3129
- });
3130
- }
3131
- catch (error) {
3132
- sufficiencyAuditError = error?.message ?? String(error);
3133
- await this.jobService.appendLog(job.id, `Task sufficiency audit failed; continuing with created backlog: ${sufficiencyAuditError}\n`);
3134
- }
3412
+ sufficiencyAudit = await sufficiencyService.runAudit({
3413
+ workspace: options.workspace,
3414
+ projectKey: options.projectKey,
3415
+ sourceCommand: "create-tasks",
3416
+ });
3135
3417
  }
3136
3418
  finally {
3137
3419
  try {
3138
3420
  await sufficiencyService.close();
3139
3421
  }
3140
3422
  catch (closeError) {
3141
- const closeMessage = closeError?.message ?? String(closeError);
3142
- const details = `Task sufficiency audit close failed; continuing with created backlog: ${closeMessage}`;
3143
- sufficiencyAuditError = sufficiencyAuditError ? `${sufficiencyAuditError}; ${details}` : details;
3144
- await this.jobService.appendLog(job.id, `${details}\n`);
3423
+ sufficiencyCloseError = closeError?.message ?? String(closeError);
3424
+ await this.jobService.appendLog(job.id, `Task sufficiency audit close warning: ${sufficiencyCloseError}\n`);
3145
3425
  }
3146
3426
  }
3147
3427
  }
3148
3428
  catch (error) {
3149
3429
  sufficiencyAuditError = error?.message ?? String(error);
3150
- await this.jobService.appendLog(job.id, `Task sufficiency audit setup failed; continuing with created backlog: ${sufficiencyAuditError}\n`);
3430
+ }
3431
+ if (!sufficiencyAudit) {
3432
+ const message = `create-tasks blocked: task sufficiency audit failed (${sufficiencyAuditError ?? "unknown error"}).`;
3433
+ await this.jobService.writeCheckpoint(job.id, {
3434
+ stage: "task_sufficiency_audit",
3435
+ timestamp: new Date().toISOString(),
3436
+ details: {
3437
+ status: "failed",
3438
+ error: message,
3439
+ jobId: undefined,
3440
+ commandRunId: undefined,
3441
+ satisfied: false,
3442
+ dryRun: undefined,
3443
+ totalTasksAdded: undefined,
3444
+ totalTasksUpdated: undefined,
3445
+ finalCoverageRatio: undefined,
3446
+ reportPath: undefined,
3447
+ remainingSectionCount: undefined,
3448
+ remainingFolderCount: undefined,
3449
+ remainingGapCount: undefined,
3450
+ warnings: [],
3451
+ },
3452
+ });
3453
+ throw new Error(message);
3454
+ }
3455
+ const sufficiencyWarnings = uniqueStrings([
3456
+ ...(sufficiencyAudit.warnings ?? []),
3457
+ ...(sufficiencyCloseError ? [`Task sufficiency audit close warning: ${sufficiencyCloseError}`] : []),
3458
+ ]);
3459
+ if (!sufficiencyAudit.satisfied) {
3460
+ sufficiencyAuditError = `SDS coverage target not reached (coverage=${sufficiencyAudit.finalCoverageRatio}, remaining gaps=${sufficiencyAudit.remainingGaps.total}).`;
3151
3461
  }
3152
3462
  await this.jobService.writeCheckpoint(job.id, {
3153
3463
  stage: "task_sufficiency_audit",
3154
3464
  timestamp: new Date().toISOString(),
3155
3465
  details: {
3156
- status: sufficiencyAudit ? "succeeded" : "failed",
3466
+ status: sufficiencyAudit.satisfied ? "succeeded" : "blocked",
3157
3467
  error: sufficiencyAuditError,
3158
- jobId: sufficiencyAudit?.jobId,
3159
- commandRunId: sufficiencyAudit?.commandRunId,
3160
- satisfied: sufficiencyAudit?.satisfied,
3161
- dryRun: sufficiencyAudit?.dryRun,
3162
- totalTasksAdded: sufficiencyAudit?.totalTasksAdded,
3163
- totalTasksUpdated: sufficiencyAudit?.totalTasksUpdated,
3164
- finalCoverageRatio: sufficiencyAudit?.finalCoverageRatio,
3165
- reportPath: sufficiencyAudit?.reportPath,
3166
- remainingSectionCount: sufficiencyAudit?.remainingSectionHeadings.length,
3167
- remainingFolderCount: sufficiencyAudit?.remainingFolderEntries.length,
3168
- remainingGapCount: sufficiencyAudit?.remainingGaps.total,
3169
- warnings: sufficiencyAudit?.warnings,
3468
+ jobId: sufficiencyAudit.jobId,
3469
+ commandRunId: sufficiencyAudit.commandRunId,
3470
+ satisfied: sufficiencyAudit.satisfied,
3471
+ dryRun: sufficiencyAudit.dryRun,
3472
+ totalTasksAdded: sufficiencyAudit.totalTasksAdded,
3473
+ totalTasksUpdated: sufficiencyAudit.totalTasksUpdated,
3474
+ finalCoverageRatio: sufficiencyAudit.finalCoverageRatio,
3475
+ reportPath: sufficiencyAudit.reportPath,
3476
+ remainingSectionCount: sufficiencyAudit.remainingSectionHeadings.length,
3477
+ remainingFolderCount: sufficiencyAudit.remainingFolderEntries.length,
3478
+ remainingGapCount: sufficiencyAudit.remainingGaps.total,
3479
+ warnings: sufficiencyWarnings,
3170
3480
  },
3171
3481
  });
3482
+ if (!sufficiencyAudit.satisfied) {
3483
+ throw new Error(`create-tasks blocked: task sufficiency audit did not reach full coverage. Report: ${sufficiencyAudit.reportPath}`);
3484
+ }
3172
3485
  }
3173
3486
  await this.jobService.updateJobStatus(job.id, "completed", {
3174
3487
  payload: {
@@ -3180,6 +3493,25 @@ export class CreateTasksService {
3180
3493
  planFolder: folder,
3181
3494
  planSource,
3182
3495
  fallbackReason,
3496
+ sdsPreflight: sdsPreflight
3497
+ ? {
3498
+ readyForPlanning: sdsPreflight.readyForPlanning,
3499
+ qualityStatus: sdsPreflight.qualityStatus,
3500
+ sourceSdsCount: sdsPreflight.sourceSdsPaths.length,
3501
+ issueCount: sdsPreflight.issueCount,
3502
+ blockingIssueCount: sdsPreflight.blockingIssueCount,
3503
+ questionCount: sdsPreflight.questionCount,
3504
+ requiredQuestionCount: sdsPreflight.requiredQuestionCount,
3505
+ appliedToSds: sdsPreflight.appliedToSds,
3506
+ appliedSdsPaths: sdsPreflight.appliedSdsPaths,
3507
+ commitHash: sdsPreflight.commitHash,
3508
+ reportPath: sdsPreflight.reportPath,
3509
+ openQuestionsPath: sdsPreflight.openQuestionsPath,
3510
+ gapAddendumPath: sdsPreflight.gapAddendumPath,
3511
+ warnings: sdsPreflight.warnings,
3512
+ }
3513
+ : undefined,
3514
+ sdsPreflightError,
3183
3515
  sufficiencyAudit: sufficiencyAudit
3184
3516
  ? {
3185
3517
  jobId: sufficiencyAudit.jobId,