@nathapp/nax 0.42.0 → 0.42.1

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/bin/nax.ts CHANGED
@@ -277,6 +277,7 @@ program
277
277
  .option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)")
278
278
  .option("--plan", "Run plan phase first before execution", false)
279
279
  .option("--from <spec-path>", "Path to spec file (required when --plan is used)")
280
+ .option("--one-shot", "Skip interactive planning Q&A, use single LLM call (ACP only)", false)
280
281
  .option("--headless", "Force headless mode (disable TUI, use pipe mode)", false)
281
282
  .option("--verbose", "Enable verbose logging (debug level)", false)
282
283
  .option("--quiet", "Quiet mode (warnings and errors only)", false)
@@ -347,7 +348,7 @@ program
347
348
  const generatedPrdPath = await planCommand(workdir, config, {
348
349
  from: options.from,
349
350
  feature: options.feature,
350
- auto: true, // AC-1: --auto mode for one-shot planning
351
+ auto: options.oneShot ?? false, // interactive by default; --one-shot skips Q&A
351
352
  branch: undefined,
352
353
  });
353
354
 
@@ -625,7 +626,8 @@ program
625
626
  .description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')")
626
627
  .requiredOption("--from <spec-path>", "Path to spec file (required)")
627
628
  .requiredOption("-f, --feature <name>", "Feature name (required)")
628
- .option("--auto", "Run in auto (one-shot LLM) mode", false)
629
+ .option("--auto", "Run in one-shot LLM mode (alias: --one-shot)", false)
630
+ .option("--one-shot", "Run in one-shot LLM mode (alias: --auto)", false)
629
631
  .option("-b, --branch <branch>", "Override default branch name")
630
632
  .option("-d, --dir <path>", "Project directory", process.cwd())
