@nathapp/nax 0.42.1 → 0.42.3

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: [
@@ -18055,6 +18054,13 @@ var init_defaults = __esm(() => {
18055
18054
  });
18056
18055
 
18057
18056
  // src/config/schema.ts
18057
+ var exports_schema = {};
18058
+ __export(exports_schema, {
18059
+ resolveModel: () => resolveModel,
18060
+ NaxConfigSchema: () => NaxConfigSchema,
18061
+ DEFAULT_CONFIG: () => DEFAULT_CONFIG,
18062
+ AcceptanceConfigSchema: () => AcceptanceConfigSchema
18063
+ });
18058
18064
  var init_schema = __esm(() => {
18059
18065
  init_types3();
18060
18066
  init_schemas3();
@@ -18969,8 +18975,6 @@ class SpawnAcpSession {
18969
18975
  "--cwd",
18970
18976
  this.cwd,
18971
18977
  ...this.permissionMode === "approve-all" ? ["--approve-all"] : [],
18972
- "--format",
18973
- "json",
18974
18978
  "--model",
18975
18979
  this.model,
18976
18980
  "--timeout",
@@ -19057,7 +19061,7 @@ class SpawnAcpClient {
19057
19061
  async start() {}
19058
19062
  async createSession(opts) {
19059
19063
  const sessionName = opts.sessionName || `nax-${Date.now()}`;
19060
- const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
19064
+ const cmd = ["acpx", "--cwd", this.cwd, opts.agentName, "sessions", "ensure", "--name", sessionName];
19061
19065
  getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
19062
19066
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
19063
19067
  const exitCode = await proc.exited;
@@ -19076,7 +19080,7 @@ class SpawnAcpClient {
19076
19080
  });
19077
19081
  }
19078
19082
  async loadSession(sessionName) {
19079
- const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
19083
+ const cmd = ["acpx", "--cwd", this.cwd, this.agentName, "sessions", "ensure", "--name", sessionName];
19080
19084
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
19081
19085
  const exitCode = await proc.exited;
19082
19086
  if (exitCode !== 0) {
@@ -19360,7 +19364,7 @@ class AcpAgentAdapter {
19360
19364
  sessionName = await readAcpSession(options.workdir, options.featureName, options.storyId) ?? undefined;
19361
19365
  }
19362
19366
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
19363
- const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "default";
19367
+ const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
19364
19368
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
19365
19369
  if (options.featureName && options.storyId) {
19366
19370
  await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
@@ -19371,7 +19375,7 @@ class AcpAgentAdapter {
19371
19375
  try {
19372
19376
  let currentPrompt = options.prompt;
19373
19377
  let turnCount = 0;
19374
- const MAX_TURNS = options.interactionBridge ? 10 : 1;
19378
+ const MAX_TURNS = options.interactionBridge ? options.maxInteractionTurns ?? 10 : 1;
19375
19379
  while (turnCount < MAX_TURNS) {
19376
19380
  turnCount++;
19377
19381
  getSafeLogger()?.debug("acp-adapter", `Session turn ${turnCount}/${MAX_TURNS}`, { sessionName });
@@ -19466,10 +19470,17 @@ class AcpAgentAdapter {
19466
19470
  }
19467
19471
  const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
19468
19472
  `).trim();
19469
- if (!text) {
19473
+ let unwrapped = text;
19474
+ try {
19475
+ const envelope = JSON.parse(text);
19476
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
19477
+ unwrapped = envelope.result;
19478
+ }
19479
+ } catch {}
19480
+ if (!unwrapped) {
19470
19481
  throw new CompleteError("complete() returned empty output");
19471
19482
  }
19472
- return text;
19483
+ return unwrapped;
19473
19484
  } catch (err) {
19474
19485
  const error48 = err instanceof Error ? err : new Error(String(err));
19475
19486
  lastError = error48;
@@ -19492,7 +19503,19 @@ class AcpAgentAdapter {
19492
19503
  throw lastError ?? new CompleteError("complete() failed with unknown error");
19493
19504
  }
19494
19505
  async plan(options) {
19495
- const modelDef = options.modelDef ?? { provider: "anthropic", model: "default" };
19506
+ let modelDef = options.modelDef;
19507
+ if (!modelDef && options.config?.models) {
19508
+ const tier = options.modelTier ?? "balanced";
19509
+ const { resolveModel: resolveModel2 } = await Promise.resolve().then(() => (init_schema(), exports_schema));
19510
+ const models = options.config.models;
19511
+ const entry = models[tier] ?? models.balanced;
19512
+ if (entry) {
19513
+ try {
19514
+ modelDef = resolveModel2(entry);
19515
+ } catch {}
19516
+ }
19517
+ }
19518
+ modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
19496
19519
  const timeoutSeconds = options.timeoutSeconds ?? options.config?.execution?.sessionTimeoutSeconds ?? 600;
19497
19520
  const result = await this.run({
19498
19521
  prompt: options.prompt,
@@ -19502,6 +19525,7 @@ class AcpAgentAdapter {
19502
19525
  timeoutSeconds,
19503
19526
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19504
19527
  interactionBridge: options.interactionBridge,
19528
+ maxInteractionTurns: options.maxInteractionTurns,
19505
19529
  featureName: options.featureName,
19506
19530
  storyId: options.storyId,
19507
19531
  sessionRole: options.sessionRole
@@ -21835,7 +21859,7 @@ var package_default;
21835
21859
  var init_package = __esm(() => {
21836
21860
  package_default = {
21837
21861
  name: "@nathapp/nax",
21838
- version: "0.42.1",
21862
+ version: "0.42.3",
21839
21863
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21840
21864
  type: "module",
21841
21865
  bin: {
@@ -21889,7 +21913,15 @@ var init_package = __esm(() => {
21889
21913
  "bin/",
21890
21914
  "README.md",
21891
21915
  "CHANGELOG.md"
21892
- ]
21916
+ ],
21917
+ repository: {
21918
+ type: "git",
21919
+ url: "https://github.com/nathapp-io/nax.git"
21920
+ },
21921
+ homepage: "https://github.com/nathapp-io/nax",
21922
+ bugs: {
21923
+ url: "https://github.com/nathapp-io/nax/issues"
21924
+ }
21893
21925
  };
21894
21926
  });
21895
21927
 
@@ -21900,8 +21932,8 @@ var init_version = __esm(() => {
21900
21932
  NAX_VERSION = package_default.version;
21901
21933
  NAX_COMMIT = (() => {
21902
21934
  try {
21903
- if (/^[0-9a-f]{6,10}$/.test("29c340c"))
21904
- return "29c340c";
21935
+ if (/^[0-9a-f]{6,10}$/.test("b051dcb"))
21936
+ return "b051dcb";
21905
21937
  } catch {}
21906
21938
  try {
21907
21939
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -26222,6 +26254,7 @@ async function runRectificationLoop(story, config2, workdir, agent, implementerT
26222
26254
  modelDef: resolveModel(config2.models[implementerTier]),
26223
26255
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26224
26256
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26257
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26225
26258
  featureName,
26226
26259
  storyId: story.id,
26227
26260
  sessionRole: "implementer"
@@ -26829,6 +26862,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
26829
26862
  modelDef: resolveModel(config2.models[modelTier]),
26830
26863
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26831
26864
  dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26865
+ maxInteractionTurns: config2.agent?.maxInteractionTurns,
26832
26866
  featureName,
26833
26867
  storyId: story.id,
26834
26868
  sessionRole: role
@@ -27578,6 +27612,7 @@ Category: ${tddResult.failureCategory ?? "unknown"}`,
27578
27612
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
27579
27613
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
27580
27614
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
27615
+ maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
27581
27616
  pidRegistry: ctx.pidRegistry,
27582
27617
  featureName: ctx.prd.feature,
27583
27618
  storyId: ctx.story.id,
@@ -28126,7 +28161,8 @@ ${rectificationPrompt}`;
28126
28161
  modelTier,
28127
28162
  modelDef,
28128
28163
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
28129
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions
28164
+ dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
28165
+ maxInteractionTurns: config2.agent?.maxInteractionTurns
28130
28166
  });
28131
28167
  if (agentResult.success) {
28132
28168
  logger?.info("rectification", `Agent ${label} session complete`, {
@@ -28628,6 +28664,9 @@ function buildScopedCommand(testFiles, baseCommand, testScopedTemplate) {
28628
28664
  }
28629
28665
  return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
28630
28666
  }
28667
+ function isMonorepoOrchestratorCommand(command) {
28668
+ return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
28669
+ }
28631
28670
 
28632
28671
  class ScopedStrategy {
28633
28672
  name = "scoped";
@@ -28635,9 +28674,10 @@ class ScopedStrategy {
28635
28674
  const logger = getLogger();
28636
28675
  const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
28637
28676
  const regressionMode = ctx.regressionMode ?? "deferred";
28677
+ const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
28638
28678
  let effectiveCommand = ctx.testCommand;
28639
28679
  let isFullSuite = true;
28640
- if (smartCfg.enabled && ctx.storyGitRef) {
28680
+ if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
28641
28681
  const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
28642
28682
  const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
28643
28683
  if (pass1Files.length > 0) {
@@ -28657,14 +28697,19 @@ class ScopedStrategy {
28657
28697
  }
28658
28698
  }
28659
28699
  }
28660
- if (isFullSuite && regressionMode === "deferred") {
28700
+ if (isFullSuite && regressionMode === "deferred" && !isMonorepoOrchestrator) {
28661
28701
  logger.info("verify[scoped]", "No mapped tests \u2014 deferring to run-end (mode: deferred)", {
28662
28702
  storyId: ctx.storyId
28663
28703
  });
28664
28704
  return makeSkippedResult(ctx.storyId, "scoped");
28665
28705
  }
28666
- if (isFullSuite) {
28706
+ if (isFullSuite && !isMonorepoOrchestrator) {
28667
28707
  logger.info("verify[scoped]", "No mapped tests \u2014 falling back to full suite", { storyId: ctx.storyId });
28708
+ } else if (isMonorepoOrchestrator) {
28709
+ logger.info("verify[scoped]", "Monorepo orchestrator detected \u2014 delegating scoping to tool", {
28710
+ storyId: ctx.storyId,
28711
+ command: effectiveCommand
28712
+ });
28668
28713
  }
28669
28714
  const start = Date.now();
28670
28715
  const result = await _scopedDeps.regression({
@@ -65702,13 +65747,14 @@ var _deps2 = {
65702
65747
  readFile: (path) => Bun.file(path).text(),
65703
65748
  writeFile: (path, content) => Bun.write(path, content).then(() => {}),
65704
65749
  scanCodebase: (workdir) => scanCodebase(workdir),
65705
- getAgent: (name) => getAgent(name),
65750
+ getAgent: (name, cfg) => cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
65706
65751
  readPackageJson: (workdir) => Bun.file(join10(workdir, "package.json")).json().catch(() => null),
65707
65752
  spawnSync: (cmd, opts) => {
65708
65753
  const result = Bun.spawnSync(cmd, opts ? { cwd: opts.cwd } : {});
65709
65754
  return { stdout: result.stdout, exitCode: result.exitCode };
65710
65755
  },
65711
- mkdirp: (path) => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {})
65756
+ mkdirp: (path) => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
65757
+ existsSync: (path) => existsSync9(path)
65712
65758
  };
65713
65759
  async function planCommand(workdir, config2, options) {
65714
65760
  const naxDir = join10(workdir, "nax");
@@ -65724,37 +65770,53 @@ async function planCommand(workdir, config2, options) {
65724
65770
  const pkg = await _deps2.readPackageJson(workdir);
65725
65771
  const projectName = detectProjectName(workdir, pkg);
65726
65772
  const branchName = options.branch ?? `feat/${options.feature}`;
65727
- const prompt = buildPlanningPrompt(specContent, codebaseContext);
65773
+ const outputDir = join10(naxDir, "features", options.feature);
65774
+ const outputPath = join10(outputDir, "prd.json");
65775
+ await _deps2.mkdirp(outputDir);
65728
65776
  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
65777
  const timeoutSeconds = config2?.execution?.sessionTimeoutSeconds ?? 600;
65734
65778
  let rawResponse;
65735
65779
  if (options.auto) {
65736
- rawResponse = await adapter.complete(prompt, { jsonMode: true });
65780
+ const prompt = buildPlanningPrompt(specContent, codebaseContext);
65781
+ const cliAdapter = _deps2.getAgent(agentName);
65782
+ if (!cliAdapter)
65783
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65784
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
65785
+ try {
65786
+ const envelope = JSON.parse(rawResponse);
65787
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
65788
+ rawResponse = envelope.result;
65789
+ }
65790
+ } catch {}
65737
65791
  } else {
65792
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath);
65793
+ const adapter = _deps2.getAgent(agentName, config2);
65794
+ if (!adapter)
65795
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65738
65796
  const interactionBridge = createCliInteractionBridge();
65739
65797
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
65740
65798
  try {
65741
- const result = await adapter.plan({
65799
+ await adapter.plan({
65742
65800
  prompt,
65743
65801
  workdir,
65744
65802
  interactive: true,
65745
65803
  timeoutSeconds,
65746
- interactionBridge
65804
+ interactionBridge,
65805
+ config: config2,
65806
+ modelTier: config2?.plan?.model ?? "balanced",
65807
+ dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
65808
+ maxInteractionTurns: config2?.agent?.maxInteractionTurns
65747
65809
  });
65748
- rawResponse = result.specContent;
65749
65810
  } finally {
65750
65811
  logger?.info("plan", "Interactive session ended");
65751
65812
  }
65813
+ if (!_deps2.existsSync(outputPath)) {
65814
+ throw new Error(`[plan] Agent did not write PRD to ${outputPath}. Check agent logs for errors.`);
65815
+ }
65816
+ rawResponse = await _deps2.readFile(outputPath);
65752
65817
  }
65753
65818
  const finalPrd = validatePlanOutput(rawResponse, options.feature, branchName);
65754
65819
  finalPrd.project = projectName;
65755
- const outputDir = join10(naxDir, "features", options.feature);
65756
- const outputPath = join10(outputDir, "prd.json");
65757
- await _deps2.mkdirp(outputDir);
65758
65820
  await _deps2.writeFile(outputPath, JSON.stringify(finalPrd, null, 2));
65759
65821
  logger?.info("plan", "[OK] PRD written", { outputPath });
65760
65822
  return outputPath;
@@ -65821,7 +65883,7 @@ function buildCodebaseContext2(scan) {
65821
65883
  return sections.join(`
65822
65884
  `);
65823
65885
  }
65824
- function buildPlanningPrompt(specContent, codebaseContext) {
65886
+ function buildPlanningPrompt(specContent, codebaseContext, outputFilePath) {
65825
65887
  return `You are a senior software architect generating a product requirements document (PRD) as JSON.
65826
65888
 
65827
65889
  ## Spec
@@ -65877,7 +65939,8 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
65877
65939
  - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
65878
65940
  - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
65879
65941
 
65880
- Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
65942
+ ${outputFilePath ? `Write the PRD JSON directly to this file path: ${outputFilePath}
65943
+ Do NOT output the JSON to the conversation. Write the file, then reply with a brief confirmation.` : "Output ONLY the JSON object. Do not wrap in markdown code blocks."}`;
65881
65944
  }
65882
65945
  // src/cli/accept.ts
65883
65946
  init_config();
@@ -67848,7 +67911,10 @@ var FIELD_DESCRIPTIONS = {
67848
67911
  "decompose.maxSubstories": "Max number of substories to generate (default: 5)",
67849
67912
  "decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
67850
67913
  "decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
67851
- "decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')"
67914
+ "decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
67915
+ agent: "Agent protocol configuration (ACP-003)",
67916
+ "agent.protocol": "Protocol for agent communication: 'acp' | 'cli' (default: 'acp')",
67917
+ "agent.maxInteractionTurns": "Max turns in multi-turn interaction loop when interactionBridge is active (default: 10)"
67852
67918
  };
67853
67919
 
67854
67920
  // src/cli/config-diff.ts
@@ -76600,8 +76666,8 @@ program2.command("run").description("Run the orchestration loop for a feature").
76600
76666
  } else if (options.quiet || options.silent) {
76601
76667
  formatterMode = "quiet";
76602
76668
  }
76603
- const config2 = await loadConfig();
76604
76669
  const naxDir = findProjectDir(workdir);
76670
+ const config2 = await loadConfig(naxDir ?? undefined);
76605
76671
  if (!naxDir) {
76606
76672
  console.error(source_default.red("nax not initialized. Run: nax init"));
76607
76673
  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.3",
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,22 @@ 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[tier] > config.models.balanced > fallback
640
+ let modelDef = options.modelDef;
641
+ if (!modelDef && options.config?.models) {
642
+ const tier = options.modelTier ?? "balanced";
643
+ const { resolveModel } = await import("../../config/schema");
644
+ const models = options.config.models as Record<string, unknown>;
645
+ const entry = models[tier] ?? models.balanced;
646
+ if (entry) {
647
+ try {
648
+ modelDef = resolveModel(entry as Parameters<typeof resolveModel>[0]);
649
+ } catch {
650
+ // resolveModel can throw on malformed entries
651
+ }
652
+ }
653
+ }
654
+ modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
627
655
  // Timeout: from options, or config, or fallback to 600s
628
656
  const timeoutSeconds =
629
657
  options.timeoutSeconds ?? (options.config?.execution?.sessionTimeoutSeconds as number | undefined) ?? 600;
@@ -636,6 +664,7 @@ export class AcpAgentAdapter implements AgentAdapter {
636
664
  timeoutSeconds,
637
665
  dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
638
666
  interactionBridge: options.interactionBridge,
667
+ maxInteractionTurns: options.maxInteractionTurns,
639
668
  featureName: options.featureName,
640
669
  storyId: options.storyId,
641
670
  sessionRole: options.sessionRole,
@@ -121,8 +121,6 @@ class SpawnAcpSession implements AcpSession {
121
121
  "--cwd",
122
122
  this.cwd,
123
123
  ...(this.permissionMode === "approve-all" ? ["--approve-all"] : []),
124
- "--format",
125
- "json",
126
124
  "--model",
127
125
  this.model,
128
126
  "--timeout",
@@ -247,7 +245,7 @@ export class SpawnAcpClient implements AcpClient {
247
245
  const sessionName = opts.sessionName || `nax-${Date.now()}`;
248
246
 
249
247
  // Ensure session exists via CLI
250
- const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
248
+ const cmd = ["acpx", "--cwd", this.cwd, opts.agentName, "sessions", "ensure", "--name", sessionName];
251
249
  getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
252
250
 
253
251
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
@@ -271,7 +269,7 @@ export class SpawnAcpClient implements AcpClient {
271
269
 
272
270
  async loadSession(sessionName: string): Promise<AcpSession | null> {
273
271
  // Try to ensure session exists — if it does, acpx returns success
274
- const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
272
+ const cmd = ["acpx", "--cwd", this.cwd, this.agentName, "sessions", "ensure", "--name", sessionName];
275
273
 
276
274
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
277
275
  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()
@@ -36,6 +37,7 @@ export const _deps = {
36
37
  return { stdout: result.stdout as Buffer, exitCode: result.exitCode };
37
38
  },
38
39
  mkdirp: (path: string): Promise<void> => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
40
+ existsSync: (path: string): boolean => existsSync(path),
39
41
  };
40
42
 
41
43
  // ─────────────────────────────────────────────────────────────────────────────
@@ -87,16 +89,13 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
87
89
  const pkg = await _deps.readPackageJson(workdir);
88
90
  const projectName = detectProjectName(workdir, pkg);
89
91
 
90
- // Build prompt
92
+ // Compute output path early — needed for interactive file-write prompt
91
93
  const branchName = options.branch ?? `feat/${options.feature}`;
92
- const prompt = buildPlanningPrompt(specContent, codebaseContext);
94
+ const outputDir = join(naxDir, "features", options.feature);
95
+ const outputPath = join(outputDir, "prd.json");
96
+ await _deps.mkdirp(outputDir);
93
97
 
94
- // Get agent adapter
95
98
  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
99
 
101
100
  // Timeout: from config, or default to 600 seconds (10 min)
102
101
  const timeoutSeconds = config?.execution?.sessionTimeoutSeconds ?? 600;
@@ -104,22 +103,47 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
104
103
  // Route to auto (one-shot) or interactive (multi-turn) mode
105
104
  let rawResponse: string;
106
105
  if (options.auto) {
107
- rawResponse = await adapter.complete(prompt, { jsonMode: true });
106
+ // One-shot: use CLI adapter directly simple completion doesn't need ACP session overhead
107
+ const prompt = buildPlanningPrompt(specContent, codebaseContext);
108
+ const cliAdapter = _deps.getAgent(agentName);
109
+ if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
110
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
111
+ // CLI adapter returns {"type":"result","result":"..."} envelope — unwrap it
112
+ try {
113
+ const envelope = JSON.parse(rawResponse) as Record<string, unknown>;
114
+ if (envelope?.type === "result" && typeof envelope?.result === "string") {
115
+ rawResponse = envelope.result;
116
+ }
117
+ } catch {
118
+ // Not an envelope — use rawResponse as-is
119
+ }
108
120
  } else {
121
+ // Interactive: agent writes PRD JSON directly to outputPath (avoids output truncation)
122
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath);
123
+ const adapter = _deps.getAgent(agentName, config);
124
+ if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
109
125
  const interactionBridge = createCliInteractionBridge();
110
126
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
111
127
  try {
112
- const result = await adapter.plan({
128
+ await adapter.plan({
113
129
  prompt,
114
130
  workdir,
115
131
  interactive: true,
116
132
  timeoutSeconds,
117
133
  interactionBridge,
134
+ config,
135
+ modelTier: config?.plan?.model ?? "balanced",
136
+ dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
137
+ maxInteractionTurns: config?.agent?.maxInteractionTurns,
118
138
  });
119
- rawResponse = result.specContent;
120
139
  } finally {
121
140
  logger?.info("plan", "Interactive session ended");
122
141
  }
142
+ // Read back from file written by agent
143
+ if (!_deps.existsSync(outputPath)) {
144
+ throw new Error(`[plan] Agent did not write PRD to ${outputPath}. Check agent logs for errors.`);
145
+ }
146
+ rawResponse = await _deps.readFile(outputPath);
123
147
  }
124
148
 
125
149
  // Validate and normalize: handles markdown extraction, trailing commas, LLM quirks,
@@ -129,10 +153,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
129
153
  // Override project with auto-detected name (validatePlanOutput fills feature/branchName already)
130
154
  finalPrd.project = projectName;
131
155
 
132
- // Write output
133
- const outputDir = join(naxDir, "features", options.feature);
134
- const outputPath = join(outputDir, "prd.json");
135
- await _deps.mkdirp(outputDir);
156
+ // Write normalized PRD (overwrites agent-written file with validated/normalized version)
136
157
  await _deps.writeFile(outputPath, JSON.stringify(finalPrd, null, 2));
137
158
 
138
159
  logger?.info("plan", "[OK] PRD written", { outputPath });
@@ -241,7 +262,7 @@ function buildCodebaseContext(scan: CodebaseScan): string {
241
262
  * - Complexity classification guide
242
263
  * - Test strategy guide
243
264
  */
244
- function buildPlanningPrompt(specContent: string, codebaseContext: string): string {
265
+ function buildPlanningPrompt(specContent: string, codebaseContext: string, outputFilePath?: string): string {
245
266
  return `You are a senior software architect generating a product requirements document (PRD) as JSON.
246
267
 
247
268
  ## Spec
@@ -297,5 +318,9 @@ Generate a JSON object with this exact structure (no markdown, no explanation
297
318
  - three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
298
319
  - three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
299
320
 
300
- Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
321
+ ${
322
+ outputFilePath
323
+ ? `Write the PRD JSON directly to this file path: ${outputFilePath}\nDo NOT output the JSON to the conversation. Write the file, then reply with a brief confirmation.`
324
+ : "Output ONLY the JSON object. Do not wrap in markdown code blocks."
325
+ }`;
301
326
  }
@@ -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();