@nathapp/nax 0.36.0 → 0.36.2

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.
Files changed (49) hide show
  1. package/dist/nax.js +543 -154
  2. package/package.json +1 -1
  3. package/src/agents/claude-decompose.ts +3 -3
  4. package/src/cli/constitution.ts +0 -92
  5. package/src/constitution/generator.ts +0 -33
  6. package/src/constitution/index.ts +2 -1
  7. package/src/constitution/loader.ts +1 -13
  8. package/src/context/builder.ts +1 -2
  9. package/src/context/elements.ts +1 -12
  10. package/src/context/index.ts +2 -1
  11. package/src/context/test-scanner.ts +1 -1
  12. package/src/execution/dry-run.ts +1 -1
  13. package/src/execution/escalation/escalation.ts +5 -3
  14. package/src/execution/escalation/tier-escalation.ts +41 -4
  15. package/src/execution/iteration-runner.ts +5 -0
  16. package/src/execution/parallel-executor.ts +293 -9
  17. package/src/execution/parallel.ts +40 -21
  18. package/src/execution/pipeline-result-handler.ts +3 -2
  19. package/src/execution/runner.ts +13 -3
  20. package/src/interaction/chain.ts +17 -1
  21. package/src/metrics/tracker.ts +8 -4
  22. package/src/metrics/types.ts +2 -0
  23. package/src/pipeline/event-bus.ts +1 -1
  24. package/src/pipeline/stages/completion.ts +1 -1
  25. package/src/pipeline/stages/execution.ts +23 -1
  26. package/src/pipeline/stages/verify.ts +8 -1
  27. package/src/pipeline/subscribers/reporters.ts +3 -3
  28. package/src/pipeline/types.ts +4 -0
  29. package/src/plugins/types.ts +1 -1
  30. package/src/prd/types.ts +2 -0
  31. package/src/prompts/builder.ts +13 -6
  32. package/src/prompts/sections/conventions.ts +5 -7
  33. package/src/prompts/sections/isolation.ts +7 -7
  34. package/src/prompts/sections/role-task.ts +64 -64
  35. package/src/review/orchestrator.ts +11 -1
  36. package/src/routing/strategies/llm-prompts.ts +1 -1
  37. package/src/routing/strategies/llm.ts +3 -3
  38. package/src/tdd/index.ts +2 -3
  39. package/src/tdd/isolation.ts +0 -13
  40. package/src/tdd/orchestrator.ts +5 -0
  41. package/src/tdd/prompts.ts +1 -231
  42. package/src/tdd/session-runner.ts +2 -0
  43. package/src/tdd/types.ts +2 -1
  44. package/src/tdd/verdict.ts +20 -2
  45. package/src/verification/crash-detector.ts +34 -0
  46. package/src/verification/orchestrator-types.ts +8 -1
  47. package/src/verification/parser.ts +0 -10
  48. package/src/verification/rectification-loop.ts +2 -51
  49. package/src/worktree/dispatcher.ts +0 -59
@@ -4,6 +4,8 @@
4
4
  * Handles the full parallel execution flow:
5
5
  * - Status updates with parallel info
6
6
  * - Execute parallel stories
7
+ * - Rectify merge conflicts (MFX-005): re-run conflicted stories sequentially
8
+ * on the updated base branch so each sees all previously merged stories
7
9
  * - Handle completion or continue to sequential
8
10
  */
9
11
 
@@ -17,17 +19,147 @@ import type { StoryMetrics } from "../metrics";
17
19
  import type { PipelineEventEmitter } from "../pipeline/events";
18
20
  import type { PluginRegistry } from "../plugins/registry";
19
21
  import type { PRD } from "../prd";
20
- import { countStories, isComplete } from "../prd";
22
+ import { countStories, isComplete, markStoryPassed } from "../prd";
21
23
  import { getAllReadyStories, hookCtx } from "./helpers";
22
24
  import { executeParallel } from "./parallel";
23
25
  import type { StatusWriter } from "./status-writer";
24
26
 
