@nathapp/nax 0.42.1 → 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
@@ -330,8 +330,8 @@ program
330
330
  formatterMode = "quiet";
331
331
  }
332
332
 
333
- const config = await loadConfig();
334
333
  const naxDir = findProjectDir(workdir);
334
+ const config = await loadConfig(naxDir ?? undefined);
335
335
 
336
336
  if (!naxDir) {
337
337
  console.error(chalk.red("nax not initialized. Run: nax init"));
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 });
@@ -19466,10 +19465,17 @@ class AcpAgentAdapter {
19466
19465
  }
19467
19466
  const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
19468
19467
  `).trim();
19469
- if (!text) {
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) {
19470
19476
  throw new CompleteError("complete() returned empty output");
19471
19477
  }
19472
- return text;
19478
+ return unwrapped;
19473
19479
  } catch (err) {
19474
19480
  const error48 = err instanceof Error ? err : new Error(String(err));
19475
19481
  lastError = error48;
@@ -19492,7 +19498,14 @@ class AcpAgentAdapter {
19492
19498
  throw lastError ?? new CompleteError("complete() failed with unknown error");
19493
19499
  }
19494
19500
  async plan(options) {
19495
- 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" };
19496
19509
  const timeoutSeconds = options.timeoutSeconds ?? options.config?.execution?.sessionTimeoutSeconds ?? 600;
19497
19510
  const result = await this.run({
19498
19511
  prompt: options.prompt,
@@ -19502,6 +19515,7 @@ class AcpAgentAdapter {
19502
19515
  timeoutSeconds,
19503
19516
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19504
19517
  interactionBridge: options.interactionBridge,
19518
+ maxInteractionTurns: options.maxInteractionTurns,
19505
19519
  featureName: options.featureName,
19506
19520
  storyId: options.storyId,
19507
19521
  sessionRole: options.sessionRole
@@ -21835,7 +21849,7 @@ var package_default;
21835
21849
  var init_package = __esm(() => {
21836
21850
  package_default = {
21837
21851
  name: "@nathapp/nax",
21838
- version: "0.42.1",
21852
+ version: "0.42.2",
21839
21853
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21840
21854
  type: "module",
21841
21855
  bin: {
@@ -21889,7 +21903,15 @@ var init_package = __esm(() => {
21889
21903
  "bin/",
21890
21904
  "README.md",
21891
21905
  "CHANGELOG.md"
21892
- ]
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
+ }
21893
21915
  };
21894
21916
  });
21895
21917
 
@@ -21900,8 +21922,8 @@ var init_version = __esm(() => {
21900
21922
  NAX_VERSION = package_default.version;
21901
21923
  NAX_COMMIT = (() => {
21902
21924
  try {
21903
- if (/^[0-9a-f]{6,10}$/.test("29c340c"))
21904
- return "29c340c";
21925
+ if (/^[0-9a-f]{6,10}$/.test("9c1f716"))
21926
+ return "9c1f716";
21905
21927
  } catch {}
21906
21928
  try {
21907
21929
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -26222,6 +26244,7 @@ async function runRectificationLoop(story, config2, workdir, agent, implementerT
26222
26244
  modelDef: resolveModel(config2.models[implementerTier]),
26223
26245
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26224
26246
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26247
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26225
26248
  featureName,
26226
26249
  storyId: story.id,
26227
26250
  sessionRole: "implementer"
@@ -26829,6 +26852,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
26829
26852
  modelDef: resolveModel(config2.models[modelTier]),
26830
26853
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26831
26854
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26855
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26832
26856
  featureName,
26833
26857
  storyId: story.id,
26834
26858
  sessionRole: role
@@ -27578,6 +27602,7 @@ Category: ${tddResult.failureCategory ?? "unknown"}`,
27578
27602
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
27579
27603
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
27580
27604
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
27605
+ maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
27581
27606
  pidRegistry: ctx.pidRegistry,
27582
27607
  featureName: ctx.prd.feature,
27583
27608
  storyId: ctx.story.id,
