@nathapp/nax 0.42.0 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/nax.ts +6 -4
- package/dist/nax.js +164 -57
- package/package.json +11 -3
- package/src/agents/acp/adapter.ts +88 -29
- package/src/agents/acp/spawn-client.ts +2 -2
- package/src/agents/types-extended.ts +2 -0
- package/src/agents/types.ts +8 -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 +47 -19
- 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/acceptance.ts +21 -6
- package/src/pipeline/stages/execution.ts +1 -0
- package/src/prd/schema.ts +6 -2
- 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
|
@@ -277,6 +277,7 @@ program
|
|
|
277
277
|
.option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)")
|
|
278
278
|
.option("--plan", "Run plan phase first before execution", false)
|
|
279
279
|
.option("--from <spec-path>", "Path to spec file (required when --plan is used)")
|
|
280
|
+
.option("--one-shot", "Skip interactive planning Q&A, use single LLM call (ACP only)", false)
|
|
280
281
|
.option("--headless", "Force headless mode (disable TUI, use pipe mode)", false)
|
|
281
282
|
.option("--verbose", "Enable verbose logging (debug level)", false)
|
|
282
283
|
.option("--quiet", "Quiet mode (warnings and errors only)", false)
|
|
@@ -329,8 +330,8 @@ program
|
|
|
329
330
|
formatterMode = "quiet";
|
|
330
331
|
}
|
|
331
332
|
|
|
332
|
-
const config = await loadConfig();
|
|
333
333
|
const naxDir = findProjectDir(workdir);
|
|
334
|
+
const config = await loadConfig(naxDir ?? undefined);
|
|
334
335
|
|
|
335
336
|
if (!naxDir) {
|
|
336
337
|
console.error(chalk.red("nax not initialized. Run: nax init"));
|
|
@@ -347,7 +348,7 @@ program
|
|
|
347
348
|
const generatedPrdPath = await planCommand(workdir, config, {
|
|
348
349
|
from: options.from,
|
|
349
350
|
feature: options.feature,
|
|
350
|
-
auto:
|
|
351
|
+
auto: options.oneShot ?? false, // interactive by default; --one-shot skips Q&A
|
|
351
352
|
branch: undefined,
|
|
352
353
|
});
|
|
353
354
|
|
|
@@ -625,7 +626,8 @@ program
|
|
|
625
626
|
.description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')")
|
|
626
627
|
.requiredOption("--from <spec-path>", "Path to spec file (required)")
|
|
627
628
|
.requiredOption("-f, --feature <name>", "Feature name (required)")
|
|
628
|
-
.option("--auto", "Run in
|
|
629
|
+
.option("--auto", "Run in one-shot LLM mode (alias: --one-shot)", false)
|
|
630
|
+
.option("--one-shot", "Run in one-shot LLM mode (alias: --auto)", false)
|
|
629
631
|
.option("-b, --branch <branch>", "Override default branch name")
|
|
630
632
|
.option("-d, --dir <path>", "Project directory", process.cwd())
|
|
631
633
|
.action(async (description, options) => {
|
|
@@ -658,7 +660,7 @@ program
|
|
|
658
660
|
const prdPath = await planCommand(workdir, config, {
|
|
659
661
|
from: options.from,
|
|
660
662
|
feature: options.feature,
|
|
661
|
-
auto: options.auto,
|
|
663
|
+
auto: options.auto || options.oneShot, // --auto and --one-shot are aliases
|
|
662
664
|
branch: options.branch,
|
|
663
665
|
});
|
|
664
666
|
|
package/dist/nax.js
CHANGED
|
@@ -17804,7 +17804,7 @@ var init_schemas3 = __esm(() => {
|
|
|
17804
17804
|
});
|
|
17805
17805
|
AgentConfigSchema = exports_external.object({
|
|
17806
17806
|
protocol: exports_external.enum(["acp", "cli"]).default("acp"),
|
|
17807
|
-
|
|
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 });
|
|
@@ -19439,32 +19438,74 @@ class AcpAgentAdapter {
|
|
|
19439
19438
|
}
|
|
19440
19439
|
async complete(prompt, _options) {
|
|
19441
19440
|
const model = _options?.model ?? "default";
|
|
19442
|
-
const
|
|
19443
|
-
const client = _acpAdapterDeps.createClient(cmdStr);
|
|
19444
|
-
await client.start();
|
|
19441
|
+
const timeoutMs = _options?.timeoutMs ?? 120000;
|
|
19445
19442
|
const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
|
|
19446
|
-
let
|
|
19447
|
-
|
|
19448
|
-
|
|
19449
|
-
const
|
|
19450
|
-
|
|
19451
|
-
|
|
19452
|
-
|
|
19453
|
-
|
|
19443
|
+
let lastError;
|
|
19444
|
+
for (let attempt = 0;attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
|
|
19445
|
+
const cmdStr = `acpx --model ${model} ${this.name}`;
|
|
19446
|
+
const client = _acpAdapterDeps.createClient(cmdStr);
|
|
19447
|
+
await client.start();
|
|
19448
|
+
let session = null;
|
|
19449
|
+
try {
|
|
19450
|
+
session = await client.createSession({ agentName: this.name, permissionMode });
|
|
19451
|
+
let timeoutId;
|
|
19452
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
19453
|
+
timeoutId = setTimeout(() => reject(new Error(`complete() timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
19454
|
+
});
|
|
19455
|
+
timeoutPromise.catch(() => {});
|
|
19456
|
+
const promptPromise = session.prompt(prompt);
|
|
19457
|
+
let response;
|
|
19458
|
+
try {
|
|
19459
|
+
response = await Promise.race([promptPromise, timeoutPromise]);
|
|
19460
|
+
} finally {
|
|
19461
|
+
clearTimeout(timeoutId);
|
|
19462
|
+
}
|
|
19463
|
+
if (response.stopReason === "error") {
|
|
19464
|
+
throw new CompleteError("complete() failed: stop reason is error");
|
|
19465
|
+
}
|
|
19466
|
+
const text = response.messages.filter((m) => m.role === "assistant").map((m) => m.content).join(`
|
|
19454
19467
|
`).trim();
|
|
19455
|
-
|
|
19456
|
-
|
|
19457
|
-
|
|
19458
|
-
|
|
19459
|
-
|
|
19460
|
-
|
|
19461
|
-
|
|
19468
|
+
let unwrapped = text;
|
|
19469
|
+
try {
|
|
19470
|
+
const envelope = JSON.parse(text);
|
|
19471
|
+
if (envelope?.type === "result" && typeof envelope?.result === "string") {
|
|
19472
|
+
unwrapped = envelope.result;
|
|
19473
|
+
}
|
|
19474
|
+
} catch {}
|
|
19475
|
+
if (!unwrapped) {
|
|
19476
|
+
throw new CompleteError("complete() returned empty output");
|
|
19477
|
+
}
|
|
19478
|
+
return unwrapped;
|
|
19479
|
+
} catch (err) {
|
|
19480
|
+
const error48 = err instanceof Error ? err : new Error(String(err));
|
|
19481
|
+
lastError = error48;
|
|
19482
|
+
const shouldRetry = isRateLimitError(error48) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
|
|
19483
|
+
if (!shouldRetry)
|
|
19484
|
+
throw error48;
|
|
19485
|
+
const backoffMs = 2 ** (attempt + 1) * 1000;
|
|
19486
|
+
getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
|
|
19487
|
+
backoffSeconds: backoffMs / 1000,
|
|
19488
|
+
attempt: attempt + 1
|
|
19489
|
+
});
|
|
19490
|
+
await _acpAdapterDeps.sleep(backoffMs);
|
|
19491
|
+
} finally {
|
|
19492
|
+
if (session) {
|
|
19493
|
+
await session.close().catch(() => {});
|
|
19494
|
+
}
|
|
19495
|
+
await client.close().catch(() => {});
|
|
19462
19496
|
}
|
|
19463
|
-
await client.close().catch(() => {});
|
|
19464
19497
|
}
|
|
19498
|
+
throw lastError ?? new CompleteError("complete() failed with unknown error");
|
|
19465
19499
|
}
|
|
19466
19500
|
async plan(options) {
|
|
19467
|
-
|
|
19501
|
+
let modelDef = options.modelDef;
|
|
19502
|
+
if (!modelDef && options.config?.models) {
|
|
19503
|
+
const { resolveBalancedModelDef: resolveBalancedModelDef2 } = await Promise.resolve().then(() => (init_model_resolution(), exports_model_resolution));
|
|
19504
|
+
try {
|
|
19505
|
+
modelDef = resolveBalancedModelDef2(options.config);
|
|
19506
|
+
} catch {}
|
|
19507
|
+
}
|
|
19508
|
+
modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
|
|
19468
19509
|
const timeoutSeconds = options.timeoutSeconds ?? options.config?.execution?.sessionTimeoutSeconds ?? 600;
|
|
19469
19510
|
const result = await this.run({
|
|
19470
19511
|
prompt: options.prompt,
|
|
@@ -19474,6 +19515,7 @@ class AcpAgentAdapter {
|
|
|
19474
19515
|
timeoutSeconds,
|
|
19475
19516
|
dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
|
|
19476
19517
|
interactionBridge: options.interactionBridge,
|
|
19518
|
+
maxInteractionTurns: options.maxInteractionTurns,
|
|
19477
19519
|
featureName: options.featureName,
|
|
19478
19520
|
storyId: options.storyId,
|
|
19479
19521
|
sessionRole: options.sessionRole
|
|
@@ -21807,7 +21849,7 @@ var package_default;
|
|
|
21807
21849
|
var init_package = __esm(() => {
|
|
21808
21850
|
package_default = {
|
|
21809
21851
|
name: "@nathapp/nax",
|
|
21810
|
-
version: "0.42.
|
|
21852
|
+
version: "0.42.2",
|
|
21811
21853
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
21812
21854
|
type: "module",
|
|
21813
21855
|
bin: {
|
|
@@ -21861,7 +21903,15 @@ var init_package = __esm(() => {
|
|
|
21861
21903
|
"bin/",
|
|
21862
21904
|
"README.md",
|
|
21863
21905
|
"CHANGELOG.md"
|
|
21864
|
-
]
|
|
21906
|
+
],
|
|
21907
|
+
repository: {
|
|
21908
|
+
type: "git",
|
|
21909
|
+
url: "https://github.com/nathapp-io/nax.git"
|
|
21910
|
+
},
|
|
21911
|
+
homepage: "https://github.com/nathapp-io/nax",
|
|
21912
|
+
bugs: {
|
|
21913
|
+
url: "https://github.com/nathapp-io/nax/issues"
|
|
21914
|
+
}
|
|
21865
21915
|
};
|
|
21866
21916
|
});
|
|
21867
21917
|
|
|
@@ -21872,8 +21922,8 @@ var init_version = __esm(() => {
|
|
|
21872
21922
|
NAX_VERSION = package_default.version;
|
|
21873
21923
|
NAX_COMMIT = (() => {
|
|
21874
21924
|
try {
|
|
21875
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
21876
|
-
return "
|
|
21925
|
+
if (/^[0-9a-f]{6,10}$/.test("9c1f716"))
|
|
21926
|
+
return "9c1f716";
|
|
21877
21927
|
} catch {}
|
|
21878
21928
|
try {
|
|
21879
21929
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -23550,11 +23600,23 @@ ${stderr}`;
|
|
|
23550
23600
|
logger.info("acceptance", "All acceptance tests passed");
|
|
23551
23601
|
return { action: "continue" };
|
|
23552
23602
|
}
|
|
23553
|
-
if (
|
|
23554
|
-
logger.
|
|
23603
|
+
if (failedACs.length > 0 && actualFailures.length === 0) {
|
|
23604
|
+
logger.info("acceptance", "All failed ACs are overridden \u2014 treating as pass");
|
|
23605
|
+
return { action: "continue" };
|
|
23606
|
+
}
|
|
23607
|
+
if (failedACs.length === 0 && exitCode !== 0) {
|
|
23608
|
+
logger.error("acceptance", "Tests errored with no AC failures parsed", {
|
|
23609
|
+
exitCode,
|
|
23555
23610
|
output
|
|
23556
23611
|
});
|
|
23557
|
-
|
|
23612
|
+
ctx.acceptanceFailures = {
|
|
23613
|
+
failedACs: ["AC-ERROR"],
|
|
23614
|
+
testOutput: output
|
|
23615
|
+
};
|
|
23616
|
+
return {
|
|
23617
|
+
action: "fail",
|
|
23618
|
+
reason: `Acceptance tests errored (exit code ${exitCode}): syntax error, import failure, or unhandled exception`
|
|
23619
|
+
};
|
|
23558
23620
|
}
|
|
23559
23621
|
if (actualFailures.length > 0) {
|
|
23560
23622
|
const overriddenFailures = failedACs.filter((acId) => overrides[acId]);
|
|
@@ -26182,6 +26244,7 @@ async function runRectificationLoop(story, config2, workdir, agent, implementerT
|
|
|
26182
26244
|
modelDef: resolveModel(config2.models[implementerTier]),
|
|
26183
26245
|
timeoutSeconds: config2.execution.sessionTimeoutSeconds,
|
|
26184
26246
|
dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
|
|
26247
|
+
maxInteractionTurns: config2.agent?.maxInteractionTurns,
|
|
26185
26248
|
featureName,
|
|
26186
26249
|
storyId: story.id,
|
|
26187
26250
|
sessionRole: "implementer"
|
|
@@ -26789,6 +26852,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
|
|
|
26789
26852
|
modelDef: resolveModel(config2.models[modelTier]),
|
|
26790
26853
|
timeoutSeconds: config2.execution.sessionTimeoutSeconds,
|
|
26791
26854
|
dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
|
|
26855
|
+
maxInteractionTurns: config2.agent?.maxInteractionTurns,
|
|
26792
26856
|
featureName,
|
|
26793
26857
|
storyId: story.id,
|
|
26794
26858
|
sessionRole: role
|
|
@@ -27538,6 +27602,7 @@ Category: ${tddResult.failureCategory ?? "unknown"}`,
|
|
|
27538
27602
|
modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
|
|
27539
27603
|
timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
|
|
27540
27604
|
dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
|
|
27605
|
+
maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
|
|
27541
27606
|
pidRegistry: ctx.pidRegistry,
|
|
27542
27607
|
featureName: ctx.prd.feature,
|
|
27543
27608
|
storyId: ctx.story.id,
|
|
@@ -28086,7 +28151,8 @@ ${rectificationPrompt}`;
|
|
|
28086
28151
|
modelTier,
|
|
28087
28152
|
modelDef,
|
|
28088
28153
|
timeoutSeconds: config2.execution.sessionTimeoutSeconds,
|
|
28089
|
-
dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions
|
|
28154
|
+
dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
|
|
28155
|
+
maxInteractionTurns: config2.agent?.maxInteractionTurns
|
|
28090
28156
|
});
|
|
28091
28157
|
if (agentResult.success) {
|
|
28092
28158
|
logger?.info("rectification", `Agent ${label} session complete`, {
|
|
@@ -28588,6 +28654,9 @@ function buildScopedCommand(testFiles, baseCommand, testScopedTemplate) {
|
|
|
28588
28654
|
}
|
|
28589
28655
|
return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
|
|
28590
28656
|
}
|
|
28657
|
+
function isMonorepoOrchestratorCommand(command) {
|
|
28658
|
+
return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
|
|
28659
|
+
}
|
|
28591
28660
|
|
|
28592
28661
|
class ScopedStrategy {
|
|
28593
28662
|
name = "scoped";
|
|
@@ -28595,9 +28664,10 @@ class ScopedStrategy {
|
|
|
28595
28664
|
const logger = getLogger();
|
|
28596
28665
|
const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
|
|
28597
28666
|
const regressionMode = ctx.regressionMode ?? "deferred";
|
|
28667
|
+
const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
|
|
28598
28668
|
let effectiveCommand = ctx.testCommand;
|
|
28599
28669
|
let isFullSuite = true;
|
|
28600
|
-
if (smartCfg.enabled && ctx.storyGitRef) {
|
|
28670
|
+
if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
|
|
28601
28671
|
const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
|
|
28602
28672
|
const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
|
|
28603
28673
|
if (pass1Files.length > 0) {
|
|
@@ -28617,14 +28687,19 @@ class ScopedStrategy {
|
|
|
28617
28687
|
}
|
|
28618
28688
|
}
|
|
28619
28689
|
}
|
|
28620
|
-
if (isFullSuite && regressionMode === "deferred") {
|
|
28690
|
+
if (isFullSuite && regressionMode === "deferred" && !isMonorepoOrchestrator) {
|
|
28621
28691
|
logger.info("verify[scoped]", "No mapped tests \u2014 deferring to run-end (mode: deferred)", {
|
|
28622
28692
|
storyId: ctx.storyId
|
|
28623
28693
|
});
|
|
28624
28694
|
return makeSkippedResult(ctx.storyId, "scoped");
|
|
28625
28695
|
}
|
|
28626
|
-
if (isFullSuite) {
|
|
28696
|
+
if (isFullSuite && !isMonorepoOrchestrator) {
|
|
28627
28697
|
logger.info("verify[scoped]", "No mapped tests \u2014 falling back to full suite", { storyId: ctx.storyId });
|
|
28698
|
+
} else if (isMonorepoOrchestrator) {
|
|
28699
|
+
logger.info("verify[scoped]", "Monorepo orchestrator detected \u2014 delegating scoping to tool", {
|
|
28700
|
+
storyId: ctx.storyId,
|
|
28701
|
+
command: effectiveCommand
|
|
28702
|
+
});
|
|
28628
28703
|
}
|
|
28629
28704
|
const start = Date.now();
|
|
28630
28705
|
const result = await _scopedDeps.regression({
|
|
@@ -65505,6 +65580,7 @@ async function generateAcceptanceTestsForFeature(specContent, featureName, featu
|
|
|
65505
65580
|
init_registry();
|
|
65506
65581
|
import { existsSync as existsSync9 } from "fs";
|
|
65507
65582
|
import { join as join10 } from "path";
|
|
65583
|
+
import { createInterface } from "readline";
|
|
65508
65584
|
init_logger2();
|
|
65509
65585
|
|
|
65510
65586
|
// src/prd/schema.ts
|
|
@@ -65584,7 +65660,9 @@ function validateStory(raw, index, allIds) {
|
|
|
65584
65660
|
throw new Error(`[schema] story[${index}].routing.complexity "${rawComplexity}" is invalid. Valid values: ${VALID_COMPLEXITY.join(", ")}`);
|
|
65585
65661
|
}
|
|
65586
65662
|
const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
|
|
65587
|
-
const
|
|
65663
|
+
const STRATEGY_ALIASES = { "tdd-lite": "three-session-tdd-lite" };
|
|
65664
|
+
const normalizedStrategy = typeof rawTestStrategy === "string" ? STRATEGY_ALIASES[rawTestStrategy] ?? rawTestStrategy : rawTestStrategy;
|
|
65665
|
+
const testStrategy = normalizedStrategy !== undefined && VALID_TEST_STRATEGIES.includes(normalizedStrategy) ? normalizedStrategy : "tdd-simple";
|
|
65588
65666
|
const rawDeps = s.dependencies;
|
|
65589
65667
|
const dependencies = Array.isArray(rawDeps) ? rawDeps : [];
|
|
65590
65668
|
for (const dep of dependencies) {
|
|
@@ -65659,7 +65737,7 @@ var _deps2 = {
|
|
|
65659
65737
|
readFile: (path) => Bun.file(path).text(),
|
|
65660
65738
|
writeFile: (path, content) => Bun.write(path, content).then(() => {}),
|
|
65661
65739
|
scanCodebase: (workdir) => scanCodebase(workdir),
|
|
65662
|
-
getAgent: (name) => getAgent(name),
|
|
65740
|
+
getAgent: (name, cfg) => cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
|
|
65663
65741
|
readPackageJson: (workdir) => Bun.file(join10(workdir, "package.json")).json().catch(() => null),
|
|
65664
65742
|
spawnSync: (cmd, opts) => {
|
|
65665
65743
|
const result = Bun.spawnSync(cmd, opts ? { cwd: opts.cwd } : {});
|
|
@@ -65683,15 +65761,23 @@ async function planCommand(workdir, config2, options) {
|
|
|
65683
65761
|
const branchName = options.branch ?? `feat/${options.feature}`;
|
|
65684
65762
|
const prompt = buildPlanningPrompt(specContent, codebaseContext);
|
|
65685
65763
|
const agentName = config2?.autoMode?.defaultAgent ?? "claude";
|
|
65686
|
-
const adapter = _deps2.getAgent(agentName);
|
|
65687
|
-
if (!adapter) {
|
|
65688
|
-
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
65689
|
-
}
|
|
65690
65764
|
const timeoutSeconds = config2?.execution?.sessionTimeoutSeconds ?? 600;
|
|
65691
65765
|
let rawResponse;
|
|
65692
65766
|
if (options.auto) {
|
|
65693
|
-
|
|
65767
|
+
const cliAdapter = _deps2.getAgent(agentName);
|
|
65768
|
+
if (!cliAdapter)
|
|
65769
|
+
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
65770
|
+
rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
|
|
65771
|
+
try {
|
|
65772
|
+
const envelope = JSON.parse(rawResponse);
|
|
65773
|
+
if (envelope?.type === "result" && typeof envelope?.result === "string") {
|
|
65774
|
+
rawResponse = envelope.result;
|
|
65775
|
+
}
|
|
65776
|
+
} catch {}
|
|
65694
65777
|
} else {
|
|
65778
|
+
const adapter = _deps2.getAgent(agentName, config2);
|
|
65779
|
+
if (!adapter)
|
|
65780
|
+
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
65695
65781
|
const interactionBridge = createCliInteractionBridge();
|
|
65696
65782
|
logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
|
|
65697
65783
|
try {
|
|
@@ -65700,7 +65786,11 @@ async function planCommand(workdir, config2, options) {
|
|
|
65700
65786
|
workdir,
|
|
65701
65787
|
interactive: true,
|
|
65702
65788
|
timeoutSeconds,
|
|
65703
|
-
interactionBridge
|
|
65789
|
+
interactionBridge,
|
|
65790
|
+
config: config2,
|
|
65791
|
+
modelTier: "balanced",
|
|
65792
|
+
dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
|
|
65793
|
+
maxInteractionTurns: config2?.agent?.maxInteractionTurns
|
|
65704
65794
|
});
|
|
65705
65795
|
rawResponse = result.specContent;
|
|
65706
65796
|
} finally {
|
|
@@ -65722,7 +65812,20 @@ function createCliInteractionBridge() {
|
|
|
65722
65812
|
return text.includes("?");
|
|
65723
65813
|
},
|
|
65724
65814
|
async onQuestionDetected(text) {
|
|
65725
|
-
|
|
65815
|
+
if (!process.stdin.isTTY) {
|
|
65816
|
+
return "";
|
|
65817
|
+
}
|
|
65818
|
+
process.stdout.write(`
|
|
65819
|
+
\uD83E\uDD16 Agent: ${text}
|
|
65820
|
+
You: `);
|
|
65821
|
+
return new Promise((resolve6) => {
|
|
65822
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
65823
|
+
rl.once("line", (line) => {
|
|
65824
|
+
rl.close();
|
|
65825
|
+
resolve6(line.trim());
|
|
65826
|
+
});
|
|
65827
|
+
rl.once("close", () => resolve6(""));
|
|
65828
|
+
});
|
|
65726
65829
|
}
|
|
65727
65830
|
};
|
|
65728
65831
|
}
|
|
@@ -65798,7 +65901,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
|
|
|
65798
65901
|
"passes": false,
|
|
65799
65902
|
"routing": {
|
|
65800
65903
|
"complexity": "simple | medium | complex | expert",
|
|
65801
|
-
"testStrategy": "test-after | tdd-
|
|
65904
|
+
"testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
|
|
65802
65905
|
"reasoning": "string \u2014 brief classification rationale"
|
|
65803
65906
|
},
|
|
65804
65907
|
"escalations": [],
|
|
@@ -65810,15 +65913,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation \u
|
|
|
65810
65913
|
## Complexity Classification Guide
|
|
65811
65914
|
|
|
65812
65915
|
- simple: \u226450 LOC, single-file change, purely additive, no new dependencies \u2192 test-after
|
|
65813
|
-
- medium: 50\u2013200 LOC, 2\u20135 files, standard patterns, clear requirements \u2192 tdd-
|
|
65916
|
+
- medium: 50\u2013200 LOC, 2\u20135 files, standard patterns, clear requirements \u2192 tdd-simple
|
|
65814
65917
|
- complex: 200\u2013500 LOC, multiple modules, new abstractions or integrations \u2192 three-session-tdd
|
|
65815
|
-
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk \u2192 three-session-tdd
|
|
65918
|
+
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk \u2192 three-session-tdd-lite
|
|
65816
65919
|
|
|
65817
65920
|
## Test Strategy Guide
|
|
65818
65921
|
|
|
65819
65922
|
- test-after: Simple changes with well-understood behavior. Write tests after implementation.
|
|
65820
|
-
- tdd-
|
|
65821
|
-
- three-session-tdd: Complex
|
|
65923
|
+
- tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
|
|
65924
|
+
- three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
|
|
65925
|
+
- three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
|
|
65822
65926
|
|
|
65823
65927
|
Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
|
|
65824
65928
|
}
|
|
@@ -67791,7 +67895,10 @@ var FIELD_DESCRIPTIONS = {
|
|
|
67791
67895
|
"decompose.maxSubstories": "Max number of substories to generate (default: 5)",
|
|
67792
67896
|
"decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
|
|
67793
67897
|
"decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
|
|
67794
|
-
"decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')"
|
|
67898
|
+
"decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
|
|
67899
|
+
agent: "Agent protocol configuration (ACP-003)",
|
|
67900
|
+
"agent.protocol": "Protocol for agent communication: 'acp' | 'cli' (default: 'acp')",
|
|
67901
|
+
"agent.maxInteractionTurns": "Max turns in multi-turn interaction loop when interactionBridge is active (default: 10)"
|
|
67795
67902
|
};
|
|
67796
67903
|
|
|
67797
67904
|
// src/cli/config-diff.ts
|
|
@@ -76508,7 +76615,7 @@ Run \`nax generate\` to regenerate agent config files (CLAUDE.md, AGENTS.md, .cu
|
|
|
76508
76615
|
console.log(source_default.dim(`
|
|
76509
76616
|
Next: nax features create <name>`));
|
|
76510
76617
|
});
|
|
76511
|
-
program2.command("run").description("Run the orchestration loop for a feature").requiredOption("-f, --feature <name>", "Feature name").option("-a, --agent <name>", "Force a specific agent").option("-m, --max-iterations <n>", "Max iterations", "20").option("--dry-run", "Show plan without executing", false).option("--no-context", "Disable context builder (skip file context in prompts)").option("--no-batch", "Disable story batching (execute all stories individually)").option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)").option("--plan", "Run plan phase first before execution", false).option("--from <spec-path>", "Path to spec file (required when --plan is used)").option("--headless", "Force headless mode (disable TUI, use pipe mode)", false).option("--verbose", "Enable verbose logging (debug level)", false).option("--quiet", "Quiet mode (warnings and errors only)", false).option("--silent", "Silent mode (errors only)", false).option("--json", "JSON mode (raw JSONL output to stdout)", false).option("-d, --dir <path>", "Working directory", process.cwd()).option("--skip-precheck", "Skip precheck validations (advanced users only)", false).action(async (options) => {
|
|
76618
|
+
program2.command("run").description("Run the orchestration loop for a feature").requiredOption("-f, --feature <name>", "Feature name").option("-a, --agent <name>", "Force a specific agent").option("-m, --max-iterations <n>", "Max iterations", "20").option("--dry-run", "Show plan without executing", false).option("--no-context", "Disable context builder (skip file context in prompts)").option("--no-batch", "Disable story batching (execute all stories individually)").option("--parallel <n>", "Max parallel sessions (0=auto, omit=sequential)").option("--plan", "Run plan phase first before execution", false).option("--from <spec-path>", "Path to spec file (required when --plan is used)").option("--one-shot", "Skip interactive planning Q&A, use single LLM call (ACP only)", false).option("--headless", "Force headless mode (disable TUI, use pipe mode)", false).option("--verbose", "Enable verbose logging (debug level)", false).option("--quiet", "Quiet mode (warnings and errors only)", false).option("--silent", "Silent mode (errors only)", false).option("--json", "JSON mode (raw JSONL output to stdout)", false).option("-d, --dir <path>", "Working directory", process.cwd()).option("--skip-precheck", "Skip precheck validations (advanced users only)", false).action(async (options) => {
|
|
76512
76619
|
let workdir;
|
|
76513
76620
|
try {
|
|
76514
76621
|
workdir = validateDirectory(options.dir);
|
|
@@ -76543,8 +76650,8 @@ program2.command("run").description("Run the orchestration loop for a feature").
|
|
|
76543
76650
|
} else if (options.quiet || options.silent) {
|
|
76544
76651
|
formatterMode = "quiet";
|
|
76545
76652
|
}
|
|
76546
|
-
const config2 = await loadConfig();
|
|
76547
76653
|
const naxDir = findProjectDir(workdir);
|
|
76654
|
+
const config2 = await loadConfig(naxDir ?? undefined);
|
|
76548
76655
|
if (!naxDir) {
|
|
76549
76656
|
console.error(source_default.red("nax not initialized. Run: nax init"));
|
|
76550
76657
|
process.exit(1);
|
|
@@ -76557,7 +76664,7 @@ program2.command("run").description("Run the orchestration loop for a feature").
|
|
|
76557
76664
|
const generatedPrdPath = await planCommand(workdir, config2, {
|
|
76558
76665
|
from: options.from,
|
|
76559
76666
|
feature: options.feature,
|
|
76560
|
-
auto:
|
|
76667
|
+
auto: options.oneShot ?? false,
|
|
76561
76668
|
branch: undefined
|
|
76562
76669
|
});
|
|
76563
76670
|
const generatedPrd = await loadPRD(generatedPrdPath);
|
|
@@ -76774,7 +76881,7 @@ Features:
|
|
|
76774
76881
|
}
|
|
76775
76882
|
console.log();
|
|
76776
76883
|
});
|
|
76777
|
-
program2.command("plan [description]").description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')").requiredOption("--from <spec-path>", "Path to spec file (required)").requiredOption("-f, --feature <name>", "Feature name (required)").option("--auto", "Run in
|
|
76884
|
+
program2.command("plan [description]").description("Generate prd.json from a spec file via LLM one-shot call (replaces deprecated 'nax analyze')").requiredOption("--from <spec-path>", "Path to spec file (required)").requiredOption("-f, --feature <name>", "Feature name (required)").option("--auto", "Run in one-shot LLM mode (alias: --one-shot)", false).option("--one-shot", "Run in one-shot LLM mode (alias: --auto)", false).option("-b, --branch <branch>", "Override default branch name").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (description, options) => {
|
|
76778
76885
|
if (description) {
|
|
76779
76886
|
console.error(source_default.red(`Error: Positional args removed in plan v2.
|
|
76780
76887
|
|
|
@@ -76798,7 +76905,7 @@ Use: nax plan -f <feature> --from <spec>`));
|
|
|
76798
76905
|
const prdPath = await planCommand(workdir, config2, {
|
|
76799
76906
|
from: options.from,
|
|
76800
76907
|
feature: options.feature,
|
|
76801
|
-
auto: options.auto,
|
|
76908
|
+
auto: options.auto || options.oneShot,
|
|
76802
76909
|
branch: options.branch
|
|
76803
76910
|
});
|
|
76804
76911
|
console.log(source_default.green(`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.42.
|
|
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++;
|
|
@@ -552,43 +552,101 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
552
552
|
|
|
553
553
|
async complete(prompt: string, _options?: CompleteOptions): Promise<string> {
|
|
554
554
|
const model = _options?.model ?? "default";
|
|
555
|
-
const
|
|
556
|
-
const client = _acpAdapterDeps.createClient(cmdStr);
|
|
557
|
-
await client.start();
|
|
558
|
-
|
|
559
|
-
// complete() is one-shot — ephemeral session, no session name, no sidecar
|
|
555
|
+
const timeoutMs = _options?.timeoutMs ?? 120_000; // 2-min safety net by default
|
|
560
556
|
const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
|
|
561
557
|
|
|
562
|
-
let
|
|
563
|
-
try {
|
|
564
|
-
session = await client.createSession({ agentName: this.name, permissionMode });
|
|
565
|
-
const response = await session.prompt(prompt);
|
|
558
|
+
let lastError: Error | undefined;
|
|
566
559
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
560
|
+
for (let attempt = 0; attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
|
|
561
|
+
const cmdStr = `acpx --model ${model} ${this.name}`;
|
|
562
|
+
const client = _acpAdapterDeps.createClient(cmdStr);
|
|
563
|
+
await client.start();
|
|
570
564
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.
|
|
575
|
-
.trim();
|
|
565
|
+
let session: AcpSession | null = null;
|
|
566
|
+
try {
|
|
567
|
+
// complete() is one-shot — ephemeral session, no session name, no sidecar
|
|
568
|
+
session = await client.createSession({ agentName: this.name, permissionMode });
|
|
576
569
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
570
|
+
// Enforce timeout via Promise.race — session.prompt() can hang indefinitely
|
|
571
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
572
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
573
|
+
timeoutId = setTimeout(() => reject(new Error(`complete() timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
574
|
+
});
|
|
575
|
+
timeoutPromise.catch(() => {}); // prevent unhandled rejection if promptPromise wins
|
|
580
576
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
577
|
+
const promptPromise = session.prompt(prompt);
|
|
578
|
+
|
|
579
|
+
let response: AcpSessionResponse;
|
|
580
|
+
try {
|
|
581
|
+
response = await Promise.race([promptPromise, timeoutPromise]);
|
|
582
|
+
} finally {
|
|
583
|
+
clearTimeout(timeoutId);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (response.stopReason === "error") {
|
|
587
|
+
throw new CompleteError("complete() failed: stop reason is error");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const text = response.messages
|
|
591
|
+
.filter((m) => m.role === "assistant")
|
|
592
|
+
.map((m) => m.content)
|
|
593
|
+
.join("\n")
|
|
594
|
+
.trim();
|
|
595
|
+
|
|
596
|
+
// ACP one-shot sessions wrap the response in a result envelope:
|
|
597
|
+
// {"type":"result","subtype":"success","result":"<actual output>"}
|
|
598
|
+
// Unwrap to return the actual content.
|
|
599
|
+
let unwrapped = text;
|
|
600
|
+
try {
|
|
601
|
+
const envelope = JSON.parse(text) as Record<string, unknown>;
|
|
602
|
+
if (envelope?.type === "result" && typeof envelope?.result === "string") {
|
|
603
|
+
unwrapped = envelope.result;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
// Not an envelope — use text as-is
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!unwrapped) {
|
|
610
|
+
throw new CompleteError("complete() returned empty output");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return unwrapped;
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
616
|
+
lastError = error;
|
|
617
|
+
|
|
618
|
+
const shouldRetry = isRateLimitError(error) && attempt < MAX_RATE_LIMIT_RETRIES - 1;
|
|
619
|
+
if (!shouldRetry) throw error;
|
|
620
|
+
|
|
621
|
+
const backoffMs = 2 ** (attempt + 1) * 1000;
|
|
622
|
+
getSafeLogger()?.warn("acp-adapter", "complete() rate limited, retrying", {
|
|
623
|
+
backoffSeconds: backoffMs / 1000,
|
|
624
|
+
attempt: attempt + 1,
|
|
625
|
+
});
|
|
626
|
+
await _acpAdapterDeps.sleep(backoffMs);
|
|
627
|
+
} finally {
|
|
628
|
+
if (session) {
|
|
629
|
+
await session.close().catch(() => {});
|
|
630
|
+
}
|
|
631
|
+
await client.close().catch(() => {});
|
|
585
632
|
}
|
|
586
|
-
await client.close().catch(() => {});
|
|
587
633
|
}
|
|
634
|
+
|
|
635
|
+
throw lastError ?? new CompleteError("complete() failed with unknown error");
|
|
588
636
|
}
|
|
589
637
|
|
|
590
638
|
async plan(options: PlanOptions): Promise<PlanResult> {
|
|
591
|
-
|
|
639
|
+
// Resolve model: explicit > config.models.balanced > fallback
|
|
640
|
+
let modelDef = options.modelDef;
|
|
641
|
+
if (!modelDef && options.config?.models) {
|
|
642
|
+
const { resolveBalancedModelDef } = await import("../model-resolution");
|
|
643
|
+
try {
|
|
644
|
+
modelDef = resolveBalancedModelDef(options.config as import("../../config").NaxConfig);
|
|
645
|
+
} catch {
|
|
646
|
+
// resolveBalancedModelDef can throw if models.balanced missing
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
modelDef ??= { provider: "anthropic", model: "claude-sonnet-4-5-20250514" };
|
|
592
650
|
// Timeout: from options, or config, or fallback to 600s
|
|
593
651
|
const timeoutSeconds =
|
|
594
652
|
options.timeoutSeconds ?? (options.config?.execution?.sessionTimeoutSeconds as number | undefined) ?? 600;
|
|
@@ -601,6 +659,7 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
601
659
|
timeoutSeconds,
|
|
602
660
|
dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
|
|
603
661
|
interactionBridge: options.interactionBridge,
|
|
662
|
+
maxInteractionTurns: options.maxInteractionTurns,
|
|
604
663
|
featureName: options.featureName,
|
|
605
664
|
storyId: options.storyId,
|
|
606
665
|
sessionRole: options.sessionRole,
|
|
@@ -247,7 +247,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
247
247
|
const sessionName = opts.sessionName || `nax-${Date.now()}`;
|
|
248
248
|
|
|
249
249
|
// Ensure session exists via CLI
|
|
250
|
-
const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
|
|
250
|
+
const cmd = ["acpx", "--cwd", this.cwd, opts.agentName, "sessions", "ensure", "--name", sessionName];
|
|
251
251
|
getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
|
|
252
252
|
|
|
253
253
|
const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -271,7 +271,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
271
271
|
|
|
272
272
|
async loadSession(sessionName: string): Promise<AcpSession | null> {
|
|
273
273
|
// Try to ensure session exists — if it does, acpx returns success
|
|
274
|
-
const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
|
|
274
|
+
const cmd = ["acpx", "--cwd", this.cwd, this.agentName, "sessions", "ensure", "--name", sessionName];
|
|
275
275
|
|
|
276
276
|
const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
277
277
|
const exitCode = await proc.exited;
|
|
@@ -48,6 +48,8 @@ export interface PlanOptions {
|
|
|
48
48
|
timeoutSeconds?: number;
|
|
49
49
|
/** Whether to skip permission prompts (maps to permissionMode in ACP) */
|
|
50
50
|
dangerouslySkipPermissions?: boolean;
|
|
51
|
+
/** Max interaction turns when interactionBridge is active (default: 10) */
|
|
52
|
+
maxInteractionTurns?: number;
|
|
51
53
|
/**
|
|
52
54
|
* Callback invoked with the ACP session name after the session is created.
|
|
53
55
|
* Used to persist the name to status.json for plan→run session continuity.
|
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
|
/**
|
|
@@ -100,6 +102,12 @@ export interface CompleteOptions {
|
|
|
100
102
|
model?: string;
|
|
101
103
|
/** Whether to skip permission prompts (maps to permissionMode in ACP) */
|
|
102
104
|
dangerouslySkipPermissions?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Timeout for the completion call in milliseconds.
|
|
107
|
+
* Adapters that support it (e.g. ACP) will enforce this as a hard deadline.
|
|
108
|
+
* Callers may also wrap complete() in their own Promise.race for shorter timeouts.
|
|
109
|
+
*/
|
|
110
|
+
timeoutMs?: number;
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
/**
|
|
@@ -203,4 +203,10 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
|
|
203
203
|
"decompose.maxSubstoryComplexity": "Max complexity for any generated substory (default: 'medium')",
|
|
204
204
|
"decompose.maxRetries": "Max retries on decomposition validation failure (default: 2)",
|
|
205
205
|
"decompose.model": "Model tier for decomposition LLM calls (default: 'balanced')",
|
|
206
|
+
|
|
207
|
+
// Agent protocol
|
|
208
|
+
agent: "Agent protocol configuration (ACP-003)",
|
|
209
|
+
"agent.protocol": "Protocol for agent communication: 'acp' | 'cli' (default: 'acp')",
|
|
210
|
+
"agent.maxInteractionTurns":
|
|
211
|
+
"Max turns in multi-turn interaction loop when interactionBridge is active (default: 10)",
|
|
206
212
|
};
|
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
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* Reads a spec file (--from), builds a planning prompt with codebase context,
|
|
5
5
|
* calls adapter.complete(), validates the JSON response, and writes prd.json.
|
|
6
6
|
*
|
|
7
|
-
* Interactive mode
|
|
7
|
+
* Interactive mode: uses ACP session + stdin bridge for Q&A.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
import { createAgentRegistry, getAgent } from "../agents/registry";
|
|
13
14
|
import type { AgentAdapter } from "../agents/types";
|
|
14
15
|
import { scanCodebase } from "../analyze/scanner";
|
|
15
16
|
import type { CodebaseScan } from "../analyze/types";
|
|
@@ -25,7 +26,8 @@ export const _deps = {
|
|
|
25
26
|
readFile: (path: string): Promise<string> => Bun.file(path).text(),
|
|
26
27
|
writeFile: (path: string, content: string): Promise<void> => Bun.write(path, content).then(() => {}),
|
|
27
28
|
scanCodebase: (workdir: string): Promise<CodebaseScan> => scanCodebase(workdir),
|
|
28
|
-
getAgent: (name: string): AgentAdapter | undefined =>
|
|
29
|
+
getAgent: (name: string, cfg?: NaxConfig): AgentAdapter | undefined =>
|
|
30
|
+
cfg ? createAgentRegistry(cfg).getAgent(name) : getAgent(name),
|
|
29
31
|
readPackageJson: (workdir: string): Promise<Record<string, unknown> | null> =>
|
|
30
32
|
Bun.file(join(workdir, "package.json"))
|
|
31
33
|
.json()
|
|
@@ -90,12 +92,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
90
92
|
const branchName = options.branch ?? `feat/${options.feature}`;
|
|
91
93
|
const prompt = buildPlanningPrompt(specContent, codebaseContext);
|
|
92
94
|
|
|
93
|
-
// Get agent adapter
|
|
94
95
|
const agentName = config?.autoMode?.defaultAgent ?? "claude";
|
|
95
|
-
const adapter = _deps.getAgent(agentName);
|
|
96
|
-
if (!adapter) {
|
|
97
|
-
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
98
|
-
}
|
|
99
96
|
|
|
100
97
|
// Timeout: from config, or default to 600 seconds (10 min)
|
|
101
98
|
const timeoutSeconds = config?.execution?.sessionTimeoutSeconds ?? 600;
|
|
@@ -103,8 +100,23 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
103
100
|
// Route to auto (one-shot) or interactive (multi-turn) mode
|
|
104
101
|
let rawResponse: string;
|
|
105
102
|
if (options.auto) {
|
|
106
|
-
|
|
103
|
+
// One-shot: use CLI adapter directly — simple completion doesn't need ACP session overhead
|
|
104
|
+
const cliAdapter = _deps.getAgent(agentName);
|
|
105
|
+
if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
106
|
+
rawResponse = await cliAdapter.complete(prompt, { jsonMode: true });
|
|
107
|
+
// CLI adapter returns {"type":"result","result":"..."} envelope — unwrap it
|
|
108
|
+
try {
|
|
109
|
+
const envelope = JSON.parse(rawResponse) as Record<string, unknown>;
|
|
110
|
+
if (envelope?.type === "result" && typeof envelope?.result === "string") {
|
|
111
|
+
rawResponse = envelope.result;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Not an envelope — use rawResponse as-is
|
|
115
|
+
}
|
|
107
116
|
} else {
|
|
117
|
+
// Interactive: use protocol-aware adapter (ACP when configured)
|
|
118
|
+
const adapter = _deps.getAgent(agentName, config);
|
|
119
|
+
if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
108
120
|
const interactionBridge = createCliInteractionBridge();
|
|
109
121
|
logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
|
|
110
122
|
try {
|
|
@@ -114,6 +126,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
114
126
|
interactive: true,
|
|
115
127
|
timeoutSeconds,
|
|
116
128
|
interactionBridge,
|
|
129
|
+
config,
|
|
130
|
+
modelTier: "balanced",
|
|
131
|
+
dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
|
|
132
|
+
maxInteractionTurns: config?.agent?.maxInteractionTurns,
|
|
117
133
|
});
|
|
118
134
|
rawResponse = result.specContent;
|
|
119
135
|
} finally {
|
|
@@ -153,15 +169,26 @@ function createCliInteractionBridge(): {
|
|
|
153
169
|
} {
|
|
154
170
|
return {
|
|
155
171
|
async detectQuestion(text: string): Promise<boolean> {
|
|
156
|
-
// Simple heuristic: detect if text contains a question mark
|
|
157
172
|
return text.includes("?");
|
|
158
173
|
},
|
|
159
174
|
|
|
160
175
|
async onQuestionDetected(text: string): Promise<string> {
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
// In non-TTY mode (headless/pipes), skip interaction and continue
|
|
177
|
+
if (!process.stdin.isTTY) {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Print agent question and read one line from stdin
|
|
182
|
+
process.stdout.write(`\n🤖 Agent: ${text}\nYou: `);
|
|
183
|
+
|
|
184
|
+
return new Promise<string>((resolve) => {
|
|
185
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
186
|
+
rl.once("line", (line) => {
|
|
187
|
+
rl.close();
|
|
188
|
+
resolve(line.trim());
|
|
189
|
+
});
|
|
190
|
+
rl.once("close", () => resolve(""));
|
|
191
|
+
});
|
|
165
192
|
},
|
|
166
193
|
};
|
|
167
194
|
}
|
|
@@ -262,7 +289,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
262
289
|
"passes": false,
|
|
263
290
|
"routing": {
|
|
264
291
|
"complexity": "simple | medium | complex | expert",
|
|
265
|
-
"testStrategy": "test-after | tdd-
|
|
292
|
+
"testStrategy": "test-after | tdd-simple | three-session-tdd | three-session-tdd-lite",
|
|
266
293
|
"reasoning": "string — brief classification rationale"
|
|
267
294
|
},
|
|
268
295
|
"escalations": [],
|
|
@@ -274,15 +301,16 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
274
301
|
## Complexity Classification Guide
|
|
275
302
|
|
|
276
303
|
- simple: ≤50 LOC, single-file change, purely additive, no new dependencies → test-after
|
|
277
|
-
- medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-
|
|
304
|
+
- medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-simple
|
|
278
305
|
- complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
|
|
279
|
-
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd
|
|
306
|
+
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd-lite
|
|
280
307
|
|
|
281
308
|
## Test Strategy Guide
|
|
282
309
|
|
|
283
310
|
- test-after: Simple changes with well-understood behavior. Write tests after implementation.
|
|
284
|
-
- tdd-
|
|
285
|
-
- three-session-tdd: Complex
|
|
311
|
+
- tdd-simple: Medium complexity. Write key tests first, implement, then fill coverage.
|
|
312
|
+
- three-session-tdd: Complex stories. Full TDD cycle with separate test-writer and implementer sessions.
|
|
313
|
+
- three-session-tdd-lite: Expert/high-risk stories. Full TDD with additional verifier session.
|
|
286
314
|
|
|
287
315
|
Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
|
|
288
316
|
}
|
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({
|
|
@@ -149,19 +149,34 @@ export const acceptanceStage: PipelineStage = {
|
|
|
149
149
|
const overrides = ctx.prd.acceptanceOverrides || {};
|
|
150
150
|
const actualFailures = failedACs.filter((acId) => !overrides[acId]);
|
|
151
151
|
|
|
152
|
-
// If all
|
|
152
|
+
// If all tests passed cleanly
|
|
153
153
|
if (actualFailures.length === 0 && exitCode === 0) {
|
|
154
154
|
logger.info("acceptance", "All acceptance tests passed");
|
|
155
155
|
return { action: "continue" };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
// All parsed AC failures are overridden — treat as success even with non-zero exit
|
|
159
|
+
if (failedACs.length > 0 && actualFailures.length === 0) {
|
|
160
|
+
logger.info("acceptance", "All failed ACs are overridden — treating as pass");
|
|
161
|
+
return { action: "continue" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Non-zero exit but no AC failures parsed at all — test crashed (syntax error, import failure, etc.)
|
|
165
|
+
if (failedACs.length === 0 && exitCode !== 0) {
|
|
166
|
+
logger.error("acceptance", "Tests errored with no AC failures parsed", {
|
|
167
|
+
exitCode,
|
|
162
168
|
output,
|
|
163
169
|
});
|
|
164
|
-
|
|
170
|
+
|
|
171
|
+
ctx.acceptanceFailures = {
|
|
172
|
+
failedACs: ["AC-ERROR"],
|
|
173
|
+
testOutput: output,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
action: "fail",
|
|
178
|
+
reason: `Acceptance tests errored (exit code ${exitCode}): syntax error, import failure, or unhandled exception`,
|
|
179
|
+
};
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
// If we have actual failures, report them
|
|
@@ -218,6 +218,7 @@ export const executionStage: PipelineStage = {
|
|
|
218
218
|
modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
|
|
219
219
|
timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
|
|
220
220
|
dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
|
|
221
|
+
maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
|
|
221
222
|
pidRegistry: ctx.pidRegistry,
|
|
222
223
|
featureName: ctx.prd.feature,
|
|
223
224
|
storyId: ctx.story.id,
|
package/src/prd/schema.ts
CHANGED
|
@@ -140,10 +140,14 @@ function validateStory(raw: unknown, index: number, allIds: Set<string>): UserSt
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// testStrategy — accept from routing.testStrategy or top-level testStrategy
|
|
143
|
+
// Also map legacy/LLM-hallucinated aliases: tdd-lite → tdd-simple
|
|
143
144
|
const rawTestStrategy = routing.testStrategy ?? s.testStrategy;
|
|
145
|
+
const STRATEGY_ALIASES: Record<string, TestStrategy> = { "tdd-lite": "three-session-tdd-lite" };
|
|
146
|
+
const normalizedStrategy =
|
|
147
|
+
typeof rawTestStrategy === "string" ? (STRATEGY_ALIASES[rawTestStrategy] ?? rawTestStrategy) : rawTestStrategy;
|
|
144
148
|
const testStrategy: TestStrategy =
|
|
145
|
-
|
|
146
|
-
? (
|
|
149
|
+
normalizedStrategy !== undefined && (VALID_TEST_STRATEGIES as unknown[]).includes(normalizedStrategy)
|
|
150
|
+
? (normalizedStrategy as TestStrategy)
|
|
147
151
|
: "tdd-simple";
|
|
148
152
|
|
|
149
153
|
// dependencies
|
|
@@ -159,6 +159,7 @@ async function runRectificationLoop(
|
|
|
159
159
|
modelDef: resolveModel(config.models[implementerTier]),
|
|
160
160
|
timeoutSeconds: config.execution.sessionTimeoutSeconds,
|
|
161
161
|
dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
|
|
162
|
+
maxInteractionTurns: config.agent?.maxInteractionTurns,
|
|
162
163
|
featureName,
|
|
163
164
|
storyId: story.id,
|
|
164
165
|
sessionRole: "implementer",
|
|
@@ -140,6 +140,7 @@ export async function runTddSession(
|
|
|
140
140
|
modelDef: resolveModel(config.models[modelTier]),
|
|
141
141
|
timeoutSeconds: config.execution.sessionTimeoutSeconds,
|
|
142
142
|
dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
|
|
143
|
+
maxInteractionTurns: config.agent?.maxInteractionTurns,
|
|
143
144
|
featureName,
|
|
144
145
|
storyId: story.id,
|
|
145
146
|
sessionRole: role,
|
|
@@ -74,6 +74,7 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
|
|
|
74
74
|
modelDef,
|
|
75
75
|
timeoutSeconds: config.execution.sessionTimeoutSeconds,
|
|
76
76
|
dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
|
|
77
|
+
maxInteractionTurns: config.agent?.maxInteractionTurns,
|
|
77
78
|
});
|
|
78
79
|
|
|
79
80
|
if (agentResult.success) {
|
|
@@ -36,6 +36,18 @@ function buildScopedCommand(testFiles: string[], baseCommand: string, testScoped
|
|
|
36
36
|
return _scopedDeps.buildSmartTestCommand(testFiles, baseCommand);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Returns true when the test command delegates to a monorepo orchestrator
|
|
41
|
+
* (Turborepo, Nx) that handles change-aware scoping natively.
|
|
42
|
+
*
|
|
43
|
+
* These tools use their own filter syntax (e.g. `--filter=...[HEAD~1]`,
|
|
44
|
+
* `nx affected`) — nax's smart test runner must not attempt to append
|
|
45
|
+
* file paths to such commands, as it would produce invalid syntax.
|
|
46
|
+
*/
|
|
47
|
+
export function isMonorepoOrchestratorCommand(command: string): boolean {
|
|
48
|
+
return /\bturbo\b/.test(command) || /\bnx\b/.test(command);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export class ScopedStrategy implements IVerificationStrategy {
|
|
40
52
|
readonly name = "scoped" as const;
|
|
41
53
|
|
|
@@ -44,10 +56,16 @@ export class ScopedStrategy implements IVerificationStrategy {
|
|
|
44
56
|
const smartCfg = coerceSmartRunner(ctx.smartRunnerConfig);
|
|
45
57
|
const regressionMode = ctx.regressionMode ?? "deferred";
|
|
46
58
|
|
|
59
|
+
// Monorepo orchestrators (turbo, nx) handle change-aware scoping themselves.
|
|
60
|
+
// Skip nax's smart runner — appending file paths would produce invalid syntax.
|
|
61
|
+
// Also bypass deferred mode: run per-story so the orchestrator's own filter
|
|
62
|
+
// (e.g. --filter=...[HEAD~1]) can pick up the story's changes immediately.
|
|
63
|
+
const isMonorepoOrchestrator = isMonorepoOrchestratorCommand(ctx.testCommand);
|
|
64
|
+
|
|
47
65
|
let effectiveCommand = ctx.testCommand;
|
|
48
66
|
let isFullSuite = true;
|
|
49
67
|
|
|
50
|
-
if (smartCfg.enabled && ctx.storyGitRef) {
|
|
68
|
+
if (smartCfg.enabled && ctx.storyGitRef && !isMonorepoOrchestrator) {
|
|
51
69
|
const sourceFiles = await _scopedDeps.getChangedSourceFiles(ctx.workdir, ctx.storyGitRef);
|
|
52
70
|
|
|
53
71
|
const pass1Files = await _scopedDeps.mapSourceToTests(sourceFiles, ctx.workdir);
|
|
@@ -69,16 +87,22 @@ export class ScopedStrategy implements IVerificationStrategy {
|
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
// Defer to regression gate when no scoped tests found and mode is deferred
|
|
73
|
-
|
|
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();
|