27
+ /** StoryMetrics extended with execution-path source */
28
+ export type ParallelStoryMetrics = StoryMetrics & {
29
+ source: "parallel" | "sequential" | "rectification";
30
+ rectifiedFromConflict?: boolean;
31
+ originalCost?: number;
32
+ rectificationCost?: number;
33
+ };
34
+
35
+ /** A story that conflicted during the initial parallel merge pass */
36
+ export interface ConflictedStoryInfo {
37
+ storyId: string;
38
+ conflictFiles: string[];
39
+ originalCost: number;
40
+ }
41
+
42
+ /** Result from attempting to rectify a single conflicted story */
43
+ export type RectificationResult =
44
+ | { success: true; storyId: string; cost: number }
45
+ | {
46
+ success: false;
47
+ storyId: string;
48
+ cost: number;
49
+ finalConflict: boolean;
50
+ pipelineFailure?: boolean;
51
+ conflictFiles?: string[];
52
+ };
53
+
54
+ /** Options passed to rectifyConflictedStory */
55
+ export interface RectifyConflictedStoryOptions extends ConflictedStoryInfo {
56
+ workdir: string;
57
+ config: NaxConfig;
58
+ hooks: LoadedHooksConfig;
59
+ pluginRegistry: PluginRegistry;
60
+ prd: PRD;
61
+ eventEmitter?: PipelineEventEmitter;
62
+ }
63
+
64
+ /**
65
+ * Actual implementation of rectifyConflictedStory.
66
+ *
67
+ * Steps:
68
+ * 1. Remove the old worktree
69
+ * 2. Create a fresh worktree from current HEAD (post-merge state)
70
+ * 3. Re-run the full story pipeline
71
+ * 4. Attempt merge on the updated base
72
+ * 5. Return success/finalConflict
73
+ */
74
+ async function rectifyConflictedStory(options: RectifyConflictedStoryOptions): Promise<RectificationResult> {
75
+ const { storyId, workdir, config, hooks, pluginRegistry, prd, eventEmitter } = options;
76
+ const logger = getSafeLogger();
77
+
78
+ logger?.info("parallel", "Rectifying story on updated base", { storyId, attempt: "rectification" });
79
+
80
+ try {
81
+ const { WorktreeManager } = await import("../worktree/manager");
82
+ const { MergeEngine } = await import("../worktree/merge");
83
+ const { runPipeline } = await import("../pipeline/runner");
84
+ const { defaultPipeline } = await import("../pipeline/stages");
85
+ const { routeTask } = await import("../routing");
86
+
87
+ const worktreeManager = new WorktreeManager();
88
+ const mergeEngine = new MergeEngine(worktreeManager);
89
+
90
+ // Step 1: Remove old worktree
91
+ try {
92
+ await worktreeManager.remove(workdir, storyId);
93
+ } catch {
94
+ // Ignore — worktree may have already been removed
95
+ }
96
+
97
+ // Step 2: Create fresh worktree from current HEAD
98
+ await worktreeManager.create(workdir, storyId);
99
+ const worktreePath = path.join(workdir, ".nax-wt", storyId);
100
+
101
+ // Step 3: Re-run the story pipeline
102
+ const story = prd.userStories.find((s) => s.id === storyId);
103
+ if (!story) {
104
+ return { success: false, storyId, cost: 0, finalConflict: false, pipelineFailure: true };
105
+ }
106
+
107
+ const routing = routeTask(story.title, story.description, story.acceptanceCriteria, story.tags, config);
108
+
109
+ const pipelineContext = {
110
+ config,
111
+ prd,
112
+ story,
113
+ stories: [story],
114
+ workdir: worktreePath,
115
+ featureDir: undefined,
116
+ hooks,
117
+ plugins: pluginRegistry,
118
+ storyStartTime: new Date().toISOString(),
119
+ routing: routing as import("../pipeline/types").RoutingResult,
120
+ };
121
+
122
+ const pipelineResult = await runPipeline(defaultPipeline, pipelineContext, eventEmitter);
123
+ const cost = pipelineResult.context.agentResult?.estimatedCost ?? 0;
124
+
125
+ if (!pipelineResult.success) {
126
+ logger?.info("parallel", "Rectification failed - preserving worktree", { storyId });
127
+ return { success: false, storyId, cost, finalConflict: false, pipelineFailure: true };
128
+ }
129
+
130
+ // Step 4: Attempt merge on updated base
131
+ const mergeResults = await mergeEngine.mergeAll(workdir, [storyId], { [storyId]: [] });
132
+ const mergeResult = mergeResults[0];
133
+
134
+ if (!mergeResult || !mergeResult.success) {
135
+ const conflictFiles = mergeResult?.conflictFiles ?? [];
136
+ logger?.info("parallel", "Rectification failed - preserving worktree", { storyId });
137
+ return { success: false, storyId, cost, finalConflict: true, conflictFiles };
138
+ }
139
+
140
+ logger?.info("parallel", "Rectification succeeded - story merged", {
141
+ storyId,
142
+ originalCost: options.originalCost,
143
+ rectificationCost: cost,
144
+ });
145
+ return { success: true, storyId, cost };
146
+ } catch (error) {
147
+ logger?.error("parallel", "Rectification failed - preserving worktree", {
148
+ storyId,
149
+ error: error instanceof Error ? error.message : String(error),
150
+ });
151
+ return { success: false, storyId, cost: 0, finalConflict: false, pipelineFailure: true };
152
+ }
153
+ }
154
+
25
155
  /**
26
156
  * Injectable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
27
157
  * @internal - test use only.
28
158
  */
