@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 +21 -0
- package/bin/nax.ts +1 -1
- package/dist/nax.js +103 -37
- package/package.json +11 -3
- package/src/agents/acp/adapter.ts +35 -6
- package/src/agents/acp/spawn-client.ts +2 -4
- 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 +43 -18
- 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: [
|
|
@@ -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" : "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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("
|
|
21904
|
-
return "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "AI Coding Agent Orchestrator
|
|
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" : "
|
|
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,22 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
623
636
|
}
|
|
624
637
|
|
|
625
638
|
async plan(options: PlanOptions): Promise<PlanResult> {
|
|
626
|
-
|
|
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.
|
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()
|
|
@@ -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
|
-
//
|
|
92
|
+
// Compute output path early — needed for interactive file-write prompt
|
|
91
93
|
const branchName = options.branch ?? `feat/${options.feature}`;
|
|
92
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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();
|