@@ -28126,7 +28151,8 @@ ${rectificationPrompt}`;
28126
28151
  modelTier,
28127
28152
  modelDef,
28128
28153
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
28129
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions
28154
+ dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
28155
+ maxInteractionTurns: config2.agent?.maxInteractionTurns
28130
28156
  });
28131
28157
  if (agentResult.success) {
28132
28158
  logger?.info("rectification", `Agent ${label} session complete`, {
@@ -28628,6 +28654,9 @@ function buildScopedCommand(testFiles, baseCommand, testScopedTemplate) {
28628
28654
  }
28629
28655
  return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
28630
28656
  }
28657
+ function isMonorepoOrchestratorCommand(command) {
28658
+ return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
28659
+ }
28631
28660
 
28632
28661
  class ScopedStrategy {
28633
28662
  name = "scoped";
@@ -28635,9 +28664,10 @@ class ScopedStrategy {
28635
28664
  const logger = getLogger();
28636
28665
  const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
28637
28666
  const regressionMode = ctx.regressionMode ?? "deferred";
28667
+ const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
28638
28668
  let effectiveCommand = ctx.testCommand;
28639
28669
  let isFullSuite = true;
28640
- if (smartCfg.enabled && ctx.storyGitRef) {
28670
+ if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
28641
28671
  const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
28642
28672
  const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
28643
28673
  if (pass1Files.length > 0) {
@@ -28657,14 +28687,19 @@ class ScopedStrategy {
28657
28687
  }
28658
28688
  }
28659
28689
  }
28660
- if (isFullSuite && regressionMode === "deferred") {
28690
+ if (isFullSuite && regressionMode === "deferred" && !isMonorepoOrchestrator) {
28661
28691
  logger.info("verify[scoped]", "No mapped tests \u2014 deferring to run-end (mode: deferred)", {
28662
28692
  storyId: ctx.storyId
28663
28693
  });
28664
28694
  return makeSkippedResult(ctx.storyId, "scoped");
28665
28695
  }
28666
- if (isFullSuite) {
28696
+ if (isFullSuite && !isMonorepoOrchestrator) {
28667
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
+ });
28668
28703
  }
28669
28704
  const start = Date.now();
28670
28705
  const result = await _scopedDeps.regression({
@@ -65702,7 +65737,7 @@ var _deps2 = {
65702
65737
  readFile: (path) => Bun.file(path).text(),
65703
65738
  writeFile: (path, content) => Bun.write(path, content).then(() => {}),
65704
65739
  scanCodebase: (workdir) => scanCodebase(workdir),
65705
- getAgent: (name) => getAgent(name),
65740
+ getAgent: (name, cfg) => cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
65706
65741
  readPackageJson: (workdir) => Bun.file(join10(workdir, "package.json")).json().catch(() => null),
65707
65742
  spawnSync: (cmd, opts) => {
65708
65743
  const result = Bun.spawnSync(cmd, opts ? { cwd: opts.cwd } : {});
@@ -65726,15 +65761,23 @@ async function planCommand(workdir, config2, options) {
65726
65761
  const branchName = options.branch ?? `feat/${options.feature}`;
65727
65762
  const prompt = buildPlanningPrompt(specContent, codebaseContext);
65728
65763
  const agentName = config2?.autoMode?.defaultAgent ?? "claude";
65729
- const adapter = _deps2.getAgent(agentName);
65730
- if (!adapter) {
65731
- throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65732
- }
65733
65764
  const timeoutSeconds = config2?.execution?.sessionTimeoutSeconds ?? 600;
65734
65765
  let rawResponse;
65735
65766
  if (options.auto) {
65736
- 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 {}
65737
65777
  } else {
65778
+ const adapter = _deps2.getAgent(agentName, config2);
65779
+ if (!adapter)
65780
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65738
65781
  const interactionBridge = createCliInteractionBridge();
65739
65782
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
65740
65783
  try {
@@ -65743,7 +65786,11 @@ async function planCommand(workdir, config2, options) {
65743
65786
  workdir,
65744
65787
  interactive: true,
65745
65788
  timeoutSeconds,
65746
- interactionBridge
65789
+ interactionBridge,
65790
+ config: config2,
65791
+ modelTier: "balanced",
65792
+ dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
65793
+ maxInteractionTurns: config2?.agent?.maxInteractionTurns
65747
65794
  });
65748
65795
  rawResponse = result.specContent;
65749
65796
  } finally {
@@ -67848,7 +67895,10 @@ var FIELD_DESCRIPTIONS = {
67848
67895
  "decompose.maxSubstories": "Max number of substories to generate (default: 5)",
67849
67896
  "decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
67850
67897
  "decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
67851
- "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)"
67852
67902
  };
67853
67903
 
67854
67904
  // src/cli/config-diff.ts
@@ -76600,8 +76650,8 @@ program2.command("run").description("Run the orchestration loop for a feature").
76600
76650
  } else if (options.quiet || options.silent) {
76601
76651
  formatterMode = "quiet";
76602
76652
  }
76603
- const config2 = await loadConfig();
76604
76653
  const naxDir = findProjectDir(workdir);
76654
+ const config2 = await loadConfig(naxDir ?? undefined);
76605
76655
  if (!naxDir) {
76606
76656
  console.error(source_default.red("nax not initialized. Run: nax init"));
76607
76657
  process.exit(1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.1",
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++;
@@ -593,11 +593,24 @@ export class AcpAgentAdapter implements AgentAdapter {
593
593
  .join("\n")
594
594
  .trim();
595
595
 
596
- if (!text) {
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) {
597
610
  throw new CompleteError("complete() returned empty output");
598
611
  }
599
612
 
600
- return text;
613
+ return unwrapped;
601
614
  } catch (err) {
602
615
  const error = err instanceof Error ? err : new Error(String(err));
603
616
  lastError = error;
@@ -623,7 +636,17 @@ export class AcpAgentAdapter implements AgentAdapter {
623
636
  }
624
637
 
625
638
  async plan(options: PlanOptions): Promise<PlanResult> {
626
- 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" };
627
650
  // Timeout: from options, or config, or fallback to 600s
628
651
  const timeoutSeconds =
629
652
  options.timeoutSeconds ?? (options.config?.execution?.sessionTimeoutSeconds as number | undefined) ?? 600;
@@ -636,6 +659,7 @@ export class AcpAgentAdapter implements AgentAdapter {
636
659
  timeoutSeconds,
637
660
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
638
661
  interactionBridge: options.interactionBridge,
662
+ maxInteractionTurns: options.maxInteractionTurns,
639
663
  featureName: options.featureName,
640
664
  storyId: options.storyId,
641
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
  /**
@@ -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
@@ -10,7 +10,7 @@
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { createInterface } from "node:readline";
13
- import { getAgent } from "../agents/registry";
13
+ import { createAgentRegistry, getAgent } from "../agents/registry";
14
14
  import type { AgentAdapter } from "../agents/types";
15
15
  import { scanCodebase } from "../analyze/scanner";
16
16
  import type { CodebaseScan } from "../analyze/types";
@@ -26,7 +26,8 @@ export const _deps = {
26
26
  readFile: (path: string): Promise<string> => Bun.file(path).text(),
27
27
  writeFile: (path: string, content: string): Promise<void> => Bun.write(path, content).then(() => {}),
28
28
  scanCodebase: (workdir: string): Promise<CodebaseScan> => scanCodebase(workdir),
29
- getAgent: (name: string): AgentAdapter | undefined => getAgent(name),
29
+ getAgent: (name: string, cfg?: NaxConfig): AgentAdapter | undefined =>
30
+ cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
30
31
  readPackageJson: (workdir: string): Promise<Record<string, unknown> | null> =>
31
32
  Bun.file(join(workdir, "package.json"))
32
33
  .json()
@@ -91,12 +92,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
91
92
  const branchName = options.branch ?? `feat/${options.feature}`;
92
93
  const prompt = buildPlanningPrompt(specContent, codebaseContext);
93
94
 
94
- // Get agent adapter
95
95
  const agentName = config?.autoMode?.defaultAgent ?? "claude";
96
- const adapter = _deps.getAgent(agentName);
97
- if (!adapter) {
98
- throw new Error(`[plan] No agent adapter found for '${agentName}'`);
99
- }
100
96
 
101
97
  // Timeout: from config, or default to 600 seconds (10 min)
102
98
  const timeoutSeconds = config?.execution?.sessionTimeoutSeconds ?? 600;
@@ -104,8 +100,23 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
104
100
  // Route to auto (one-shot) or interactive (multi-turn) mode
105
101
  let rawResponse: string;
106
102
  if (options.auto) {
107
- 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
+ }
108
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}'`);
109
120
  const interactionBridge = createCliInteractionBridge();
110
121
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
111
122
  try {
@@ -115,6 +126,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
115
126
  interactive: true,
116
127
  timeoutSeconds,
117
128
  interactionBridge,
129
+ config,
130
+ modelTier: "balanced",
131
+ dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
132
+ maxInteractionTurns: config?.agent?.maxInteractionTurns,
118
133
  });
119
134
  rawResponse = result.specContent;
120
135
  } finally {
@@ -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({
@@ -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,
@@ -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();