@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 +21 -0
- package/bin/nax.ts +1 -1
- package/dist/nax.js +76 -26
- package/package.json +11 -3
- package/src/agents/acp/adapter.ts +30 -6
- package/src/agents/acp/spawn-client.ts +2 -2
- package/src/agents/types-extended.ts +2 -0
- package/src/agents/types.ts +2 -0
- package/src/cli/config-descriptions.ts +6 -0
- package/src/cli/init-detect.ts +49 -1
- package/src/cli/init.ts +12 -2
- package/src/cli/plan.ts +23 -8
- package/src/config/defaults.ts +0 -1
- package/src/config/runtime-types.ts +2 -4
- package/src/config/schemas.ts +1 -1
- package/src/pipeline/stages/execution.ts +1 -0
- package/src/tdd/rectification-gate.ts +1 -0
- package/src/tdd/session-runner.ts +1 -0
- package/src/verification/rectification-loop.ts +1 -0
- package/src/verification/strategies/scoped.ts +28 -4
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
|
-
|
|
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" : "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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("
|
|
21904
|
-
return "
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "AI Coding Agent Orchestrator
|
|
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" : "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/agents/types.ts
CHANGED
|
@@ -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
|
};
|
package/src/cli/init-detect.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
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 {
|
package/src/config/defaults.ts
CHANGED
|
@@ -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
|
-
/**
|
|
477
|
-
|
|
474
|
+
/** Max interaction turns when interactionBridge is active (default: 10) */
|
|
475
|
+
maxInteractionTurns?: number;
|
|
478
476
|
}
|
package/src/config/schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|