@nathapp/nax 0.42.0 → 0.42.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 William Khoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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)
@@ -329,8 +330,8 @@ program
329
330
  formatterMode = "quiet";
330
331
  }
331
332
 
332
- const config = await loadConfig();
333
333
  const naxDir = findProjectDir(workdir);
334
+ const config = await loadConfig(naxDir ?? undefined);
334
335
 
335
336
  if (!naxDir) {
336
337
  console.error(chalk.red("nax not initialized. Run: nax init"));
@@ -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
@@ -17804,7 +17804,7 @@ var init_schemas3 = __esm(() => {
17804
17804
  });
17805
17805
  AgentConfigSchema = exports_external.object({
17806
17806
  protocol: exports_external.enum(["acp", "cli"]).default("acp"),
17807
- acpPermissionMode: exports_external.string().optional()
17807
+ maxInteractionTurns: exports_external.number().int().min(1).max(100).default(10)
17808
17808
  });
17809
17809
  PrecheckConfigSchema = exports_external.object({
17810
17810
  storySizeGate: StorySizeGateConfigSchema
@@ -17928,7 +17928,6 @@ var init_defaults = __esm(() => {
17928
17928
  detectOpenHandles: true,
17929
17929
  detectOpenHandlesRetries: 1,
17930
17930
  gracePeriodMs: 5000,
17931
- dangerouslySkipPermissions: true,
17932
17931
  drainTimeoutMs: 2000,
17933
17932
  shell: "/bin/sh",
17934
17933
  stripEnvVars: [
@@ -19057,7 +19056,7 @@ class SpawnAcpClient {
19057
19056
  async start() {}
19058
19057
  async createSession(opts) {
19059
19058
  const sessionName = opts.sessionName || `nax-${Date.now()}`;
19060
- const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
19059
+ const cmd = ["acpx", "--cwd", this.cwd, opts.agentName, "sessions", "ensure", "--name", sessionName];
19061
19060
  getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
19062
19061
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
19063
19062
  const exitCode = await proc.exited;
@@ -19076,7 +19075,7 @@ class SpawnAcpClient {
19076
19075
  });
19077
19076
  }
19078
19077
  async loadSession(sessionName) {
19079
- const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
19078
+ const cmd = ["acpx", "--cwd", this.cwd, this.agentName, "sessions", "ensure", "--name", sessionName];
19080
19079
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
19081
19080
  const exitCode = await proc.exited;
19082
19081
  if (exitCode !== 0) {
@@ -19360,7 +19359,7 @@ class AcpAgentAdapter {
19360
19359
  sessionName = await readAcpSession(options.workdir, options.featureName, options.storyId) ?? undefined;
19361
19360
  }
19362
19361
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
19363
- const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "default";
19362
+ const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
19364
19363
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
19365
19364
  if (options.featureName && options.storyId) {
19366
19365
  await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
@@ -19371,7 +19370,7 @@ class AcpAgentAdapter {
19371
19370
  try {
19372
19371
  let currentPrompt = options.prompt;
19373
19372
  let turnCount = 0;
19374
- const MAX_TURNS = options.interactionBridge ? 10 : 1;
19373
+ const MAX_TURNS = options.interactionBridge ? options.maxInteractionTurns ?? 10 : 1;
19375
19374
  while (turnCount < MAX_TURNS) {
19376
19375
  turnCount++;
19377
19376
  getSafeLogger()?.debug("acp-adapter", `Session turn ${turnCount}/${MAX_TURNS}`, { sessionName });
@@ -19439,32 +19438,74 @@ class AcpAgentAdapter {
19439
19438
  }
19440
19439
  async complete(prompt, _options) {
19441
19440
  const model = _options?.model ?? "default";
19442
- const cmdStr = `acpx --model ${model} ${this.name}`;
19443
- const client = _acpAdapterDeps.createClient(cmdStr);
19444
- await client.start();
19441
+ const timeoutMs = _options?.timeoutMs ?? 120000;
19445
19442
  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(`
19443
+ let lastError;
19444
+ for (let attempt = 0;attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
19445
+ const cmdStr = `acpx --model ${model} ${this.name}`;
19446
+ const client = _acpAdapterDeps.createClient(cmdStr);
19447
+ await client.start();
19448
+ let session = null;
19449
+ try {
19450
+ session = await client.createSession({ agentName: this.name, permissionMode });
19451
+ let timeoutId;
19452
+ const timeoutPromise = new Promise((_, reject) => {
19453
+ timeoutId = setTimeout(() => reject(new Error(`complete() timed out after ${timeoutMs}ms`)), timeoutMs);
19454
+ });
19455
+ timeoutPromise.catch(() => {});
19456
+ const promptPromise = session.prompt(prompt);
19457
+ let response;
19458
+ try {
19459
+ response = await Promise.race([promptPromise, timeoutPromise]);
19460
+ } finally {
19461
+ clearTimeout(timeoutId);
19462
+ }
19463
+ if (response.stopReason === "error") {
19464
+ throw new CompleteError("complete() failed: stop reason is error");
19465
+ }
19466
+ const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
19454
19467
  `).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(() => {});
19468
+ let unwrapped = text;
19469
+ try {
19470
+ const envelope = JSON.parse(text);
19471
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
19472
+ unwrapped = envelope.result;
19473
+ }
19474
+ } catch {}
19475
+ if (!unwrapped) {
19476
+ throw new CompleteError("complete() returned empty output");
19477
+ }
19478
+ return unwrapped;
19479
+ } catch (err) {
19480
+ const error48 = err instanceof Error ? err : new Error(String(err));
19481
+ lastError = error48;
19482
+ const shouldRetry = isRateLimitError(error48) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
19483
+ if (!shouldRetry)
19484
+ throw error48;
19485
+ const backoffMs = 2 ** (attempt + 1) * 1000;
19486
+ getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
19487
+ backoffSeconds: backoffMs / 1000,
19488
+ attempt: attempt + 1
19489
+ });
19490
+ await _acpAdapterDeps.sleep(backoffMs);
19491
+ } finally {
19492
+ if (session) {
19493
+ await session.close().catch(() => {});
19494
+ }
19495
+ await client.close().catch(() => {});
19462
19496
  }
19463
- await client.close().catch(() => {});
19464
19497
  }
19498
+ throw lastError ?? new CompleteError("complete() failed with unknown error");
19465
19499
  }
19466
19500
  async plan(options) {
19467
- const modelDef = options.modelDef ?? { provider: "anthropic", model: "default" };
19501
+ let modelDef = options.modelDef;
19502
+ if (!modelDef && options.config?.models) {
19503
+ const { resolveBalancedModelDef: resolveBalancedModelDef2 } = await Promise.resolve().then(() => (init_model_resolution(), exports_model_resolution));
19504
+ try {
19505
+ modelDef = resolveBalancedModelDef2(options.config);
19506
+ } catch {}
19507
+ }
19508
+ modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
19468
19509
  const timeoutSeconds = options.timeoutSeconds ?? options.config?.execution?.sessionTimeoutSeconds ?? 600;
19469
19510
  const result = await this.run({
19470
19511
  prompt: options.prompt,
@@ -19474,6 +19515,7 @@ class AcpAgentAdapter {
19474
19515
  timeoutSeconds,
19475
19516
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19476
19517
  interactionBridge: options.interactionBridge,
19518
+ maxInteractionTurns: options.maxInteractionTurns,
19477
19519
  featureName: options.featureName,
19478
19520
  storyId: options.storyId,
19479
19521
  sessionRole: options.sessionRole
@@ -21807,7 +21849,7 @@ var package_default;
21807
21849
  var init_package = __esm(() => {
21808
21850
  package_default = {
21809
21851
  name: "@nathapp/nax",
21810
- version: "0.42.0",
21852
+ version: "0.42.2",
21811
21853
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21812
21854
  type: "module",
21813
21855
  bin: {
@@ -21861,7 +21903,15 @@ var init_package = __esm(() => {
21861
21903
  "bin/",
21862
21904
  "README.md",
21863
21905
  "CHANGELOG.md"
21864
- ]
21906
+ ],
21907
+ repository: {
21908
+ type: "git",
21909
+ url: "https://github.com/nathapp-io/nax.git"
21910
+ },
21911
+ homepage: "https://github.com/nathapp-io/nax",
21912
+ bugs: {
21913
+ url: "https://github.com/nathapp-io/nax/issues"
21914
+ }
21865
21915
  };
21866
21916
  });
21867
21917
 
@@ -21872,8 +21922,8 @@ var init_version = __esm(() => {
21872
21922
  NAX_VERSION = package_default.version;
21873
21923
  NAX_COMMIT = (() => {
21874
21924
  try {
21875
- if (/^[0-9a-f]{6,10}$/.test("a59af3a"))
21876
- return "a59af3a";
21925
+ if (/^[0-9a-f]{6,10}$/.test("9c1f716"))
21926
+ return "9c1f716";
21877
21927
  } catch {}
21878
21928
  try {
21879
21929
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -23550,11 +23600,23 @@ ${stderr}`;
23550
23600
  logger.info("acceptance", "All acceptance tests passed");
23551
23601
  return { action: "continue" };
23552
23602
  }
23553
- if (actualFailures.length === 0 && exitCode !== 0) {
23554
- logger.warn("acceptance", "Tests failed but no specific AC failures detected", {
23603
+ if (failedACs.length > 0 && actualFailures.length === 0) {
23604
+ logger.info("acceptance", "All failed ACs are overridden \u2014 treating as pass");
23605
+ return { action: "continue" };
23606
+ }
23607
+ if (failedACs.length === 0 && exitCode !== 0) {
23608
+ logger.error("acceptance", "Tests errored with no AC failures parsed", {
23609
+ exitCode,
23555
23610
  output
23556
23611
  });
23557
- return { action: "continue" };
23612
+ ctx.acceptanceFailures = {
23613
+ failedACs: ["AC-ERROR"],
23614
+ testOutput: output
23615
+ };
23616
+ return {
23617
+ action: "fail",
23618
+ reason: `Acceptance tests errored (exit code ${exitCode}): syntax error, import failure, or unhandled exception`
23619
+ };
23558
23620
  }
23559
23621
  if (actualFailures.length > 0) {
23560
23622
  const overriddenFailures = failedACs.filter((acId) => overrides[acId]);
@@ -26182,6 +26244,7 @@ async function runRectificationLoop(story, config2, workdir, agent, implementerT
26182
26244
  modelDef: resolveModel(config2.models[implementerTier]),
26183
26245
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26184
26246
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26247
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26185
26248
  featureName,
26186
26249
  storyId: story.id,
26187
26250
  sessionRole: "implementer"
@@ -26789,6 +26852,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
26789
26852
  modelDef: resolveModel(config2.models[modelTier]),
26790
26853
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26791
26854
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26855
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26792
26856
  featureName,
26793
26857
  storyId: story.id,
26794
26858
  sessionRole: role
@@ -27538,6 +27602,7 @@ Category: ${tddResult.failureCategory ?? "unknown"}`,
27538
27602
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
27539
27603
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
27540
27604
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
27605
+ maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
27541
27606
  pidRegistry: ctx.pidRegistry,
27542
27607
  featureName: ctx.prd.feature,
27543
27608
  storyId: ctx.story.id,
@@ -28086,7 +28151,8 @@ ${rectificationPrompt}`;
28086
28151
  modelTier,
28087
28152
  modelDef,
28088
28153
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
28089
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions
28154
+ dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
28155
+ maxInteractionTurns: config2.agent?.maxInteractionTurns
28090
28156
  });
28091
28157
  if (agentResult.success) {
28092
28158
  logger?.info("rectification", `Agent ${label} session complete`, {
@@ -28588,6 +28654,9 @@ function buildScopedCommand(testFiles, baseCommand, testScopedTemplate) {
28588
28654
  }
28589
28655
  return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
28590
28656
  }
28657
+ function isMonorepoOrchestratorCommand(command) {
28658
+ return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
28659
+ }
28591
28660
 
28592
28661
  class ScopedStrategy {
28593
28662
  name = "scoped";
@@ -28595,9 +28664,10 @@ class ScopedStrategy {
28595
28664
  const logger = getLogger();
28596
28665
  const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
28597
28666
  const regressionMode = ctx.regressionMode ?? "deferred";
28667
+ const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
28598
28668
  let effectiveCommand = ctx.testCommand;
28599
28669
  let isFullSuite = true;
28600
- if (smartCfg.enabled && ctx.storyGitRef) {
28670
+ if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
28601
28671
  const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
28602
28672
  const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
28603
28673
  if (pass1Files.length > 0) {
@@ -28617,14 +28687,19 @@ class ScopedStrategy {
28617
28687
  }
28618
28688
  }
28619
28689
  }
28620
- if (isFullSuite && regressionMode === "deferred") {
28690
+ if (isFullSuite && regressionMode === "deferred" && !isMonorepoOrchestrator) {
28621
28691
  logger.info("verify[scoped]", "No mapped tests \u2014 deferring to run-end (mode: deferred)", {
28622
28692
  storyId: ctx.storyId
28623
28693
  });
28624
28694
  return makeSkippedResult(ctx.storyId, "scoped");
28625
28695
  }
28626
- if (isFullSuite) {
28696
+ if (isFullSuite && !isMonorepoOrchestrator) {
28627
28697
  logger.info("verify[scoped]", "No mapped tests \u2014 falling back to full suite", { storyId: ctx.storyId });
28698
+ } else if (isMonorepoOrchestrator) {
28699
+ logger.info("verify[scoped]", "Monorepo orchestrator detected \u2014 delegating scoping to tool", {
28700
+ storyId: ctx.storyId,
28701
+ command: effectiveCommand
28702
+ });
28628
28703
  }
28629
28704
  const start = Date.now();
28630
28705
  const result = await _scopedDeps.regression({
@@ -65505,6 +65580,7 @@ async function generateAcceptanceTestsForFeature(specContent, featureName, featu
65505
65580
  init_registry();
65506
65581
  import { existsSync as existsSync9 } from "fs";
65507
65582
  import { join as join10 } from "path";
65583
+ import { createInterface } from "readline";
65508
65584
  init_logger2();
65509
65585
 
65510
65586
  // src/prd/schema.ts
@@ -65584,7 +65660,9 @@ function validateStory(raw, index, allIds) {
65584
65660
  throw new Error(`[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`);
65585
65661
  }
65586
65662
  const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
65587
- const testStrategy = rawTestStrategy !== undefined && VALID_TEST_STRATEGIES.includes(rawTestStrategy) ? rawTestStrategy : "tdd-simple";
65663
+ const STRATEGY_ALIASES = { "tdd-lite": "three-session-tdd-lite" };
65664
+ const normalizedStrategy = typeof rawTestStrategy === "string" ? STRATEGY_ALIASES[rawTestStrategy] ?? rawTestStrategy : rawTestStrategy;
65665
+ const testStrategy = normalizedStrategy !== undefined && VALID_TEST_STRATEGIES.includes(normalizedStrategy) ? normalizedStrategy : "tdd-simple";
65588
65666
  const rawDeps = s.dependencies;
65589
65667
  const dependencies = Array.isArray(rawDeps) ? rawDeps : [];
65590
65668
  for (const dep of dependencies) {
@@ -65659,7 +65737,7 @@ var _deps2 = {
65659
65737
  readFile: (path) => Bun.file(path).text(),
65660
65738
  writeFile: (path, content) => Bun.write(path, content).then(() => {}),
65661
65739
  scanCodebase: (workdir) => scanCodebase(workdir),
65662
- getAgent: (name) => getAgent(name),
65740
+ getAgent: (name, cfg) => cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
65663
65741
  readPackageJson: (workdir) => Bun.file(join10(workdir, "package.json")).json().catch(() => null),
65664
65742
  spawnSync: (cmd, opts) => {
65665
65743
  const result = Bun.spawnSync(cmd, opts ? { cwd: opts.cwd } : {});
@@ -65683,15 +65761,23 @@ async function planCommand(workdir, config2, options) {
65683
65761
  const branchName = options.branch ?? `feat/${options.feature}`;
65684
65762
  const prompt = buildPlanningPrompt(specContent, codebaseContext);
65685
65763
  const agentName = config2?.autoMode?.defaultAgent ?? "claude";
65686
- const adapter = _deps2.getAgent(agentName);
65687
- if (!adapter) {
65688
- throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65689
- }
65690
65764
  const timeoutSeconds = config2?.execution?.sessionTimeoutSeconds ?? 600;
65691
65765
  let rawResponse;
65692
65766
  if (options.auto) {
65693
- rawResponse = await adapter.complete(prompt, { jsonMode: true });
65767
+ const cliAdapter = _deps2.getAgent(agentName);
65768
+ if (!cliAdapter)
65769
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65770
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
65771
+ try {
65772
+ const envelope = JSON.parse(rawResponse);
65773
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
65774
+ rawResponse = envelope.result;
65775
+ }
65776
+ } catch {}
65694
65777
  } else {
65778
+ const adapter = _deps2.getAgent(agentName, config2);
65779
+ if (!adapter)
65780
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65695
65781
  const interactionBridge = createCliInteractionBridge();
65696
65782
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
65697
65783
  try {
@@ -65700,7 +65786,11 @@ async function planCommand(workdir, config2, options) {
65700
65786
  workdir,
65701
65787
  interactive: true,
65702
65788
  timeoutSeconds,
65703
- interactionBridge
65789
+ interactionBridge,
65790
+ config: config2,
65791
+ modelTier: "balanced",
65792
+ dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
65793
+ maxInteractionTurns: config2?.agent?.maxInteractionTurns
65704
65794
  });
65705
65795
  rawResponse = result.specContent;
65706
65796
  } finally {
@@ -65722,7 +65812,20 @@ function createCliInteractionBridge() {
65722
65812
  return text.includes("?");
65723
65813
  },
65724
65814
  async onQuestionDetected(text) {
65725
- return text;
65815
+ if (!process.stdin.isTTY) {
65816
+ return "";
65817
+ }
65818
+ process.stdout.write(`
65819
+ \uD83E\uDD16 Agent: ${text}
65820
+ You: `);
65821
+ return new Promise((resolve6) => {
65822
+ const rl = createInterface({ input: process.stdin, terminal: false });
65823
+ rl.once("line", (line) => {
65824
+ rl.close();
65825
+ resolve6(line.trim());
65826
+ });
65827
+ rl.once("close", () => resolve6(""));
65828
+ });
65726
65829
  }
65727
65830
  };
65728
65831
  }
@@ -65798,7 +65901,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
65798
65901
  "passes": false,
65799
65902
  "routing": {
65800
65903
  "complexity": "simple | medium | complex | expert",
65801
- "testStrategy": "test-after | tdd-lite | three-session-tdd",
65904
+ "testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
65802
65905
  "reasoning": "string \u2014 brief classification rationale"
65803
65906
  },
65804
65907
  "escalations": [],
@@ -65810,15 +65913,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
65810
65913
  ## Complexity Classification Guide
65811
65914
 
65812
65915
  - 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
65916
+ - medium: 50\u2013200 LOC, 2\u20135 files, standard patterns, clear requirements \u2192 tdd-simple
65814
65917
  - 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
65918
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk \u2192 three-session-tdd-lite
65816
65919
 
65817
65920
  ## Test Strategy Guide
65818
65921
 
65819
65922
  - 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.
65923
+ - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
65924
+ - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
65925
+ - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
65822
65926
 
65823
65927
  Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
65824
65928
  }
@@ -67791,7 +67895,10 @@ var FIELD_DESCRIPTIONS = {
67791
67895
  "decompose.maxSubstories": "Max number of substories to generate (default: 5)",
67792
67896
  "decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
67793
67897
  "decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
67794
- "decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')"
67898
+ "decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
67899
+ agent: "Agent protocol configuration (ACP-003)",
67900
+ "agent.protocol": "Protocol for agent communication: 'acp' | 'cli' (default: 'acp')",
67901
+ "agent.maxInteractionTurns": "Max turns in multi-turn interaction loop when interactionBridge is active (default: 10)"
67795
67902
  };
67796
67903
 
67797
67904
  // src/cli/config-diff.ts
@@ -76508,7 +76615,7 @@ Run \`nax generate\` to regenerate agent config files (CLAUDE.md, AGENTS.md, .cu
76508
76615
  console.log(source_default.dim(`
76509
76616
  Next: nax features create <name>`));
76510
76617
  });
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) => {
76618
+ 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
76619
  let workdir;
76513
76620
  try {
76514
76621
  workdir = validateDirectory(options.dir);
@@ -76543,8 +76650,8 @@ program2.command("run").description("Run the orchestration loop for a feature").
76543
76650
  } else if (options.quiet || options.silent) {
76544
76651
  formatterMode = "quiet";
76545
76652
  }
76546
- const config2 = await loadConfig();
76547
76653
  const naxDir = findProjectDir(workdir);
76654
+ const config2 = await loadConfig(naxDir ?? undefined);
76548
76655
  if (!naxDir) {
76549
76656
  console.error(source_default.red("nax not initialized. Run: nax init"));
76550
76657
  process.exit(1);
@@ -76557,7 +76664,7 @@ program2.command("run").description("Run the orchestration loop for a feature").
76557
76664
  const generatedPrdPath = await planCommand(workdir, config2, {
76558
76665
  from: options.from,
76559
76666
  feature: options.feature,
76560
- auto: true,
76667
+ auto: options.oneShot ?? false,
76561
76668
  branch: undefined
76562
76669
  });
76563
76670
  const generatedPrd = await loadPRD(generatedPrdPath);
@@ -76774,7 +76881,7 @@ Features:
76774
76881
  }
76775
76882
  console.log();
76776
76883
  });
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) => {
76884
+ 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
76885
  if (description) {
76779
76886
  console.error(source_default.red(`Error: Positional args removed in plan v2.
76780
76887
 
@@ -76798,7 +76905,7 @@ Use: nax plan -f <feature> --from <spec>`));
76798
76905
  const prdPath = await planCommand(workdir, config2, {
76799
76906
  from: options.from,
76800
76907
  feature: options.feature,
76801
- auto: options.auto,
76908
+ auto: options.auto || options.oneShot,
76802
76909
  branch: options.branch
76803
76910
  });
76804
76911
  console.log(source_default.green(`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.0",
4
- "description": "AI Coding Agent Orchestrator \u2014 loops until done",
3
+ "version": "0.42.2",
4
+ "description": "AI Coding Agent Orchestrator loops until done",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nax": "./dist/nax.js"
@@ -54,5 +54,13 @@
54
54
  "bin/",
55
55
  "README.md",
56
56
  "CHANGELOG.md"
57
- ]
57
+ ],
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/nathapp-io/nax.git"
61
+ },
62
+ "homepage": "https://github.com/nathapp-io/nax",
63
+ "bugs": {
64
+ "url": "https://github.com/nathapp-io/nax/issues"
65
+ }
58
66
  }
@@ -442,8 +442,8 @@ export class AcpAgentAdapter implements AgentAdapter {
442
442
  }
443
443
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
444
444
 
445
- // 2. Permission mode follows dangerouslySkipPermissions
446
- const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "default";
445
+ // 2. Permission mode follows dangerouslySkipPermissions, default is "approve-reads". or should --deny-all be the default?
446
+ const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
447
447
 
448
448
  // 3. Ensure session (resume existing or create new)
449
449
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
@@ -461,7 +461,7 @@ export class AcpAgentAdapter implements AgentAdapter {
461
461
  // 5. Multi-turn loop
462
462
  let currentPrompt = options.prompt;
463
463
  let turnCount = 0;
464
- const MAX_TURNS = options.interactionBridge ? 10 : 1;
464
+ const MAX_TURNS = options.interactionBridge ? (options.maxInteractionTurns ?? 10) : 1;
465
465
 
466
466
  while (turnCount < MAX_TURNS) {
467
467
  turnCount++;
@@ -552,43 +552,101 @@ 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
+ // ACP one-shot sessions wrap the response in a result envelope:
597
+ // {"type":"result","subtype":"success","result":"<actual output>"}
598
+ // Unwrap to return the actual content.
599
+ let unwrapped = text;
600
+ try {
601
+ const envelope = JSON.parse(text) as Record<string, unknown>;
602
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
603
+ unwrapped = envelope.result;
604
+ }
605
+ } catch {
606
+ // Not an envelope — use text as-is
607
+ }
608
+
609
+ if (!unwrapped) {
610
+ throw new CompleteError("complete() returned empty output");
611
+ }
612
+
613
+ return unwrapped;
614
+ } catch (err) {
615
+ const error = err instanceof Error ? err : new Error(String(err));
616
+ lastError = error;
617
+
618
+ const shouldRetry = isRateLimitError(error) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
619
+ if (!shouldRetry) throw error;
620
+
621
+ const backoffMs = 2 ** (attempt + 1) * 1000;
622
+ getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
623
+ backoffSeconds: backoffMs / 1000,
624
+ attempt: attempt + 1,
625
+ });
626
+ await _acpAdapterDeps.sleep(backoffMs);
627
+ } finally {
628
+ if (session) {
629
+ await session.close().catch(() => {});
630
+ }
631
+ await client.close().catch(() => {});
585
632
  }
586
- await client.close().catch(() => {});
587
633
  }
634
+
635
+ throw lastError ?? new CompleteError("complete() failed with unknown error");
588
636
  }
589
637
 
590
638
  async plan(options: PlanOptions): Promise<PlanResult> {
591
- const modelDef = options.modelDef ?? { provider: "anthropic", model: "default" };
639
+ // Resolve model: explicit > config.models.balanced > fallback
640
+ let modelDef = options.modelDef;
641
+ if (!modelDef && options.config?.models) {
642
+ const { resolveBalancedModelDef } = await import("../model-resolution");
643
+ try {
644
+ modelDef = resolveBalancedModelDef(options.config as import("../../config").NaxConfig);
645
+ } catch {
646
+ // resolveBalancedModelDef can throw if models.balanced missing
647
+ }
648
+ }
649
+ modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
592
650
  // Timeout: from options, or config, or fallback to 600s
593
651
  const timeoutSeconds =
594
652
  options.timeoutSeconds ?? (options.config?.execution?.sessionTimeoutSeconds as number | undefined) ?? 600;
@@ -601,6 +659,7 @@ export class AcpAgentAdapter implements AgentAdapter {
601
659
  timeoutSeconds,
602
660
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
603
661
  interactionBridge: options.interactionBridge,
662
+ maxInteractionTurns: options.maxInteractionTurns,
604
663
  featureName: options.featureName,
605
664
  storyId: options.storyId,
606
665
  sessionRole: options.sessionRole,
@@ -247,7 +247,7 @@ export class SpawnAcpClient implements AcpClient {
247
247
  const sessionName = opts.sessionName || `nax-${Date.now()}`;
248
248
 
249
249
  // Ensure session exists via CLI
250
- const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
250
+ const cmd = ["acpx", "--cwd", this.cwd, opts.agentName, "sessions", "ensure", "--name", sessionName];
251
251
  getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
252
252
 
253
253
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
@@ -271,7 +271,7 @@ export class SpawnAcpClient implements AcpClient {
271
271
 
272
272
  async loadSession(sessionName: string): Promise<AcpSession | null> {
273
273
  // Try to ensure session exists — if it does, acpx returns success
274
- const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
274
+ const cmd = ["acpx", "--cwd", this.cwd, this.agentName, "sessions", "ensure", "--name", sessionName];
275
275
 
276
276
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
277
277
  const exitCode = await proc.exited;
@@ -48,6 +48,8 @@ export interface PlanOptions {
48
48
  timeoutSeconds?: number;
49
49
  /** Whether to skip permission prompts (maps to permissionMode in ACP) */
50
50
  dangerouslySkipPermissions?: boolean;
51
+ /** Max interaction turns when interactionBridge is active (default: 10) */
52
+ maxInteractionTurns?: number;
51
53
  /**
52
54
  * Callback invoked with the ACP session name after the session is created.
53
55
  * Used to persist the name to status.json for plan→run session continuity.
@@ -74,6 +74,8 @@ export interface AgentRunOptions {
74
74
  storyId?: string;
75
75
  /** Session role for TDD isolation (e.g. "test-writer" | "implementer" | "verifier") */
76
76
  sessionRole?: string;
77
+ /** Max turns in multi-turn interaction loop when interactionBridge is active (default: 10) */
78
+ maxInteractionTurns?: number;
77
79
  }
78
80
 
79
81
  /**
@@ -100,6 +102,12 @@ export interface CompleteOptions {
100
102
  model?: string;
101
103
  /** Whether to skip permission prompts (maps to permissionMode in ACP) */
102
104
  dangerouslySkipPermissions?: boolean;
105
+ /**
106
+ * Timeout for the completion call in milliseconds.
107
+ * Adapters that support it (e.g. ACP) will enforce this as a hard deadline.
108
+ * Callers may also wrap complete() in their own Promise.race for shorter timeouts.
109
+ */
110
+ timeoutMs?: number;
103
111
  }
104
112
 
105
113
  /**
@@ -203,4 +203,10 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
203
203
  "decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
204
204
  "decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
205
205
  "decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
206
+
207
+ // Agent protocol
208
+ agent: "Agent protocol configuration (ACP-003)",
209
+ "agent.protocol": "Protocol for agent communication: 'acp' | 'cli' (default: 'acp')",
210
+ "agent.maxInteractionTurns":
211
+ "Max turns in multi-turn interaction loop when interactionBridge is active (default: 10)",
206
212
  };
@@ -26,6 +26,7 @@ interface PackageJson {
26
26
  devDependencies?: Record<string, string>;
27
27
  peerDependencies?: Record<string, string>;
28
28
  bin?: Record<string, string> | string;
29
+ workspaces?: string[] | { packages: string[] };
29
30
  }
30
31
 
31
32
  function readPackageJson(projectRoot: string): PackageJson | undefined {
@@ -80,7 +81,7 @@ export type Language = "typescript" | "python" | "rust" | "go" | "unknown";
80
81
  export type Linter = "biome" | "eslint" | "ruff" | "clippy" | "golangci-lint" | "unknown";
81
82
 
82
83
  /** Detected monorepo tooling */
83
- export type Monorepo = "turborepo" | "none";
84
+ export type Monorepo = "turborepo" | "nx" | "pnpm-workspaces" | "bun-workspaces" | "none";
84
85
 
85
86
  /** Full detected project stack */
86
87
  export interface ProjectStack {
@@ -137,6 +138,11 @@ function detectLinter(projectRoot: string): Linter {
137
138
 
138
139
  function detectMonorepo(projectRoot: string): Monorepo {
139
140
  if (existsSync(join(projectRoot, "turbo.json"))) return "turborepo";
141
+ if (existsSync(join(projectRoot, "nx.json"))) return "nx";
142
+ if (existsSync(join(projectRoot, "pnpm-workspace.yaml"))) return "pnpm-workspaces";
143
+ // Bun/npm/yarn workspaces: package.json with "workspaces" field
144
+ const pkg = readPackageJson(projectRoot);
145
+ if (pkg?.workspaces) return "bun-workspaces";
140
146
  return "none";
141
147
  }
142
148
 
@@ -158,10 +164,52 @@ function resolveLintCommand(stack: ProjectStack, fallback: string): string {
158
164
  return fallback;
159
165
  }
160
166
 
167
+ /**
168
+ * Build quality.commands for monorepo orchestrators.
169
+ *
170
+ * Turborepo and Nx support change-aware filtering natively — delegate
171
+ * scoping to the tool rather than nax's smart test runner.
172
+ * pnpm/bun workspaces have no built-in affected detection, so nax's
173
+ * smart runner still applies; commands run across all packages.
174
+ */
175
+ function buildMonorepoQualityCommands(stack: ProjectStack): QualityCommands | null {
176
+ if (stack.monorepo === "turborepo") {
177
+ return {
178
+ typecheck: "turbo run typecheck --filter=...[HEAD~1]",
179
+ lint: "turbo run lint --filter=...[HEAD~1]",
180
+ test: "turbo run test --filter=...[HEAD~1]",
181
+ };
182
+ }
183
+ if (stack.monorepo === "nx") {
184
+ return {
185
+ typecheck: "nx affected --target=typecheck",
186
+ lint: "nx affected --target=lint",
187
+ test: "nx affected --target=test",
188
+ };
189
+ }
190
+ if (stack.monorepo === "pnpm-workspaces") {
191
+ return {
192
+ lint: resolveLintCommand(stack, "pnpm run --recursive lint"),
193
+ test: "pnpm run --recursive test",
194
+ };
195
+ }
196
+ if (stack.monorepo === "bun-workspaces") {
197
+ return {
198
+ lint: resolveLintCommand(stack, "bun run lint"),
199
+ test: "bun run --filter '*' test",
200
+ };
201
+ }
202
+ return null;
203
+ }
204
+
161
205
  /**
162
206
  * Build quality.commands from a detected project stack.
163
207
  */
164
208
  export function buildQualityCommands(stack: ProjectStack): QualityCommands {
209
+ // Monorepo orchestrators: delegate to the tool's own scoping
210
+ const monorepoCommands = buildMonorepoQualityCommands(stack);
211
+ if (monorepoCommands) return monorepoCommands;
212
+
165
213
  if (stack.runtime === "bun" && stack.language === "typescript") {
166
214
  return {
167
215
  typecheck: "bun run tsc --noEmit",
package/src/cli/init.ts CHANGED
@@ -103,11 +103,21 @@ function buildConstitution(stack: ProjectStack): string {
103
103
  sections.push("- Use type annotations for variables where non-obvious\n");
104
104
  }
105
105
 
106
- if (stack.monorepo === "turborepo") {
106
+ if (stack.monorepo !== "none") {
107
107
  sections.push("## Monorepo Conventions");
108
108
  sections.push("- Respect package boundaries — do not import across packages without explicit dependency");
109
109
  sections.push("- Each package should be independently buildable and testable");
110
- sections.push("- Shared utilities go in a dedicated `packages/shared` (or equivalent) package\n");
110
+ sections.push("- Shared utilities go in a dedicated `packages/shared` (or equivalent) package");
111
+ if (stack.monorepo === "turborepo") {
112
+ sections.push("- Use `turbo run <task> --filter=<package>` to run tasks scoped to a single package");
113
+ } else if (stack.monorepo === "nx") {
114
+ sections.push("- Use `nx run <package>:<task>` to run tasks scoped to a single package");
115
+ } else if (stack.monorepo === "pnpm-workspaces") {
116
+ sections.push("- Use `pnpm --filter <package> run <task>` to run tasks scoped to a single package");
117
+ } else if (stack.monorepo === "bun-workspaces") {
118
+ sections.push("- Use `bun run --filter <package> <task>` to run tasks scoped to a single package");
119
+ }
120
+ sections.push("");
111
121
  }
112
122
 
113
123
  sections.push("## Preferences");
package/src/cli/plan.ts CHANGED
@@ -4,12 +4,13 @@
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 { getAgent } from "../agents/registry";
12
+ import { createInterface } from "node:readline";
13
+ import { createAgentRegistry, getAgent } from "../agents/registry";
13
14
  import type { AgentAdapter } from "../agents/types";
14
15
  import { scanCodebase } from "../analyze/scanner";
15
16
  import type { CodebaseScan } from "../analyze/types";
@@ -25,7 +26,8 @@ export const _deps = {
25
26
  readFile: (path: string): Promise<string> => Bun.file(path).text(),
26
27
  writeFile: (path: string, content: string): Promise<void> => Bun.write(path, content).then(() => {}),
27
28
  scanCodebase: (workdir: string): Promise<CodebaseScan> => scanCodebase(workdir),
28
- getAgent: (name: string): AgentAdapter | undefined => getAgent(name),
29
+ getAgent: (name: string, cfg?: NaxConfig): AgentAdapter | undefined =>
30
+ cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
29
31
  readPackageJson: (workdir: string): Promise<Record<string, unknown> | null> =>
30
32
  Bun.file(join(workdir, "package.json"))
31
33
  .json()
@@ -90,12 +92,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
90
92
  const branchName = options.branch ?? `feat/${options.feature}`;
91
93
  const prompt = buildPlanningPrompt(specContent, codebaseContext);
92
94
 
93
- // Get agent adapter
94
95
  const agentName = config?.autoMode?.defaultAgent ?? "claude";
95
- const adapter = _deps.getAgent(agentName);
96
- if (!adapter) {
97
- throw new Error(`[plan] No agent adapter found for '${agentName}'`);
98
- }
99
96
 
100
97
  // Timeout: from config, or default to 600 seconds (10 min)
101
98
  const timeoutSeconds = config?.execution?.sessionTimeoutSeconds ?? 600;
@@ -103,8 +100,23 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
103
100
  // Route to auto (one-shot) or interactive (multi-turn) mode
104
101
  let rawResponse: string;
105
102
  if (options.auto) {
106
- rawResponse = await adapter.complete(prompt, { jsonMode: true });
103
+ // One-shot: use CLI adapter directly simple completion doesn't need ACP session overhead
104
+ const cliAdapter = _deps.getAgent(agentName);
105
+ if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
106
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
107
+ // CLI adapter returns {"type":"result","result":"..."} envelope — unwrap it
108
+ try {
109
+ const envelope = JSON.parse(rawResponse) as Record<string, unknown>;
110
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
111
+ rawResponse = envelope.result;
112
+ }
113
+ } catch {
114
+ // Not an envelope — use rawResponse as-is
115
+ }
107
116
  } else {
117
+ // Interactive: use protocol-aware adapter (ACP when configured)
118
+ const adapter = _deps.getAgent(agentName, config);
119
+ if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
108
120
  const interactionBridge = createCliInteractionBridge();
109
121
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
110
122
  try {
@@ -114,6 +126,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
114
126
  interactive: true,
115
127
  timeoutSeconds,
116
128
  interactionBridge,
129
+ config,
130
+ modelTier: "balanced",
131
+ dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
132
+ maxInteractionTurns: config?.agent?.maxInteractionTurns,
117
133
  });
118
134
  rawResponse = result.specContent;
119
135
  } finally {
@@ -153,15 +169,26 @@ function createCliInteractionBridge(): {
153
169
  } {
154
170
  return {
155
171
  async detectQuestion(text: string): Promise<boolean> {
156
- // Simple heuristic: detect if text contains a question mark
157
172
  return text.includes("?");
158
173
  },
159
174
 
160
175
  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;
176
+ // In non-TTY mode (headless/pipes), skip interaction and continue
177
+ if (!process.stdin.isTTY) {
178
+ return "";
179
+ }
180
+
181
+ // Print agent question and read one line from stdin
182
+ process.stdout.write(`\n🤖 Agent: ${text}\nYou: `);
183
+
184
+ return new Promise<string>((resolve) => {
185
+ const rl = createInterface({ input: process.stdin, terminal: false });
186
+ rl.once("line", (line) => {
187
+ rl.close();
188
+ resolve(line.trim());
189
+ });
190
+ rl.once("close", () => resolve(""));
191
+ });
165
192
  },
166
193
  };
167
194
  }
@@ -262,7 +289,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
262
289
  "passes": false,
263
290
  "routing": {
264
291
  "complexity": "simple | medium | complex | expert",
265
- "testStrategy": "test-after | tdd-lite | three-session-tdd",
292
+ "testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
266
293
  "reasoning": "string — brief classification rationale"
267
294
  },
268
295
  "escalations": [],
@@ -274,15 +301,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation
274
301
  ## Complexity Classification Guide
275
302
 
276
303
  - 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
304
+ - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-simple
278
305
  - 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
306
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd-lite
280
307
 
281
308
  ## Test Strategy Guide
282
309
 
283
310
  - 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.
311
+ - tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
312
+ - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
313
+ - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
286
314
 
287
315
  Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
288
316
  }
@@ -81,7 +81,6 @@ export const DEFAULT_CONFIG: NaxConfig = {
81
81
  detectOpenHandles: true,
82
82
  detectOpenHandlesRetries: 1,
83
83
  gracePeriodMs: 5000,
84
- dangerouslySkipPermissions: true,
85
84
  drainTimeoutMs: 2000,
86
85
  shell: "/bin/sh",
87
86
  stripEnvVars: [
@@ -141,8 +141,6 @@ export interface QualityConfig {
141
141
  detectOpenHandlesRetries: number;
142
142
  /** Grace period in ms after SIGTERM before sending SIGKILL (default: 5000) */
143
143
  gracePeriodMs: number;
144
- /** Use --dangerously-skip-permissions for agent sessions (default: false) */
145
- dangerouslySkipPermissions: boolean;
146
144
  /** Deadline in ms to drain stdout/stderr after killing process (Bun stream workaround, default: 2000) */
147
145
  drainTimeoutMs: number;
148
146
  /** Shell to use for running verification commands (default: /bin/sh) */
@@ -473,6 +471,6 @@ export interface NaxConfig {
473
471
  export interface AgentConfig {
474
472
  /** Protocol to use for agent communication (default: 'acp') */
475
473
  protocol?: "acp" | "cli";
476
- /** ACP permission mode (default: 'approve-all') */
477
- acpPermissionMode?: string;
474
+ /** Max interaction turns when interactionBridge is active (default: 10) */
475
+ maxInteractionTurns?: number;
478
476
  }
@@ -328,7 +328,7 @@ const StorySizeGateConfigSchema = z.object({
328
328
 
329
329
  const AgentConfigSchema = z.object({
330
330
  protocol: z.enum(["acp", "cli"]).default("acp"),
331
- acpPermissionMode: z.string().optional(),
331
+ maxInteractionTurns: z.number().int().min(1).max(100).default(10),
332
332
  });
333
333
 
334
334
  const PrecheckConfigSchema = z.object({
@@ -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
@@ -218,6 +218,7 @@ export const executionStage: PipelineStage = {
218
218
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
219
219
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
220
220
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
221
+ maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
221
222
  pidRegistry: ctx.pidRegistry,
222
223
  featureName: ctx.prd.feature,
223
224
  storyId: ctx.story.id,
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
@@ -159,6 +159,7 @@ async function runRectificationLoop(
159
159
  modelDef: resolveModel(config.models[implementerTier]),
160
160
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
161
161
  dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
162
+ maxInteractionTurns: config.agent?.maxInteractionTurns,
162
163
  featureName,
163
164
  storyId: story.id,
164
165
  sessionRole: "implementer",
@@ -140,6 +140,7 @@ export async function runTddSession(
140
140
  modelDef: resolveModel(config.models[modelTier]),
141
141
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
142
142
  dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
143
+ maxInteractionTurns: config.agent?.maxInteractionTurns,
143
144
  featureName,
144
145
  storyId: story.id,
145
146
  sessionRole: role,
@@ -74,6 +74,7 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
74
74
  modelDef,
75
75
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
76
76
  dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
77
+ maxInteractionTurns: config.agent?.maxInteractionTurns,
77
78
  });
78
79
 
79
80
  if (agentResult.success) {
@@ -36,6 +36,18 @@ function buildScopedCommand(testFiles: string[], baseCommand: string, testScoped
36
36
  return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
37
37
  }
38
38
 
39
+ /**
40
+ * Returns true when the test command delegates to a monorepo orchestrator
41
+ * (Turborepo, Nx) that handles change-aware scoping natively.
42
+ *
43
+ * These tools use their own filter syntax (e.g. `--filter=...[HEAD~1]`,
44
+ * `nx affected`) — nax's smart test runner must not attempt to append
45
+ * file paths to such commands, as it would produce invalid syntax.
46
+ */
47
+ export function isMonorepoOrchestratorCommand(command: string): boolean {
48
+ return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
49
+ }
50
+
39
51
  export class ScopedStrategy implements IVerificationStrategy {
40
52
  readonly name = "scoped" as const;
41
53
 
@@ -44,10 +56,16 @@ export class ScopedStrategy implements IVerificationStrategy {
44
56
  const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
45
57
  const regressionMode = ctx.regressionMode ?? "deferred";
46
58
 
59
+ // Monorepo orchestrators (turbo, nx) handle change-aware scoping themselves.
60
+ // Skip nax's smart runner — appending file paths would produce invalid syntax.
61
+ // Also bypass deferred mode: run per-story so the orchestrator's own filter
62
+ // (e.g. --filter=...[HEAD~1]) can pick up the story's changes immediately.
63
+ const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
64
+
47
65
  let effectiveCommand = ctx.testCommand;
48
66
  let isFullSuite = true;
49
67
 
50
- if (smartCfg.enabled && ctx.storyGitRef) {
68
+ if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
51
69
  const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
52
70
 
53
71
  const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
@@ -69,16 +87,22 @@ export class ScopedStrategy implements IVerificationStrategy {
69
87
  }
70
88
  }
71
89
 
72
- // Defer to regression gate when no scoped tests found and mode is deferred
73
- if (isFullSuite && regressionMode === "deferred") {
90
+ // Defer to regression gate when no scoped tests found and mode is deferred.
91
+ // Exception: monorepo orchestrators run per-story (they carry their own change filter).
92
+ if (isFullSuite && regressionMode === "deferred" && !isMonorepoOrchestrator) {
74
93
  logger.info("verify[scoped]", "No mapped tests — deferring to run-end (mode: deferred)", {
75
94
  storyId: ctx.storyId,
76
95
  });
77
96
  return makeSkippedResult(ctx.storyId, "scoped");
78
97
  }
79
98
 
80
- if (isFullSuite) {
99
+ if (isFullSuite && !isMonorepoOrchestrator) {
81
100
  logger.info("verify[scoped]", "No mapped tests — falling back to full suite", { storyId: ctx.storyId });
101
+ } else if (isMonorepoOrchestrator) {
102
+ logger.info("verify[scoped]", "Monorepo orchestrator detected — delegating scoping to tool", {
103
+ storyId: ctx.storyId,
104
+ command: effectiveCommand,
105
+ });
82
106
  }
83
107
 
84
108
  const start = Date.now();