@nathapp/nax 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.43.1",
3
+ "version": "0.45.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -307,6 +307,88 @@ export async function readAcpSession(workdir: string, featureName: string, story
307
307
  }
308
308
  }
309
309
 
310
+ // ─────────────────────────────────────────────────────────────────────────────
311
+ // Session sweep — close open sessions at run boundaries
312
+ // ─────────────────────────────────────────────────────────────────────────────
313
+
314
+ const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
315
+
316
+ /**
317
+ * Close all open sessions tracked in the sidecar file for a feature.
318
+ * Called at run-end to ensure no sessions leak past the run boundary.
319
+ */
320
+ export async function sweepFeatureSessions(workdir: string, featureName: string): Promise<void> {
321
+ const path = acpSessionsPath(workdir, featureName);
322
+ let sessions: Record<string, string>;
323
+ try {
324
+ const text = await Bun.file(path).text();
325
+ sessions = JSON.parse(text) as Record<string, string>;
326
+ } catch {
327
+ return; // No sidecar — nothing to sweep
328
+ }
329
+
330
+ const entries = Object.entries(sessions);
331
+ if (entries.length === 0) return;
332
+
333
+ const logger = getSafeLogger();
334
+ logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
335
+
336
+ const cmdStr = "acpx claude";
337
+ const client = _acpAdapterDeps.createClient(cmdStr, workdir);
338
+ try {
339
+ await client.start();
340
+ for (const [, sessionName] of entries) {
341
+ try {
342
+ if (client.loadSession) {
343
+ const session = await client.loadSession(sessionName, "claude", "approve-reads");
344
+ if (session) {
345
+ await session.close().catch(() => {});
346
+ }
347
+ }
348
+ } catch (err) {
349
+ logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
350
+ }
351
+ }
352
+ } finally {
353
+ await client.close().catch(() => {});
354
+ }
355
+
356
+ // Clear sidecar after sweep
357
+ try {
358
+ await Bun.write(path, JSON.stringify({}, null, 2));
359
+ } catch (err) {
360
+ logger?.warn("acp-adapter", "[sweep] Failed to clear sidecar after sweep", { error: String(err) });
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Sweep stale sessions if the sidecar file is older than maxAgeMs.
366
+ * Called at startup as a safety net for sessions orphaned by crashes.
367
+ */
368
+ export async function sweepStaleFeatureSessions(
369
+ workdir: string,
370
+ featureName: string,
371
+ maxAgeMs = MAX_SESSION_AGE_MS,
372
+ ): Promise<void> {
373
+ const path = acpSessionsPath(workdir, featureName);
374
+ const file = Bun.file(path);
375
+ if (!(await file.exists())) return;
376
+
377
+ const ageMs = Date.now() - file.lastModified;
378
+ if (ageMs < maxAgeMs) return; // Recent sidecar — skip
379
+
380
+ getSafeLogger()?.info(
381
+ "acp-adapter",
382
+ `[sweep] Sidecar is ${Math.round(ageMs / 60000)}m old — sweeping stale sessions`,
383
+ {
384
+ featureName,
385
+ ageMs,
386
+ },
387
+ );
388
+
389
+ await sweepFeatureSessions(workdir, featureName);
390
+ }
391
+
310
392
  // ─────────────────────────────────────────────────────────────────────────────
311
393
  // Output helpers
312
394
  // ─────────────────────────────────────────────────────────────────────────────
@@ -470,6 +552,9 @@ export class AcpAgentAdapter implements AgentAdapter {
470
552
 
471
553
  let lastResponse: AcpSessionResponse | null = null;
472
554
  let timedOut = false;
555
+ // Tracks whether the run completed successfully — used by finally to decide
556
+ // whether to close the session (success) or keep it open for retry (failure).
557
+ const runState = { succeeded: false };
473
558
  const totalTokenUsage = { input_tokens: 0, output_tokens: 0 };
474
559
 
475
560
  try {
@@ -525,13 +610,21 @@ export class AcpAgentAdapter implements AgentAdapter {
525
610
  if (turnCount >= MAX_TURNS && options.interactionBridge) {
526
611
  getSafeLogger()?.warn("acp-adapter", "Reached max turns limit", { sessionName, maxTurns: MAX_TURNS });
527
612
  }
613
+
614
+ // Compute success here so finally can use it for conditional close.
615
+ runState.succeeded = !timedOut && lastResponse?.stopReason === "end_turn";
528
616
  } finally {
529
- // 6. Cleanup — always close session and client, then clear sidecar
530
- await closeAcpSession(session);
531
- await client.close().catch(() => {});
532
- if (options.featureName && options.storyId) {
533
- await clearAcpSession(options.workdir, options.featureName, options.storyId);
617
+ // 6. Cleanup — close session and clear sidecar only on success.
618
+ // On failure, keep session open so retry can resume with full context.
619
+ if (runState.succeeded) {
620
+ await closeAcpSession(session);
621
+ if (options.featureName && options.storyId) {
622
+ await clearAcpSession(options.workdir, options.featureName, options.storyId);
623
+ }
624
+ } else {
625
+ getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
534
626
  }
627
+ await client.close().catch(() => {});
535
628
  }
536
629
 
537
630
  const durationMs = Date.now() - startTime;
@@ -5,6 +5,7 @@
5
5
  * parseDecomposeOutput(), validateComplexity()
6
6
  */
7
7
 
8
+ import { COMPLEXITY_GUIDE, GROUPING_RULES, TEST_STRATEGY_GUIDE, resolveTestStrategy } from "../config/test-strategy";
8
9
  import type { DecomposeOptions, DecomposeResult, DecomposedStory } from "./types";
9
10
 
10
11
  /**
@@ -31,24 +32,13 @@ Decompose this spec into user stories. For each story, provide:
31
32
  9. reasoning: Why this complexity level
32
33
  10. estimatedLOC: Estimated lines of code to change
33
34
  11. risks: Array of implementation risks
34
- 12. testStrategy: "three-session-tdd" | "test-after"
35
+ 12. testStrategy: "test-after" | "tdd-simple" | "three-session-tdd" | "three-session-tdd-lite"
35
36
 
36
- testStrategy rules:
37
- - "three-session-tdd": ONLY for complex/expert tasks that are security-critical (auth, encryption, tokens, credentials) or define public API contracts consumers depend on
38
- - "test-after": for all other tasks including simple/medium complexity
39
- - A "simple" complexity task should almost never be "three-session-tdd"
37
+ ${COMPLEXITY_GUIDE}
40
38
 
41
- Complexity classification rules:
42
- - simple: 1-3 files, <100 LOC, straightforward implementation, existing patterns
43
- - medium: 3-6 files, 100-300 LOC, moderate logic, some new patterns
44
- - complex: 6+ files, 300-800 LOC, architectural changes, cross-cutting concerns
45
- - expert: Security/crypto/real-time/distributed systems, >800 LOC, new infrastructure
39
+ ${TEST_STRATEGY_GUIDE}
46
40
 
47
- Grouping Guidelines:
48
- - Combine small, related tasks (e.g., multiple utility functions, interfaces) into a single "simple" or "medium" story.
49
- - Do NOT create separate stories for every single file or function unless complex.
50
- - Aim for coherent units of value (e.g., "Implement User Authentication" vs "Create User Interface", "Create Login Service").
51
- - Maximum recommended stories: 10-15 per feature. Group aggressively if list grows too long.
41
+ ${GROUPING_RULES}
52
42
 
53
43
  Consider:
54
44
  1. Does infrastructure exist? (e.g., "add caching" when no cache layer exists = complex)
@@ -141,12 +131,7 @@ export function parseDecomposeOutput(output: string): DecomposedStory[] {
141
131
  reasoning: String(record.reasoning || "No reasoning provided"),
142
132
  estimatedLOC: Number(record.estimatedLOC) || 0,
143
133
  risks: Array.isArray(record.risks) ? record.risks : [],
144
- testStrategy:
145
- record.testStrategy === "three-session-tdd"
146
- ? "three-session-tdd"
147
- : record.testStrategy === "test-after"
148
- ? "test-after"
149
- : undefined,
134
+ testStrategy: resolveTestStrategy(typeof record.testStrategy === "string" ? record.testStrategy : undefined),
150
135
  };
151
136
  });
152
137
 
@@ -117,7 +117,7 @@ export interface DecomposedStory {
117
117
  /** Implementation risks */
118
118
  risks: string[];
119
119
  /** Test strategy recommendation from LLM */
120
- testStrategy?: "three-session-tdd" | "test-after";
120
+ testStrategy?: import("../config/test-strategy").TestStrategy;
121
121
  }
122
122
 
123
123
  /**
package/src/cli/plan.ts CHANGED
@@ -16,6 +16,7 @@ import { scanCodebase } from "../analyze/scanner";
16
16
  import type { CodebaseScan } from "../analyze/types";
17
17
  import type { NaxConfig } from "../config";
18
18
  import { resolvePermissions } from "../config/permissions";
19
+ import { COMPLEXITY_GUIDE, GROUPING_RULES, TEST_STRATEGY_GUIDE } from "../config/test-strategy";
19
20
  import { PidRegistry } from "../execution/pid-registry";
20
21
  import { getLogger } from "../logger";
21
22
  import { validatePlanOutput } from "../prd/schema";
@@ -320,19 +321,11 @@ Generate a JSON object with this exact structure (no markdown, no explanation
320
321
  ]
321
322
  }
322
323
 
323
- ## Complexity Classification Guide
324
+ ${COMPLEXITY_GUIDE}
324
325
 
325
- - simple: ≤50 LOC, single-file change, purely additive, no new dependencies → test-after
326
- - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-simple
327
- - complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
328
- - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd-lite
326
+ ${TEST_STRATEGY_GUIDE}
329
327
 
330
- ## Test Strategy Guide
331
-
332
- - test-after: Simple changes with well-understood behavior. Write tests after implementation.
333
- - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
334
- - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
335
- - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
328
+ ${GROUPING_RULES}
336
329
 
337
330
  ${
338
331
  outputFilePath
@@ -85,6 +85,17 @@ async function loadProjectStatusFile(projectDir: string): Promise<NaxStatusFile
85
85
  async function getFeatureSummary(featureName: string, featureDir: string): Promise<FeatureSummary> {
86
86
  const prdPath = join(featureDir, "prd.json");
87
87
 
88
+ // Guard: prd.json may not exist (e.g. plan failed before writing it)
89
+ if (!existsSync(prdPath)) {
90
+ return {
91
+ name: featureName,
92
+ done: 0,
93
+ failed: 0,
94
+ pending: 0,
95
+ total: 0,
96
+ };
97
+ }
98
+
88
99
  // Load PRD for story counts
89
100
  const prd = await loadPRD(prdPath);
90
101
  const counts = countStories(prd);
@@ -240,6 +251,14 @@ async function displayAllFeatures(projectDir: string): Promise<void> {
240
251
  /** Display single feature details */
241
252
  async function displayFeatureDetails(featureName: string, featureDir: string): Promise<void> {
242
253
  const prdPath = join(featureDir, "prd.json");
254
+
255
+ // Guard: prd.json may not exist (e.g. plan failed or feature just created)
256
+ if (!existsSync(prdPath)) {
257
+ console.log(chalk.bold(`\n📊 ${featureName}\n`));
258
+ console.log(chalk.dim(`No prd.json found. Run: nax plan -f ${featureName} --from <spec>`));
259
+ return;
260
+ }
261
+
243
262
  const prd = await loadPRD(prdPath);
244
263
  const counts = countStories(prd);
245
264
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Test Strategy — Single Source of Truth
3
+ *
4
+ * Defines all valid test strategies, the normalizer, and shared prompt
5
+ * fragments used by plan.ts and claude-decompose.ts.
6
+ */
7
+
8
+ import type { TestStrategy } from "./schema-types";
9
+
10
+ // ─── Re-export type ───────────────────────────────────────────────────────────
11
+
12
+ export type { TestStrategy };
13
+
14
+ // ─── Valid values ─────────────────────────────────────────────────────────────
15
+
16
+ export const VALID_TEST_STRATEGIES: readonly TestStrategy[] = [
17
+ "test-after",
18
+ "tdd-simple",
19
+ "three-session-tdd",
20
+ "three-session-tdd-lite",
21
+ ];
22
+
23
+ // ─── Resolver ────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Validate and normalize a test strategy string.
27
+ * Returns a valid TestStrategy or falls back to "test-after".
28
+ */
29
+ export function resolveTestStrategy(raw: string | undefined): TestStrategy {
30
+ if (!raw) return "test-after";
31
+ if (VALID_TEST_STRATEGIES.includes(raw as TestStrategy)) return raw as TestStrategy;
32
+ // Map legacy/typo values
33
+ if (raw === "tdd") return "tdd-simple";
34
+ if (raw === "three-session") return "three-session-tdd";
35
+ if (raw === "tdd-lite") return "three-session-tdd-lite";
36
+ return "test-after"; // safe fallback
37
+ }
38
+
39
+ // ─── Prompt fragments (shared by plan.ts and claude-decompose.ts) ────────────
40
+
41
+ export const COMPLEXITY_GUIDE = `## Complexity Classification Guide
42
+
43
+ - simple: ≤50 LOC, single-file change, purely additive, no new dependencies → test-after
44
+ - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-simple
45
+ - complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
46
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd-lite
47
+
48
+ ### Security Override
49
+
50
+ Security-critical functions (authentication, cryptography, tokens, sessions, credentials,
51
+ password hashing, access control) must be classified at MINIMUM "medium" complexity
52
+ regardless of LOC count. These require at minimum "tdd-simple" test strategy.`;
53
+
54
+ export const TEST_STRATEGY_GUIDE = `## Test Strategy Guide
55
+
56
+ - test-after: Simple changes with well-understood behavior. Write tests after implementation.
57
+ - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
58
+ - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
59
+ - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.`;
60
+
61
+ export const GROUPING_RULES = `## Grouping Rules
62
+
63
+ - Combine small, related tasks into a single "simple" or "medium" story.
64
+ - Do NOT create separate stories for every single file or function unless complex.
65
+ - Do NOT create standalone stories purely for test coverage or testing.
66
+ Each story's testStrategy already handles testing (tdd-simple writes tests first,
67
+ three-session-tdd uses separate test-writer session, test-after writes tests after).
68
+ Only create a dedicated test story for unique integration/E2E test logic that spans
69
+ multiple stories and cannot be covered by individual story test strategies.
70
+ - Aim for coherent units of value. Maximum recommended stories: 10-15 per feature.`;
@@ -143,6 +143,7 @@ async function executeFixStory(
143
143
  hooks: ctx.hooks,
144
144
  plugins: ctx.pluginRegistry,
145
145
  storyStartTime: new Date().toISOString(),
146
+ agentGetFn: ctx.agentGetFn,
146
147
  };
147
148
  const result = await runPipeline(defaultPipeline, fixContext, ctx.eventEmitter);
148
149
  logger?.info("acceptance", `Fix story ${story.id} ${result.success ? "passed" : "failed"}`);
@@ -189,6 +190,7 @@ export async function runAcceptanceLoop(ctx: AcceptanceLoopContext): Promise<Acc
189
190
  featureDir: ctx.featureDir,
190
191
  hooks: ctx.hooks,
191
192
  plugins: ctx.pluginRegistry,
193
+ agentGetFn: ctx.agentGetFn,
192
194
  };
193
195
 
194
196
  const { acceptanceStage } = await import("../../pipeline/stages/acceptance");
@@ -159,6 +159,10 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
159
159
  logger?.warn("precheck", "Precheck validations skipped (--skip-precheck)");
160
160
  }
161
161
 
162
+ // Sweep stale ACP sessions from previous crashed runs (safety net)
163
+ const { sweepStaleFeatureSessions } = await import("../../agents/acp/adapter");
164
+ await sweepStaleFeatureSessions(workdir, feature).catch(() => {});
165
+
162
166
  // Acquire lock to prevent concurrent execution
163
167
  const lockAcquired = await acquireLock(workdir);
164
168
  if (!lockAcquired) {
@@ -8,7 +8,7 @@ import type { NaxConfig } from "../config";
8
8
  import type { LoadedHooksConfig } from "../hooks";
9
9
  import { getSafeLogger } from "../logger";
10
10
  import type { PipelineEventEmitter } from "../pipeline/events";
11
- import type { PipelineContext } from "../pipeline/types";
11
+ import type { AgentGetFn } from "../pipeline/types";
12
12
  import type { PluginRegistry } from "../plugins/registry";
13
13
  import type { PRD, UserStory } from "../prd";
14
14
  import { markStoryFailed, markStoryPassed, savePRD } from "../prd";
@@ -108,6 +108,7 @@ export async function executeParallel(
108
108
  featureDir: string | undefined,
109
109
  parallel: number,
110
110
  eventEmitter?: PipelineEventEmitter,
111
+ agentGetFn?: AgentGetFn,
111
112
  ): Promise<{
112
113
  storiesCompleted: number;
113
114
  totalCost: number;
@@ -152,6 +153,7 @@ export async function executeParallel(
152
153
  hooks,
153
154
  plugins,
154
155
  storyStartTime: new Date().toISOString(),
156
+ agentGetFn,
155
157
  };
156
158
 
157
159
  // Create worktrees for all stories in batch
@@ -17,6 +17,7 @@ import { fireHook } from "../hooks";
17
17
  import { getSafeLogger } from "../logger";
18
18
  import type { StoryMetrics } from "../metrics";
19
19
  import type { PipelineEventEmitter } from "../pipeline/events";
20
+ import type { AgentGetFn } from "../pipeline/types";
20
21
  import type { PluginRegistry } from "../plugins/registry";
21
22
  import type { PRD } from "../prd";
22
23
  import { countStories, isComplete } from "../prd";
@@ -57,6 +58,7 @@ export interface ParallelExecutorOptions {
57
58
  pluginRegistry: PluginRegistry;
58
59
  formatterMode: "quiet" | "normal" | "verbose" | "json";
59
60
  headless: boolean;
61
+ agentGetFn?: AgentGetFn;
60
62
  }
61
63
 
62
64
  export interface RectificationStats {
@@ -158,6 +160,7 @@ export async function runParallelExecution(
158
160
  featureDir,
159
161
  parallelCount,
160
162
  eventEmitter,
163
+ options.agentGetFn,
161
164
  );
162
165
 
163
166
  const batchDurationMs = Date.now() - batchStartMs;
@@ -129,10 +129,24 @@ export async function runExecutionPhase(
129
129
  clearLlmCache();
130
130
 
131
131
  // PERF-1: Precompute batch plan once from ready stories
132
- const batchPlan = options.useBatch ? precomputeBatchPlan(getAllReadyStories(prd), 4) : [];
132
+ const readyStories = getAllReadyStories(prd);
133
+
134
+ // BUG-068: debug log to diagnose unexpected storyCount in batch routing
135
+ logger?.debug("routing", "Ready stories for batch routing", {
136
+ readyCount: readyStories.length,
137
+ readyIds: readyStories.map((s) => s.id),
138
+ allStories: prd.userStories.map((s) => ({
139
+ id: s.id,
140
+ status: s.status,
141
+ passes: s.passes,
142
+ deps: s.dependencies,
143
+ })),
144
+ });
145
+
146
+ const batchPlan = options.useBatch ? precomputeBatchPlan(readyStories, 4) : [];
133
147
 
134
148
  if (options.useBatch) {
135
- await tryLlmBatchRoute(options.config, getAllReadyStories(prd), "routing");
149
+ await tryLlmBatchRoute(options.config, readyStories, "routing");
136
150
  }
137
151
 
138
152
  // Parallel Execution Path (when --parallel is set)
@@ -13,6 +13,7 @@
13
13
  * - runner-completion.ts: Acceptance loop, hooks, metrics
14
14
  */
15
15
 
16
+ import { sweepFeatureSessions } from "../agents/acp/adapter";
16
17
  import { createAgentRegistry } from "../agents/registry";
17
18
  import type { NaxConfig } from "../config";
18
19
  import type { LoadedHooksConfig } from "../hooks";
@@ -241,6 +242,9 @@ export async function run(options: RunOptions): Promise<RunResult> {
241
242
  // Cleanup crash handlers (MEM-1 fix)
242
243
  cleanupCrashHandlers();
243
244
 
245
+ // Sweep any remaining open ACP sessions for this feature
246
+ await sweepFeatureSessions(workdir, feature).catch(() => {});
247
+
244
248
  // Execute cleanup operations
245
249
  const { cleanupRun } = await import("./lifecycle/run-cleanup");
246
250
  await cleanupRun({
@@ -175,6 +175,12 @@ export async function buildStoryContextFull(
175
175
  export function getAllReadyStories(prd: PRD): UserStory[] {
176
176
  const completedIds = new Set(prd.userStories.filter((s) => s.passes || s.status === "skipped").map((s) => s.id));
177
177
 
178
+ const logger = getSafeLogger();
179
+ logger?.debug("routing", "getAllReadyStories: completed set", {
180
+ completedIds: [...completedIds],
181
+ totalStories: prd.userStories.length,
182
+ });
183
+
178
184
  return prd.userStories.filter(
179
185
  (s) =>
180
186
  !s.passes &&
package/src/prd/schema.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Complexity, TestStrategy } from "../config";
8
+ import { resolveTestStrategy } from "../config/test-strategy";
8
9
  import type { PRD, UserStory } from "./types";
9
10
  import { validateStoryId } from "./validate";
10
11
 
@@ -13,12 +14,6 @@ import { validateStoryId } from "./validate";
13
14
  // ---------------------------------------------------------------------------
14
15
 
15
16
  const VALID_COMPLEXITY: Complexity[] = ["simple", "medium", "complex", "expert"];
16
- const VALID_TEST_STRATEGIES: TestStrategy[] = [
17
- "test-after",
18
- "tdd-simple",
19
- "three-session-tdd",
20
- "three-session-tdd-lite",
21
- ];
22
17
 
23
18
  /** Pattern matching ST001 → ST-001 style IDs (prefix letters + digits, no separator) */
24
19
  const STORY_ID_NO_SEPARATOR = /^([A-Za-z]+)(\d+)$/;
@@ -140,15 +135,10 @@ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserSt
140
135
  }
141
136
 
142
137
  // testStrategy — accept from routing.testStrategy or top-level testStrategy
143
- // Also map legacy/LLM-hallucinated aliases: tdd-lite → tdd-simple
144
138
  const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
145
- const STRATEGY_ALIASES: Record<string, TestStrategy> = { "tdd-lite": "three-session-tdd-lite" };
146
- const normalizedStrategy =
147
- typeof rawTestStrategy === "string" ? (STRATEGY_ALIASES[rawTestStrategy] ?? rawTestStrategy) : rawTestStrategy;
148
- const testStrategy: TestStrategy =
149
- normalizedStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(normalizedStrategy)
150
- ? (normalizedStrategy as TestStrategy)
151
- : "tdd-simple";
139
+ const testStrategy: TestStrategy = resolveTestStrategy(
140
+ typeof rawTestStrategy === "string" ? rawTestStrategy : undefined,
141
+ );
152
142
 
153
143
  // dependencies
154
144
  const rawDeps = s.dependencies;