631
633
  .action(async (description, options) => {
@@ -658,7 +660,7 @@ program
658
660
  const prdPath = await planCommand(workdir, config, {
659
661
  from: options.from,
660
662
  feature: options.feature,
661
- auto: options.auto,
663
+ auto: options.auto || options.oneShot, // --auto and --one-shot are aliases
662
664
  branch: options.branch,
663
665
  });
664
666
 
package/dist/nax.js CHANGED
@@ -19439,29 +19439,57 @@ class AcpAgentAdapter {
19439
19439
  }
19440
19440
  async complete(prompt, _options) {
19441
19441
  const model = _options?.model ?? "default";
19442
- const cmdStr = `acpx --model ${model} ${this.name}`;
19443
- const client = _acpAdapterDeps.createClient(cmdStr);
19444
- await client.start();
19442
+ const timeoutMs = _options?.timeoutMs ?? 120000;
19445
19443
  const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
19446
- let session = null;
19447
- try {
19448
- session = await client.createSession({ agentName: this.name, permissionMode });
19449
- const response = await session.prompt(prompt);
19450
- if (response.stopReason === "error") {
19451
- throw new CompleteError("complete() failed: stop reason is error");
19452
- }
19453
- const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
19444
+ let lastError;
19445
+ for (let attempt = 0;attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
19446
+ const cmdStr = `acpx --model ${model} ${this.name}`;
19447
+ const client = _acpAdapterDeps.createClient(cmdStr);
19448
+ await client.start();
19449
+ let session = null;
19450
+ try {
19451
+ session = await client.createSession({ agentName: this.name, permissionMode });
19452
+ let timeoutId;
19453
+ const timeoutPromise = new Promise((_, reject) => {
19454
+ timeoutId = setTimeout(() => reject(new Error(`complete() timed out after ${timeoutMs}ms`)), timeoutMs);
19455
+ });
19456
+ timeoutPromise.catch(() => {});
19457
+ const promptPromise = session.prompt(prompt);
19458
+ let response;
19459
+ try {
19460
+ response = await Promise.race([promptPromise, timeoutPromise]);
19461
+ } finally {
19462
+ clearTimeout(timeoutId);
19463
+ }
19464
+ if (response.stopReason === "error") {
19465
+ throw new CompleteError("complete() failed: stop reason is error");
19466
+ }
19467
+ const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
19454
19468
  `).trim();
19455
- if (!text) {
19456
- throw new CompleteError("complete() returned empty output");
19457
- }
19458
- return text;
19459
- } finally {
19460
- if (session) {
19461
- await session.close().catch(() => {});
19469
+ if (!text) {
19470
+ throw new CompleteError("complete() returned empty output");
19471
+ }
19472
+ return text;
19473
+ } catch (err) {
19474
+ const error48 = err instanceof Error ? err : new Error(String(err));
19475
+ lastError = error48;
19476
+ const shouldRetry = isRateLimitError(error48) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
19477
+ if (!shouldRetry)
19478
+ throw error48;
19479
+ const backoffMs = 2 ** (attempt + 1) * 1000;
19480
+ getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
19481
+ backoffSeconds: backoffMs / 1000,
19482
+ attempt: attempt + 1
19483
+ });
19484
+ await _acpAdapterDeps.sleep(backoffMs);
19485
+ } finally {
19486
+ if (session) {
19487
+ await session.close().catch(() => {});
19488
+ }
19489
+ await client.close().catch(() => {});
19462
19490
  }
19463
- await client.close().catch(() => {});
19464
19491
  }
19492
+ throw lastError ?? new CompleteError("complete() failed with unknown error");
19465
19493
  }
19466
19494
  async plan(options) {
19467
19495
  const modelDef = options.modelDef ?? { provider: "anthropic", model: "default" };
@@ -21807,7 +21835,7 @@ var package_default;
21807
21835
  var init_package = __esm(() => {
21808
21836
  package_default = {
21809
21837
  name: "@nathapp/nax",
21810
- version: "0.42.0",
21838
+ version: "0.42.1",
21811
21839
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21812
21840
  type: "module",
21813
21841
  bin: {
@@ -21872,8 +21900,8 @@ var init_version = __esm(() => {
21872
21900
  NAX_VERSION = package_default.version;
21873
21901
  NAX_COMMIT = (() => {
21874
21902
  try {
21875
- if (/^[0-9a-f]{6,10}$/.test("a59af3a"))
21876
- return "a59af3a";
21903
+ if (/^[0-9a-f]{6,10}$/.test("29c340c"))
21904
+ return "29c340c";
21877
21905
  } catch {}
21878
21906
  try {
21879
21907
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -23550,11 +23578,23 @@ ${stderr}`;
23550
23578
  logger.info("acceptance", "All acceptance tests passed");
23551
23579
  return { action: "continue" };
23552
23580
  }
23553
- if (actualFailures.length === 0 && exitCode !== 0) {
23554
- logger.warn("acceptance", "Tests failed but no specific AC failures detected", {
23581
+ if (failedACs.length > 0 && actualFailures.length === 0) {
23582
+ logger.info("acceptance", "All failed ACs are overridden \u2014 treating as pass");
23583
+ return { action: "continue" };
23584
+ }
23585
+ if (failedACs.length === 0 && exitCode !== 0) {
23586
+ logger.error("acceptance", "Tests errored with no AC failures parsed", {
23587
+ exitCode,
23555
23588
  output
23556
23589
  });
23557
- return { action: "continue" };
23590
+ ctx.acceptanceFailures = {
23591
+ failedACs: ["AC-ERROR"],
23592
+ testOutput: output
23593
+ };
23594
+ return {
23595
+ action: "fail",
23596
+ reason: `Acceptance tests errored (exit code ${exitCode}): syntax error, import failure, or unhandled exception`
23597
+ };
23558
23598
  }
23559
23599
  if (actualFailures.length > 0) {
23560
23600
  const overriddenFailures = failedACs.filter((acId) => overrides[acId]);
@@ -65505,6 +65545,7 @@ async function generateAcceptanceTestsForFeature(specContent, featureName, featu
65505
65545
  init_registry();
65506
65546
  import { existsSync as existsSync9 } from "fs";
65507
65547
  import { join as join10 } from "path";
65548
+ import { createInterface } from "readline";
65508
65549
  init_logger2();
65509
65550
 
65510
65551
  // src/prd/schema.ts
@@ -65584,7 +65625,9 @@ function validateStory(raw, index, allIds) {
65584
65625
  throw new Error(`[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`);
65585
65626
  }
65586
65627
  const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
65587
- const testStrategy = rawTestStrategy !== undefined && VALID_TEST_STRATEGIES.includes(rawTestStrategy) ? rawTestStrategy : "tdd-simple";
65628
+ const STRATEGY_ALIASES = { "tdd-lite": "three-session-tdd-lite" };
65629
+ const normalizedStrategy = typeof rawTestStrategy === "string" ? STRATEGY_ALIASES[rawTestStrategy] ?? rawTestStrategy : rawTestStrategy;
65630
+ const testStrategy = normalizedStrategy !== undefined && VALID_TEST_STRATEGIES.includes(normalizedStrategy) ? normalizedStrategy : "tdd-simple";
65588
65631
  const rawDeps = s.dependencies;
65589
65632
  const dependencies = Array.isArray(rawDeps) ? rawDeps : [];
65590
65633
  for (const dep of dependencies) {
@@ -65722,7 +65765,20 @@ function createCliInteractionBridge() {
65722
65765
  return text.includes("?");
65723
65766
  },
65724
65767
  async onQuestionDetected(text) {
65725
- return text;
65768
+ if (!process.stdin.isTTY) {
65769
+ return "";
65770
+ }
65771
+ process.stdout.write(`
65772
+ \uD83E\uDD16 Agent: ${text}
65773
+ You: `);
65774
+ return new Promise((resolve6) => {
65775
+ const rl = createInterface({ input: process.stdin, terminal: false });
65776
+ rl.once("line", (line) => {
65777
+ rl.close();
65778
+ resolve6(line.trim());
65779
+ });
65780
+ rl.once("close", () => resolve6(""));
65781
+ });
65726
65782
  }
65727
65783
  };
65728
65784
  }
@@ -65798,7 +65854,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
65798
65854
  "passes": false,
65799
65855
  "routing": {
65800
65856
  "complexity": "simple | medium | complex | expert",
65801
- "testStrategy": "test-after | tdd-lite | three-session-tdd",
65857
+ "testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
65802
65858
  "reasoning": "string \u2014 brief classification rationale"
65803
65859
  },
65804
65860
  "escalations": [],
@@ -65810,15 +65866,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
65810
65866
  ## Complexity Classification Guide
65811
65867
 
65812
65868
  - simple: \u226450 LOC, single-file change, purely additive, no new dependencies \u2192 test-after
65813
- - medium: 50\u2013200 LOC, 2\u20135 files, standard patterns, clear requirements \u2192 tdd-lite
65869
+ - medium: 50\u2013200 LOC, 2\u20135 files, standard patterns, clear requirements \u2192 tdd-simple
65814
65870
  - complex: 200\u2013500 LOC, multiple modules, new abstractions or integrations \u2192 three-session-tdd
65815
- - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk \u2192 three-session-tdd
65871
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk \u2192 three-session-tdd-lite
65816
65872
 
65817
65873
  ## Test Strategy Guide
65818
65874
 
65819
65875
  - test-after: Simple changes with well-understood behavior. Write tests after implementation.
65820
- - tdd-lite: Medium complexity. Write key tests first, implement, then fill coverage.
65821
- - three-session-tdd: Complex/expert. Full TDD cycle with separate sessions for tests and implementation.
65876
+ - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
65877
+ - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
65878
+ - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
65822
65879
 
65823
65880
  Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
65824
65881
  }
@@ -76508,7 +76565,7 @@ Run \`nax generate\` to regenerate agent config files (CLAUDE.md, AGENTS.md, .cu
76508
76565
  console.log(source_default.dim(`
76509
76566
  Next: nax features create <name>`));
76510
76567
  });
76511
- program2.command("run").description("Run the orchestration loop for a feature").requiredOption("-f, --feature <name>", "Feature name").option("-a, --agent <name>", "Force a specific agent").option("-m, --max-iterations <n>", "Max iterations", "20").option("--dry-run", "Show plan without executing", false).option("--no-context", "Disable context builder (skip file context in prompts)").option("--no-batch", "Disable story batching (execute all stories individually)").option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)").option("--plan", "Run plan phase first before execution", false).option("--from <spec-path>", "Path to spec file (required when --plan is used)").option("--headless", "Force headless mode (disable TUI, use pipe mode)", false).option("--verbose", "Enable verbose logging (debug level)", false).option("--quiet", "Quiet mode (warnings and errors only)", false).option("--silent", "Silent mode (errors only)", false).option("--json", "JSON mode (raw JSONL output to stdout)", false).option("-d, --dir <path>", "Working directory", process.cwd()).option("--skip-precheck", "Skip precheck validations (advanced users only)", false).action(async (options) => {
76568
+ program2.command("run").description("Run the orchestration loop for a feature").requiredOption("-f, --feature <name>", "Feature name").option("-a, --agent <name>", "Force a specific agent").option("-m, --max-iterations <n>", "Max iterations", "20").option("--dry-run", "Show plan without executing", false).option("--no-context", "Disable context builder (skip file context in prompts)").option("--no-batch", "Disable story batching (execute all stories individually)").option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)").option("--plan", "Run plan phase first before execution", false).option("--from <spec-path>", "Path to spec file (required when --plan is used)").option("--one-shot", "Skip interactive planning Q&A, use single LLM call (ACP only)", false).option("--headless", "Force headless mode (disable TUI, use pipe mode)", false).option("--verbose", "Enable verbose logging (debug level)", false).option("--quiet", "Quiet mode (warnings and errors only)", false).option("--silent", "Silent mode (errors only)", false).option("--json", "JSON mode (raw JSONL output to stdout)", false).option("-d, --dir <path>", "Working directory", process.cwd()).option("--skip-precheck", "Skip precheck validations (advanced users only)", false).action(async (options) => {
76512
76569
  let workdir;
76513
76570
  try {
76514
76571
  workdir = validateDirectory(options.dir);
@@ -76557,7 +76614,7 @@ program2.command("run").description("Run the orchestration loop for a feature").
76557
76614
  const generatedPrdPath = await planCommand(workdir, config2, {
76558
76615
  from: options.from,
76559
76616
  feature: options.feature,
76560
- auto: true,
76617
+ auto: options.oneShot ?? false,
76561
76618
  branch: undefined
76562
76619
  });
76563
76620
  const generatedPrd = await loadPRD(generatedPrdPath);
@@ -76774,7 +76831,7 @@ Features:
76774
76831
  }
76775
76832
  console.log();
76776
76833
  });
76777
- program2.command("plan [description]").description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')").requiredOption("--from <spec-path>", "Path to spec file (required)").requiredOption("-f, --feature <name>", "Feature name (required)").option("--auto", "Run in auto (one-shot LLM) mode", false).option("-b, --branch <branch>", "Override default branch name").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (description, options) => {
76834
+ program2.command("plan [description]").description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')").requiredOption("--from <spec-path>", "Path to spec file (required)").requiredOption("-f, --feature <name>", "Feature name (required)").option("--auto", "Run in one-shot LLM mode (alias: --one-shot)", false).option("--one-shot", "Run in one-shot LLM mode (alias: --auto)", false).option("-b, --branch <branch>", "Override default branch name").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (description, options) => {
76778
76835
  if (description) {
76779
76836
  console.error(source_default.red(`Error: Positional args removed in plan v2.
76780
76837
 
@@ -76798,7 +76855,7 @@ Use: nax plan -f <feature> --from <spec>`));
76798
76855
  const prdPath = await planCommand(workdir, config2, {
76799
76856
  from: options.from,
76800
76857
  feature: options.feature,
76801
- auto: options.auto,
76858
+ auto: options.auto || options.oneShot,
76802
76859
  branch: options.branch
76803
76860
  });
76804
76861
  console.log(source_default.green(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.0",
3
+ "version": "0.42.1",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -552,39 +552,74 @@ export class AcpAgentAdapter implements AgentAdapter {
552
552
 
553
553
  async complete(prompt: string, _options?: CompleteOptions): Promise<string> {
554
554
  const model = _options?.model ?? "default";
555
- const cmdStr = `acpx --model ${model} ${this.name}`;
556
- const client = _acpAdapterDeps.createClient(cmdStr);
557
- await client.start();
558
-
559
- // complete() is one-shot — ephemeral session, no session name, no sidecar
555
+ const timeoutMs = _options?.timeoutMs ?? 120_000; // 2-min safety net by default
560
556
  const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
561
557
 
562
- let session: AcpSession | null = null;
563
- try {
564
- session = await client.createSession({ agentName: this.name, permissionMode });
565
- const response = await session.prompt(prompt);
558
+ let lastError: Error | undefined;
566
559
 
567
- if (response.stopReason === "error") {
568
- throw new CompleteError("complete() failed: stop reason is error");
569
- }
560
+ for (let attempt = 0; attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
561
+ const cmdStr = `acpx --model ${model} ${this.name}`;
562
+ const client = _acpAdapterDeps.createClient(cmdStr);
563
+ await client.start();
570
564
 
571
- const text = response.messages
572
- .filter((m) => m.role === "assistant")
573
- .map((m) => m.content)
574
- .join("\n")
575
- .trim();
565
+ let session: AcpSession | null = null;
566
+ try {
567
+ // complete() is one-shot — ephemeral session, no session name, no sidecar
568
+ session = await client.createSession({ agentName: this.name, permissionMode });
576
569
 
577
- if (!text) {
578
- throw new CompleteError("complete() returned empty output");
579
- }
570
+ // Enforce timeout via Promise.race — session.prompt() can hang indefinitely
571
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
572
+ const timeoutPromise = new Promise<never>((_, reject) => {
573
+ timeoutId = setTimeout(() => reject(new Error(`complete() timed out after ${timeoutMs}ms`)), timeoutMs);
574
+ });
575
+ timeoutPromise.catch(() => {}); // prevent unhandled rejection if promptPromise wins
580
576
 
581
- return text;
582
- } finally {
583
- if (session) {
584
- await session.close().catch(() => {});
577
+ const promptPromise = session.prompt(prompt);
578
+
579
+ let response: AcpSessionResponse;
580
+ try {
581
+ response = await Promise.race([promptPromise, timeoutPromise]);
582
+ } finally {
583
+ clearTimeout(timeoutId);
584
+ }
585
+
586
+ if (response.stopReason === "error") {
587
+ throw new CompleteError("complete() failed: stop reason is error");
588
+ }
589
+
590
+ const text = response.messages
591
+ .filter((m) => m.role === "assistant")
592
+ .map((m) => m.content)
593
+ .join("\n")
594
+ .trim();
595
+
596
+ if (!text) {
597
+ throw new CompleteError("complete() returned empty output");
598
+ }
599
+
600
+ return text;
601
+ } catch (err) {
602
+ const error = err instanceof Error ? err : new Error(String(err));
603
+ lastError = error;
604
+
605
+ const shouldRetry = isRateLimitError(error) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
606
+ if (!shouldRetry) throw error;
607
+
608
+ const backoffMs = 2 ** (attempt + 1) * 1000;
609
+ getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
610
+ backoffSeconds: backoffMs / 1000,
611
+ attempt: attempt + 1,
612
+ });
613
+ await _acpAdapterDeps.sleep(backoffMs);
614
+ } finally {
615
+ if (session) {
616
+ await session.close().catch(() => {});
617
+ }
618
+ await client.close().catch(() => {});
585
619
  }
586
- await client.close().catch(() => {});
587
620
  }
621
+
622
+ throw lastError ?? new CompleteError("complete() failed with unknown error");
588
623
  }
589
624
 
590
625
  async plan(options: PlanOptions): Promise<PlanResult> {
@@ -100,6 +100,12 @@ export interface CompleteOptions {
100
100
  model?: string;
101
101
  /** Whether to skip permission prompts (maps to permissionMode in ACP) */
102
102
  dangerouslySkipPermissions?: boolean;
103
+ /**
104
+ * Timeout for the completion call in milliseconds.
105
+ * Adapters that support it (e.g. ACP) will enforce this as a hard deadline.
106
+ * Callers may also wrap complete() in their own Promise.race for shorter timeouts.
107
+ */
108
+ timeoutMs?: number;
103
109
  }
104
110
 
105
111
  /**
package/src/cli/plan.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  * Reads a spec file (--from), builds a planning prompt with codebase context,
5
5
  * calls adapter.complete(), validates the JSON response, and writes prd.json.
6
6
  *
7
- * Interactive mode is not yet implemented (PLN-002).
7
+ * Interactive mode: uses ACP session + stdin bridge for Q&A.
8
8
  */
9
9
 
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { createInterface } from "node:readline";
12
13
  import { getAgent } from "../agents/registry";
13
14
  import type { AgentAdapter } from "../agents/types";
14
15
  import { scanCodebase } from "../analyze/scanner";
@@ -153,15 +154,26 @@ function createCliInteractionBridge(): {
153
154
  } {
154
155
  return {
155
156
  async detectQuestion(text: string): Promise<boolean> {
156
- // Simple heuristic: detect if text contains a question mark
157
157
  return text.includes("?");
158
158
  },
159
159
 
160
160
  async onQuestionDetected(text: string): Promise<string> {
161
- // For now, return the question text as-is to be used as follow-up prompt
162
- // In a real CLI, this would read from stdin
163
- // TODO: Implement stdin reading for actual CLI interaction
164
- return text;
161
+ // In non-TTY mode (headless/pipes), skip interaction and continue
162
+ if (!process.stdin.isTTY) {
163
+ return "";
164
+ }
165
+
166
+ // Print agent question and read one line from stdin
167
+ process.stdout.write(`\nšŸ¤– Agent: ${text}\nYou: `);
168
+
169
+ return new Promise<string>((resolve) => {
170
+ const rl = createInterface({ input: process.stdin, terminal: false });
171
+ rl.once("line", (line) => {
172
+ rl.close();
173
+ resolve(line.trim());
174
+ });
175
+ rl.once("close", () => resolve(""));
176
+ });
165
177
  },
166
178
  };
167
179
  }
@@ -262,7 +274,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
262
274
  "passes": false,
263
275
  "routing": {
264
276
  "complexity": "simple | medium | complex | expert",
265
- "testStrategy": "test-after | tdd-lite | three-session-tdd",
277
+ "testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
266
278
  "reasoning": "string — brief classification rationale"
267
279
  },
268
280
  "escalations": [],
@@ -274,15 +286,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation
274
286
  ## Complexity Classification Guide
275
287
 
276
288
  - simple: ≤50 LOC, single-file change, purely additive, no new dependencies → test-after
277
- - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-lite
289
+ - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-simple
278
290
  - complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
279
- - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd
291
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd-lite
280
292
 
281
293
  ## Test Strategy Guide
282
294
 
283
295
  - test-after: Simple changes with well-understood behavior. Write tests after implementation.
284
- - tdd-lite: Medium complexity. Write key tests first, implement, then fill coverage.
285
- - three-session-tdd: Complex/expert. Full TDD cycle with separate sessions for tests and implementation.
296
+ - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
297
+ - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
298
+ - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
286
299
 
287
300
  Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
288
301
  }
@@ -149,19 +149,34 @@ export const acceptanceStage: PipelineStage = {
149
149
  const overrides = ctx.prd.acceptanceOverrides || {};
150
150
  const actualFailures = failedACs.filter((acId) => !overrides[acId]);
151
151
 
152
- // If all failed ACs are overridden, treat as success
152
+ // If all tests passed cleanly
153
153
  if (actualFailures.length === 0 && exitCode === 0) {
154
154
  logger.info("acceptance", "All acceptance tests passed");
155
155
  return { action: "continue" };
156
156
  }
157
157
 
158
- if (actualFailures.length === 0 && exitCode !== 0) {
159
- // Tests failed but we couldn't parse which ACs
160
- // This might be a setup/teardown error
161
- logger.warn("acceptance", "Tests failed but no specific AC failures detected", {
158
+ // All parsed AC failures are overridden — treat as success even with non-zero exit
159
+ if (failedACs.length > 0 && actualFailures.length === 0) {
160
+ logger.info("acceptance", "All failed ACs are overridden — treating as pass");
161
+ return { action: "continue" };
162
+ }
163
+
164
+ // Non-zero exit but no AC failures parsed at all — test crashed (syntax error, import failure, etc.)
165
+ if (failedACs.length === 0 && exitCode !== 0) {
166
+ logger.error("acceptance", "Tests errored with no AC failures parsed", {
167
+ exitCode,
162
168
  output,
163
169
  });
164
- return { action: "continue" }; // Don't block on unparseable failures
170
+
171
+ ctx.acceptanceFailures = {
172
+ failedACs: ["AC-ERROR"],
173
+ testOutput: output,
174
+ };
175
+
176
+ return {
177
+ action: "fail",
178
+ reason: `Acceptance tests errored (exit code ${exitCode}): syntax error, import failure, or unhandled exception`,
179
+ };
165
180
  }
166
181
 
167
182
  // If we have actual failures, report them
package/src/prd/schema.ts CHANGED
@@ -140,10 +140,14 @@ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserSt
140
140
  }
141
141
 
142
142
  // testStrategy — accept from routing.testStrategy or top-level testStrategy
143
+ // Also map legacy/LLM-hallucinated aliases: tdd-lite → tdd-simple
143
144
  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;
144
148
  const testStrategy: TestStrategy =
145
- rawTestStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(rawTestStrategy)
146
- ? (rawTestStrategy as TestStrategy)
149
+ normalizedStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(normalizedStrategy)
150
+ ? (normalizedStrategy as TestStrategy)
147
151
  : "tdd-simple";
148
152
 
149
153
  // dependencies