29
159
  export const _parallelExecutorDeps = {
30
160
  fireHook,
161
+ executeParallel,
162
+ rectifyConflictedStory,
31
163
  };
32
164
 
33
165
  export interface ParallelExecutorOptions {
@@ -52,12 +184,101 @@ export interface ParallelExecutorOptions {
52
184
  headless: boolean;
53
185
  }
54
186
 
187
+ export interface RectificationStats {
188
+ rectified: number;
189
+ stillConflicting: number;
190
+ }
191
+
55
192
  export interface ParallelExecutorResult {
56
193
  prd: PRD;
57
194
  totalCost: number;
58
195
  storiesCompleted: number;
59
196
  completed: boolean;
60
197
  durationMs?: number;
198
+ /** Per-story metrics for stories completed via the parallel path */
199
+ storyMetrics: ParallelStoryMetrics[];
200
+ /** Stats from the merge-conflict rectification pass (MFX-005) */
201
+ rectificationStats: RectificationStats;
202
+ }
203
+
204
+ /**
205
+ * Run the rectification pass: sequentially re-run each conflicted story on
206
+ * the updated base (which already includes all clean merges from the first pass).
207
+ */
208
+ async function runRectificationPass(
209
+ conflictedStories: ConflictedStoryInfo[],
210
+ options: ParallelExecutorOptions,
211
+ prd: PRD,
212
+ ): Promise<{
213
+ rectifiedCount: number;
214
+ stillConflictingCount: number;
215
+ additionalCost: number;
216
+ updatedPrd: PRD;
217
+ rectificationMetrics: ParallelStoryMetrics[];
218
+ }> {
219
+ const logger = getSafeLogger();
220
+ const { workdir, config, hooks, pluginRegistry, eventEmitter } = options;
221
+ const rectificationMetrics: ParallelStoryMetrics[] = [];
222
+ let rectifiedCount = 0;
223
+ let stillConflictingCount = 0;
224
+ let additionalCost = 0;
225
+
226
+ logger?.info("parallel", "Starting merge conflict rectification", {
227
+ stories: conflictedStories.map((s) => s.storyId),
228
+ totalConflicts: conflictedStories.length,
229
+ });
230
+
231
+ // Sequential — each story sees all previously rectified stories in the base
232
+ for (const conflictInfo of conflictedStories) {
233
+ const result = await _parallelExecutorDeps.rectifyConflictedStory({
234
+ ...conflictInfo,
235
+ workdir,
236
+ config,
237
+ hooks,
238
+ pluginRegistry,
239
+ prd,
240
+ eventEmitter,
241
+ });
242
+
243
+ additionalCost += result.cost;
244
+
245
+ if (result.success) {
246
+ markStoryPassed(prd, result.storyId);
247
+ rectifiedCount++;
248
+
249
+ rectificationMetrics.push({
250
+ storyId: result.storyId,
251
+ complexity: "unknown",
252
+ modelTier: "parallel",
253
+ modelUsed: "parallel",
254
+ attempts: 1,
255
+ finalTier: "parallel",
256
+ success: true,
257
+ cost: result.cost,
258
+ durationMs: 0,
259
+ firstPassSuccess: false,
260
+ startedAt: new Date().toISOString(),
261
+ completedAt: new Date().toISOString(),
262
+ source: "rectification" as const,
263
+ rectifiedFromConflict: true,
264
+ originalCost: conflictInfo.originalCost,
265
+ rectificationCost: result.cost,
266
+ });
267
+ } else {
268
+ const isFinalConflict = result.finalConflict === true;
269
+ if (isFinalConflict) {
270
+ stillConflictingCount++;
271
+ }
272
+ // pipelineFailure — not counted as structural conflict, story remains failed
273
+ }
274
+ }
275
+
276
+ logger?.info("parallel", "Rectification complete", {
277
+ rectified: rectifiedCount,
278
+ stillConflicting: stillConflictingCount,
279
+ });
280
+
281
+ return { rectifiedCount, stillConflictingCount, additionalCost, updatedPrd: prd, rectificationMetrics };
61
282
  }
62
283
 
63
284
  /**
@@ -92,7 +313,14 @@ export async function runParallelExecution(
92
313
  const readyStories = getAllReadyStories(prd);
93
314
  if (readyStories.length === 0) {
94
315
  logger?.info("parallel", "No stories ready for parallel execution");
95
- return { prd, totalCost, storiesCompleted, completed: false };
316
+ return {
317
+ prd,
318
+ totalCost,
319
+ storiesCompleted,
320
+ completed: false,
321
+ storyMetrics: [],
322
+ rectificationStats: { rectified: 0, stillConflicting: 0 },
323
+ };
96
324
  }
97
325
 
98
326
  const maxConcurrency = parallelCount === 0 ? os.cpus().length : Math.max(1, parallelCount);
@@ -115,8 +343,16 @@ export async function runParallelExecution(
115
343
  },
116
344
  });
117
345
 
346
+ // Track which stories were already passed before this batch
347
+ const initialPassedIds = new Set(initialPrd.userStories.filter((s) => s.status === "passed").map((s) => s.id));
348
+ const batchStartedAt = new Date().toISOString();
349
+ const batchStartMs = Date.now();
350
+ const batchStoryMetrics: ParallelStoryMetrics[] = [];
351
+
352
+ let conflictedStories: ConflictedStoryInfo[] = [];
353
+
118
354
  try {
119
- const parallelResult = await executeParallel(
355
+ const parallelResult = await _parallelExecutorDeps.executeParallel(
120
356
  readyStories,
121
357
  prdPath,
122
358
  workdir,
@@ -129,14 +365,44 @@ export async function runParallelExecution(
129
365
  eventEmitter,
130
366
  );
131
367
 
368
+ const batchDurationMs = Date.now() - batchStartMs;
369
+ const batchCompletedAt = new Date().toISOString();
370
+
132
371
  prd = parallelResult.updatedPrd;
133
372
  storiesCompleted += parallelResult.storiesCompleted;
134
373
  totalCost += parallelResult.totalCost;
374
+ conflictedStories = parallelResult.mergeConflicts ?? [];
135
375
 
136
- logger?.info("parallel", "Parallel execution complete", {
137
- storiesCompleted: parallelResult.storiesCompleted,
138
- totalCost: parallelResult.totalCost,
139
- });
376
+ // BUG-066: Build per-story metrics for stories newly completed by this parallel batch
377
+ const newlyPassedStories = prd.userStories.filter((s) => s.status === "passed" && !initialPassedIds.has(s.id));
378
+ const costPerStory = newlyPassedStories.length > 0 ? parallelResult.totalCost / newlyPassedStories.length : 0;
379
+ for (const story of newlyPassedStories) {
380
+ batchStoryMetrics.push({
381
+ storyId: story.id,
382
+ complexity: "unknown",
383
+ modelTier: "parallel",
384
+ modelUsed: "parallel",
385
+ attempts: 1,
386
+ finalTier: "parallel",
387
+ success: true,
388
+ cost: costPerStory,
389
+ durationMs: batchDurationMs,
390
+ firstPassSuccess: true,
391
+ startedAt: batchStartedAt,
392
+ completedAt: batchCompletedAt,
393
+ source: "parallel" as const,
394
+ });
395
+ }
396
+
397
+ allStoryMetrics.push(...batchStoryMetrics);
398
+
399
+ // Log each conflict before scheduling rectification
400
+ for (const conflict of conflictedStories) {
401
+ logger?.info("parallel", "Merge conflict detected - scheduling for rectification", {
402
+ storyId: conflict.storyId,
403
+ conflictFiles: conflict.conflictFiles,
404
+ });
405
+ }
140
406
 
141
407
  // Clear parallel status
142
408
  statusWriter.setPrd(prd);
@@ -160,7 +426,23 @@ export async function runParallelExecution(
160
426
  throw error;
161
427
  }
162
428
 
163
- // Check if all stories are complete after parallel execution
429
+ // ── MFX-005: Rectification pass ────────────────────────────────────────────
430
+ let rectificationStats: RectificationStats = { rectified: 0, stillConflicting: 0 };
431
+
432
+ if (conflictedStories.length > 0) {
433
+ const rectResult = await runRectificationPass(conflictedStories, options, prd);
434
+ prd = rectResult.updatedPrd;
435
+ storiesCompleted += rectResult.rectifiedCount;
436
+ totalCost += rectResult.additionalCost;
437
+ rectificationStats = {
438
+ rectified: rectResult.rectifiedCount,
439
+ stillConflicting: rectResult.stillConflictingCount,
440
+ };
441
+ batchStoryMetrics.push(...rectResult.rectificationMetrics);
442
+ allStoryMetrics.push(...rectResult.rectificationMetrics);
443
+ }
444
+
445
+ // Check if all stories are complete after parallel execution + rectification
164
446
  if (isComplete(prd)) {
165
447
  logger?.info("execution", "All stories complete!", {
166
448
  feature,
@@ -228,8 +510,10 @@ export async function runParallelExecution(
228
510
  storiesCompleted,
229
511
  completed: true,
230
512
  durationMs,
513
+ storyMetrics: batchStoryMetrics,
514
+ rectificationStats,
231
515
  };
232
516
  }
233
517
 
234
- return { prd, totalCost, storiesCompleted, completed: false };
518
+ return { prd, totalCost, storiesCompleted, completed: false, storyMetrics: batchStoryMetrics, rectificationStats };
235
519
  }
@@ -26,14 +26,18 @@ import { MergeEngine, type StoryDependencies } from "../worktree/merge";
26
26
  * Result from parallel execution of a batch of stories
27
27
  */
28
28
  export interface ParallelBatchResult {
29
- /** Stories that completed successfully */
30
- successfulStories: UserStory[];
31
- /** Stories that failed */
32
- failedStories: Array<{ story: UserStory; error: string }>;
29
+ /** Stories that passed the TDD pipeline (pre-merge) */
30
+ pipelinePassed: UserStory[];
31
+ /** Stories that were actually merged to the base branch */
32
+ merged: UserStory[];
33
+ /** Stories that failed the pipeline */
34
+ failed: Array<{ story: UserStory; error: string }>;
33
35
  /** Total cost accumulated */
34
36
  totalCost: number;
35
- /** Stories with merge conflicts */
36
- conflictedStories: Array<{ storyId: string; conflictFiles: string[] }>;
37
+ /** Stories with merge conflicts (includes per-story original cost for rectification) */
38
+ mergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }>;
39
+ /** Per-story execution costs for successful stories */
40
+ storyCosts: Map<string, number>;
37
41
  }
38
42
 
39
43
  /**
@@ -148,10 +152,12 @@ async function executeParallelBatch(
148
152
  const logger = getSafeLogger();
149
153
  const worktreeManager = new WorktreeManager();
150
154
  const results: ParallelBatchResult = {
151
- successfulStories: [],
152
- failedStories: [],
155
+ pipelinePassed: [],
156
+ merged: [],
157
+ failed: [],
153
158
  totalCost: 0,
154
- conflictedStories: [],
159
+ mergeConflicts: [],
160
+ storyCosts: new Map(),
155
161
  };
156
162
 
157
163
  // Create worktrees for all stories in batch
@@ -168,7 +174,7 @@ async function executeParallelBatch(
168
174
  worktreePath,
169
175
  });
170
176
  } catch (error) {
171
- results.failedStories.push({
177
+ results.failed.push({
172
178
  story,
173
179
  error: `Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`,
174
180
  });
@@ -188,15 +194,16 @@ async function executeParallelBatch(
188
194
  const executePromise = executeStoryInWorktree(story, worktreePath, context, routing as RoutingResult, eventEmitter)
189
195
  .then((result) => {
190
196
  results.totalCost += result.cost;
197
+ results.storyCosts.set(story.id, result.cost);
191
198
 
192
199
  if (result.success) {
193
- results.successfulStories.push(story);
200
+ results.pipelinePassed.push(story);
194
201
  logger?.info("parallel", "Story execution succeeded", {
195
202
  storyId: story.id,
196
203
  cost: result.cost,
197
204
  });
198
205
  } else {
199
- results.failedStories.push({ story, error: result.error || "Unknown error" });
206
+ results.failed.push({ story, error: result.error || "Unknown error" });
200
207
  logger?.error("parallel", "Story execution failed", {
201
208
  storyId: story.id,
202
209
  error: result.error,
@@ -257,7 +264,12 @@ export async function executeParallel(
257
264
  featureDir: string | undefined,
258
265
  parallel: number,
259
266
  eventEmitter?: PipelineEventEmitter,
260
- ): Promise<{ storiesCompleted: number; totalCost: number; updatedPrd: PRD }> {
267
+ ): Promise<{
268
+ storiesCompleted: number;
269
+ totalCost: number;
270
+ updatedPrd: PRD;
271
+ mergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }>;
272
+ }> {
261
273
  const logger = getSafeLogger();
262
274
  const maxConcurrency = resolveMaxConcurrency(parallel);
263
275
  const worktreeManager = new WorktreeManager();
@@ -278,6 +290,7 @@ export async function executeParallel(
278
290
  let storiesCompleted = 0;
279
291
  let totalCost = 0;
280
292
  const currentPrd = prd;
293
+ const allMergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }> = [];
281
294
 
282
295
  // Execute each batch sequentially (stories within each batch run in parallel)
283
296
  for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
@@ -311,8 +324,8 @@ export async function executeParallel(
311
324
  totalCost += batchResult.totalCost;
312
325
 
313
326
  // Merge successful stories in topological order
314
- if (batchResult.successfulStories.length > 0) {
315
- const successfulIds = batchResult.successfulStories.map((s) => s.id);
327
+ if (batchResult.pipelinePassed.length > 0) {
328
+ const successfulIds = batchResult.pipelinePassed.map((s) => s.id);
316
329
  const deps = buildDependencyMap(batch);
317
330
 
318
331
  logger?.info("parallel", "Merging successful stories", {
@@ -327,6 +340,8 @@ export async function executeParallel(
327
340
  // Update PRD: mark story as passed
328
341
  markStoryPassed(currentPrd, mergeResult.storyId);
329
342
  storiesCompleted++;
343
+ const mergedStory = batchResult.pipelinePassed.find((s) => s.id === mergeResult.storyId);
344
+ if (mergedStory) batchResult.merged.push(mergedStory);
330
345
 
331
346
  logger?.info("parallel", "Story merged successfully", {
332
347
  storyId: mergeResult.storyId,
@@ -335,9 +350,10 @@ export async function executeParallel(
335
350
  } else {
336
351
  // Merge conflict — mark story as failed
337
352
  markStoryFailed(currentPrd, mergeResult.storyId);
338
- batchResult.conflictedStories.push({
353
+ batchResult.mergeConflicts.push({
339
354
  storyId: mergeResult.storyId,
340
355
  conflictFiles: mergeResult.conflictFiles || [],
356
+ originalCost: batchResult.storyCosts.get(mergeResult.storyId) ?? 0,
341
357
  });
342
358
 
343
359
  logger?.error("parallel", "Merge conflict", {
@@ -355,7 +371,7 @@ export async function executeParallel(
355
371
  }
356
372
 
357
373
  // Mark failed stories in PRD and clean up their worktrees
358
- for (const { story, error } of batchResult.failedStories) {
374
+ for (const { story, error } of batchResult.failed) {
359
375
  markStoryFailed(currentPrd, story.id);
360
376
 
361
377
  logger?.error("parallel", "Cleaning up failed story worktree", {
@@ -376,10 +392,13 @@ export async function executeParallel(
376
392
  // Save PRD after each batch
377
393
  await savePRD(currentPrd, prdPath);
378
394
 
395
+ allMergeConflicts.push(...batchResult.mergeConflicts);
396
+
379
397
  logger?.info("parallel", `Batch ${batchIndex + 1} complete`, {
380
- successful: batchResult.successfulStories.length,
381
- failed: batchResult.failedStories.length,
382
- conflicts: batchResult.conflictedStories.length,
398
+ pipelinePassed: batchResult.pipelinePassed.length,
399
+ merged: batchResult.merged.length,
400
+ failed: batchResult.failed.length,
401
+ mergeConflicts: batchResult.mergeConflicts.length,
383
402
  batchCost: batchResult.totalCost,
384
403
  });
385
404
  }
@@ -389,5 +408,5 @@ export async function executeParallel(
389
408
  totalCost,
390
409
  });
391
410
 
392
- return { storiesCompleted, totalCost, updatedPrd: currentPrd };
411
+ return { storiesCompleted, totalCost, updatedPrd: currentPrd, mergeConflicts: allMergeConflicts };
393
412
  }
@@ -68,7 +68,7 @@ export async function handlePipelineSuccess(
68
68
  storyId: completedStory.id,
69
69
  storyTitle: completedStory.title,
70
70
  totalCost: ctx.totalCost + costDelta,
71
- durationMs: now - ctx.startTime,
71
+ runElapsedMs: now - ctx.startTime,
72
72
  storyDurationMs: ctx.storyStartTime ? now - ctx.storyStartTime : undefined,
73
73
  });
74
74
 
@@ -77,7 +77,7 @@ export async function handlePipelineSuccess(
77
77
  storyId: completedStory.id,
78
78
  story: completedStory,
79
79
  passed: true,
80
- durationMs: Date.now() - ctx.startTime,
80
+ runElapsedMs: Date.now() - ctx.startTime,
81
81
  cost: costDelta,
82
82
  modelTier: ctx.routing.modelTier,
83
83
  testStrategy: ctx.routing.testStrategy,
@@ -177,6 +177,7 @@ export async function handlePipelineFailure(
177
177
  feature: ctx.feature,
178
178
  totalCost: ctx.totalCost,
179
179
  workdir: ctx.workdir,
180
+ attemptCost: pipelineResult.context.agentResult?.estimatedCost || 0,
180
181
  });
181
182
  prd = escalationResult.prd;
182
183
  prdDirty = escalationResult.prdDirty;
@@ -21,6 +21,7 @@ import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../rou
21
21
  import { precomputeBatchPlan } from "./batching";
22
22
  import { stopHeartbeat, writeExitSummary } from "./crash-recovery";
23
23
  import { getAllReadyStories } from "./helpers";
24
+ import type { ParallelExecutorOptions, ParallelExecutorResult } from "./parallel-executor";
24
25
  import { hookCtx } from "./story-context";
25
26
 
26
27
  /**
@@ -29,6 +30,10 @@ import { hookCtx } from "./story-context";
29
30
  */
30
31
  export const _runnerDeps = {
31
32
  fireHook,
33
+ // Injectable for tests — avoids dynamic-import module-cache issues in bun test (bun 1.3.9+)
34
+ runParallelExecution: null as
35
+ | null
36
+ | ((options: ParallelExecutorOptions, prd: import("../prd").PRD) => Promise<ParallelExecutorResult>),
32
37
  };
33
38
 
34
39
  // Re-export for backward compatibility
@@ -202,7 +207,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
202
207
 
203
208
  // ── Parallel Execution Path (when --parallel is set) ──────────────────────
204
209
  if (options.parallel !== undefined) {
205
- const { runParallelExecution } = await import("./parallel-executor");
210
+ const runParallelExecution =
211
+ _runnerDeps.runParallelExecution ?? (await import("./parallel-executor")).runParallelExecution;
206
212
  const parallelResult = await runParallelExecution(
207
213
  {
208
214
  prdPath,
@@ -231,6 +237,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
231
237
  prd = parallelResult.prd;
232
238
  totalCost = parallelResult.totalCost;
233
239
  storiesCompleted = parallelResult.storiesCompleted;
240
+ // BUG-066: merge parallel story metrics into the running accumulator
241
+ allStoryMetrics.push(...parallelResult.storyMetrics);
234
242
 
235
243
  // If parallel execution completed everything, return early
236
244
  if (parallelResult.completed && parallelResult.durationMs !== undefined) {
@@ -269,8 +277,10 @@ export async function run(options: RunOptions): Promise<RunResult> {
269
277
 
270
278
  prd = sequentialResult.prd;
271
279
  iterations = sequentialResult.iterations;
272
- storiesCompleted = sequentialResult.storiesCompleted;
273
- totalCost = sequentialResult.totalCost;
280
+ // BUG-064: accumulate (not overwrite) totalCost from sequential path
281
+ totalCost += sequentialResult.totalCost;
282
+ // BUG-065: accumulate (not overwrite) storiesCompleted from sequential path
283
+ storiesCompleted += sequentialResult.storiesCompleted;
274
284
  allStoryMetrics.push(...sequentialResult.allStoryMetrics);
275
285
 
276
286
  // After main loop: Check if we need acceptance retry loop
@@ -86,11 +86,27 @@ export class InteractionChain {
86
86
  }
87
87
 
88
88
  /**
89
- * Send and receive in one call (convenience method)
89
+ * Send and receive in one call (convenience method).
90
+ *
91
+ * Normalizes "choose" type responses: when the plugin returns
92
+ * `action: "choose"` + `value: "<key>"`, remaps action to the selected
93
+ * option key so all consumers can switch on action directly without
94
+ * needing to inspect value themselves.
90
95
  */
91
96
  async prompt(request: InteractionRequest): Promise<InteractionResponse> {
92
97
  await this.send(request);
93
98
  const response = await this.receive(request.id, request.timeout);
99
+
100
+ // Normalize choose responses: action="choose" means the user picked an option;
101
+ // the actual selection is in value. Remap to the selected key if it matches
102
+ // one of the declared options.
103
+ if (response.action === "choose" && response.value && request.options) {
104
+ const matched = request.options.find((o) => o.key === response.value);
105
+ if (matched) {
106
+ return { ...response, action: matched.key as InteractionResponse["action"] };
107
+ }
108
+ }
109
+
94
110
  return response;
95
111
  }
96
112
 
@@ -44,14 +44,16 @@ export function collectStoryMetrics(ctx: PipelineContext, storyStartTime: string
44
44
  const agentResult = ctx.agentResult;
45
45
 
46
46
  // Calculate attempts (initial + escalations)
47
+ // BUG-067: priorFailures captures cross-tier attempts that story.escalations never records
47
48
  const escalationCount = story.escalations?.length || 0;
48
- const attempts = Math.max(1, story.attempts || 1);
49
+ const priorFailureCount = story.priorFailures?.length || 0;
50
+ const attempts = priorFailureCount + Math.max(1, story.attempts || 1);
49
51
 
50
52
  // Determine final tier (from last escalation or initial routing)
51
53
  const finalTier = escalationCount > 0 ? story.escalations[escalationCount - 1].toTier : routing.modelTier;
52
54
 
53
- // First pass success = succeeded with no escalations
54
- const firstPassSuccess = agentResult?.success === true && escalationCount === 0;
55
+ // First pass success = succeeded with no prior failures and no escalations (BUG-067)
56
+ const firstPassSuccess = agentResult?.success === true && escalationCount === 0 && priorFailureCount === 0;
55
57
 
56
58
  // Extract model name from config
57
59
  const modelEntry = ctx.config.models[routing.modelTier];
@@ -76,12 +78,13 @@ export function collectStoryMetrics(ctx: PipelineContext, storyStartTime: string
76
78
  attempts,
77
79
  finalTier,
78
80
  success: agentResult?.success || false,
79
- cost: agentResult?.estimatedCost || 0,
81
+ cost: (ctx.accumulatedAttemptCost ?? 0) + (agentResult?.estimatedCost || 0),
80
82
  durationMs: agentResult?.durationMs || 0,
81
83
  firstPassSuccess,
82
84
  startedAt: storyStartTime,
83
85
  completedAt: new Date().toISOString(),
84
86
  fullSuiteGatePassed,
87
+ runtimeCrashes: ctx.storyRuntimeCrashes ?? 0,
85
88
  };
86
89
  }
87
90
 
@@ -139,6 +142,7 @@ export function collectBatchMetrics(ctx: PipelineContext, storyStartTime: string
139
142
  startedAt: storyStartTime,
140
143
  completedAt: new Date().toISOString(),
141
144
  fullSuiteGatePassed: false, // batches are not TDD-gated
145
+ runtimeCrashes: 0, // batch stories don't have individual crash tracking
142
146
  };
143
147
  });
144
148
  }
@@ -34,6 +34,8 @@ export interface StoryMetrics {
34
34
  startedAt: string;
35
35
  /** Timestamp when completed */
36
36
  completedAt: string;
37
+ /** Number of runtime crashes (RUNTIME_CRASH verify status) encountered for this story (BUG-070) */
38
+ runtimeCrashes?: number;
37
39
  /** Whether TDD full-suite gate passed (only true for TDD strategies when gate passes) */
38
40
  fullSuiteGatePassed?: boolean;
39
41
  }