@nathapp/nax 0.43.1 → 0.44.0
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/bin/nax.ts +22 -0
- package/dist/nax.js +229 -45
- package/package.json +1 -1
- package/src/agents/acp/adapter.ts +98 -5
- package/src/cli/status-features.ts +19 -0
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/runner.ts +4 -0
- package/src/precheck/index.ts +155 -44
- package/src/verification/rectification-loop.ts +18 -5
package/bin/nax.ts
CHANGED
|
@@ -278,6 +278,7 @@ program
|
|
|
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
280
|
.option("--one-shot", "Skip interactive planning Q&A, use single LLM call (ACP only)", false)
|
|
281
|
+
.option("--force", "Force overwrite existing prd.json when using --plan", false)
|
|
281
282
|
.option("--headless", "Force headless mode (disable TUI, use pipe mode)", false)
|
|
282
283
|
.option("--verbose", "Enable verbose logging (debug level)", false)
|
|
283
284
|
.option("--quiet", "Quiet mode (warnings and errors only)", false)
|
|
@@ -343,6 +344,27 @@ program
|
|
|
343
344
|
|
|
344
345
|
// Run plan phase if --plan flag is set (AC-4: runs plan then execute)
|
|
345
346
|
if (options.plan && options.from) {
|
|
347
|
+
// Guard: block overwrite of existing prd.json unless --force
|
|
348
|
+
if (existsSync(prdPath) && !options.force) {
|
|
349
|
+
console.error(chalk.red(`Error: prd.json already exists for feature "${options.feature}".`));
|
|
350
|
+
console.error(chalk.dim(" Use --force to overwrite, or run without --plan to use the existing PRD."));
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Run environment precheck before plan — catch blockers early (before expensive LLM calls)
|
|
355
|
+
if (!options.skipPrecheck) {
|
|
356
|
+
const { runEnvironmentPrecheck } = await import("../src/precheck");
|
|
357
|
+
console.log(chalk.dim("\n [Pre-plan environment check]"));
|
|
358
|
+
const envResult = await runEnvironmentPrecheck(config, workdir);
|
|
359
|
+
if (!envResult.passed) {
|
|
360
|
+
console.error(chalk.red("\n❌ Environment precheck failed — cannot proceed with planning."));
|
|
361
|
+
for (const b of envResult.blockers) {
|
|
362
|
+
console.error(chalk.red(` ${b.name}: ${b.message}`));
|
|
363
|
+
}
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
346
368
|
try {
|
|
347
369
|
// Initialize plan logger before calling planCommand — writes to features/<feature>/plan-<ts>.jsonl
|
|
348
370
|
mkdirSync(featureDir, { recursive: true });
|
package/dist/nax.js
CHANGED
|
@@ -19233,6 +19233,20 @@ var init_cost2 = __esm(() => {
|
|
|
19233
19233
|
});
|
|
19234
19234
|
|
|
19235
19235
|
// src/agents/acp/adapter.ts
|
|
19236
|
+
var exports_adapter = {};
|
|
19237
|
+
__export(exports_adapter, {
|
|
19238
|
+
sweepStaleFeatureSessions: () => sweepStaleFeatureSessions,
|
|
19239
|
+
sweepFeatureSessions: () => sweepFeatureSessions,
|
|
19240
|
+
saveAcpSession: () => saveAcpSession,
|
|
19241
|
+
runSessionPrompt: () => runSessionPrompt,
|
|
19242
|
+
readAcpSession: () => readAcpSession,
|
|
19243
|
+
ensureAcpSession: () => ensureAcpSession,
|
|
19244
|
+
closeAcpSession: () => closeAcpSession,
|
|
19245
|
+
clearAcpSession: () => clearAcpSession,
|
|
19246
|
+
buildSessionName: () => buildSessionName,
|
|
19247
|
+
_acpAdapterDeps: () => _acpAdapterDeps,
|
|
19248
|
+
AcpAgentAdapter: () => AcpAgentAdapter
|
|
19249
|
+
});
|
|
19236
19250
|
import { createHash } from "crypto";
|
|
19237
19251
|
import { join as join3 } from "path";
|
|
19238
19252
|
function resolveRegistryEntry(agentName) {
|
|
@@ -19336,6 +19350,59 @@ async function readAcpSession(workdir, featureName, storyId) {
|
|
|
19336
19350
|
return null;
|
|
19337
19351
|
}
|
|
19338
19352
|
}
|
|
19353
|
+
async function sweepFeatureSessions(workdir, featureName) {
|
|
19354
|
+
const path = acpSessionsPath(workdir, featureName);
|
|
19355
|
+
let sessions;
|
|
19356
|
+
try {
|
|
19357
|
+
const text = await Bun.file(path).text();
|
|
19358
|
+
sessions = JSON.parse(text);
|
|
19359
|
+
} catch {
|
|
19360
|
+
return;
|
|
19361
|
+
}
|
|
19362
|
+
const entries = Object.entries(sessions);
|
|
19363
|
+
if (entries.length === 0)
|
|
19364
|
+
return;
|
|
19365
|
+
const logger = getSafeLogger();
|
|
19366
|
+
logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
|
|
19367
|
+
const cmdStr = "acpx claude";
|
|
19368
|
+
const client = _acpAdapterDeps.createClient(cmdStr, workdir);
|
|
19369
|
+
try {
|
|
19370
|
+
await client.start();
|
|
19371
|
+
for (const [, sessionName] of entries) {
|
|
19372
|
+
try {
|
|
19373
|
+
if (client.loadSession) {
|
|
19374
|
+
const session = await client.loadSession(sessionName, "claude", "approve-reads");
|
|
19375
|
+
if (session) {
|
|
19376
|
+
await session.close().catch(() => {});
|
|
19377
|
+
}
|
|
19378
|
+
}
|
|
19379
|
+
} catch (err) {
|
|
19380
|
+
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
19381
|
+
}
|
|
19382
|
+
}
|
|
19383
|
+
} finally {
|
|
19384
|
+
await client.close().catch(() => {});
|
|
19385
|
+
}
|
|
19386
|
+
try {
|
|
19387
|
+
await Bun.write(path, JSON.stringify({}, null, 2));
|
|
19388
|
+
} catch (err) {
|
|
19389
|
+
logger?.warn("acp-adapter", "[sweep] Failed to clear sidecar after sweep", { error: String(err) });
|
|
19390
|
+
}
|
|
19391
|
+
}
|
|
19392
|
+
async function sweepStaleFeatureSessions(workdir, featureName, maxAgeMs = MAX_SESSION_AGE_MS) {
|
|
19393
|
+
const path = acpSessionsPath(workdir, featureName);
|
|
19394
|
+
const file2 = Bun.file(path);
|
|
19395
|
+
if (!await file2.exists())
|
|
19396
|
+
return;
|
|
19397
|
+
const ageMs = Date.now() - file2.lastModified;
|
|
19398
|
+
if (ageMs < maxAgeMs)
|
|
19399
|
+
return;
|
|
19400
|
+
getSafeLogger()?.info("acp-adapter", `[sweep] Sidecar is ${Math.round(ageMs / 60000)}m old \u2014 sweeping stale sessions`, {
|
|
19401
|
+
featureName,
|
|
19402
|
+
ageMs
|
|
19403
|
+
});
|
|
19404
|
+
await sweepFeatureSessions(workdir, featureName);
|
|
19405
|
+
}
|
|
19339
19406
|
function extractOutput(response) {
|
|
19340
19407
|
if (!response)
|
|
19341
19408
|
return "";
|
|
@@ -19459,6 +19526,7 @@ class AcpAgentAdapter {
|
|
|
19459
19526
|
}
|
|
19460
19527
|
let lastResponse = null;
|
|
19461
19528
|
let timedOut = false;
|
|
19529
|
+
const runState = { succeeded: false };
|
|
19462
19530
|
const totalTokenUsage = { input_tokens: 0, output_tokens: 0 };
|
|
19463
19531
|
try {
|
|
19464
19532
|
let currentPrompt = options.prompt;
|
|
@@ -19499,12 +19567,17 @@ class AcpAgentAdapter {
|
|
|
19499
19567
|
if (turnCount >= MAX_TURNS && options.interactionBridge) {
|
|
19500
19568
|
getSafeLogger()?.warn("acp-adapter", "Reached max turns limit", { sessionName, maxTurns: MAX_TURNS });
|
|
19501
19569
|
}
|
|
19570
|
+
runState.succeeded = !timedOut && lastResponse?.stopReason === "end_turn";
|
|
19502
19571
|
} finally {
|
|
19503
|
-
|
|
19504
|
-
|
|
19505
|
-
|
|
19506
|
-
|
|
19572
|
+
if (runState.succeeded) {
|
|
19573
|
+
await closeAcpSession(session);
|
|
19574
|
+
if (options.featureName && options.storyId) {
|
|
19575
|
+
await clearAcpSession(options.workdir, options.featureName, options.storyId);
|
|
19576
|
+
}
|
|
19577
|
+
} else {
|
|
19578
|
+
getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
|
|
19507
19579
|
}
|
|
19580
|
+
await client.close().catch(() => {});
|
|
19508
19581
|
}
|
|
19509
19582
|
const durationMs = Date.now() - startTime;
|
|
19510
19583
|
if (timedOut) {
|
|
@@ -19654,7 +19727,7 @@ class AcpAgentAdapter {
|
|
|
19654
19727
|
return { stories };
|
|
19655
19728
|
}
|
|
19656
19729
|
}
|
|
19657
|
-
var MAX_AGENT_OUTPUT_CHARS2 = 5000, MAX_RATE_LIMIT_RETRIES = 3, INTERACTION_TIMEOUT_MS, AGENT_REGISTRY, DEFAULT_ENTRY, _acpAdapterDeps;
|
|
19730
|
+
var MAX_AGENT_OUTPUT_CHARS2 = 5000, MAX_RATE_LIMIT_RETRIES = 3, INTERACTION_TIMEOUT_MS, AGENT_REGISTRY, DEFAULT_ENTRY, _acpAdapterDeps, MAX_SESSION_AGE_MS;
|
|
19658
19731
|
var init_adapter = __esm(() => {
|
|
19659
19732
|
init_logger2();
|
|
19660
19733
|
init_spawn_client();
|
|
@@ -19698,6 +19771,7 @@ var init_adapter = __esm(() => {
|
|
|
19698
19771
|
return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
|
|
19699
19772
|
}
|
|
19700
19773
|
};
|
|
19774
|
+
MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000;
|
|
19701
19775
|
});
|
|
19702
19776
|
|
|
19703
19777
|
// src/agents/adapters/aider.ts
|
|
@@ -21968,7 +22042,7 @@ var package_default;
|
|
|
21968
22042
|
var init_package = __esm(() => {
|
|
21969
22043
|
package_default = {
|
|
21970
22044
|
name: "@nathapp/nax",
|
|
21971
|
-
version: "0.
|
|
22045
|
+
version: "0.44.0",
|
|
21972
22046
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
21973
22047
|
type: "module",
|
|
21974
22048
|
bin: {
|
|
@@ -22041,8 +22115,8 @@ var init_version = __esm(() => {
|
|
|
22041
22115
|
NAX_VERSION = package_default.version;
|
|
22042
22116
|
NAX_COMMIT = (() => {
|
|
22043
22117
|
try {
|
|
22044
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
22045
|
-
return "
|
|
22118
|
+
if (/^[0-9a-f]{6,10}$/.test("05b2442"))
|
|
22119
|
+
return "05b2442";
|
|
22046
22120
|
} catch {}
|
|
22047
22121
|
try {
|
|
22048
22122
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -28235,7 +28309,7 @@ var init_test_output_parser = () => {};
|
|
|
28235
28309
|
|
|
28236
28310
|
// src/verification/rectification-loop.ts
|
|
28237
28311
|
async function runRectificationLoop2(opts) {
|
|
28238
|
-
const { config: config2, workdir, story, testCommand, timeoutSeconds, testOutput, promptPrefix } = opts;
|
|
28312
|
+
const { config: config2, workdir, story, testCommand, timeoutSeconds, testOutput, promptPrefix, featureName } = opts;
|
|
28239
28313
|
const logger = getSafeLogger();
|
|
28240
28314
|
const rectificationConfig = config2.execution.rectification;
|
|
28241
28315
|
const testSummary = parseBunTestOutput(testOutput);
|
|
@@ -28261,7 +28335,7 @@ async function runRectificationLoop2(opts) {
|
|
|
28261
28335
|
rectificationPrompt = `${promptPrefix}
|
|
28262
28336
|
|
|
28263
28337
|
${rectificationPrompt}`;
|
|
28264
|
-
const agent = getAgent(config2.autoMode.defaultAgent);
|
|
28338
|
+
const agent = _rectificationDeps.getAgent(config2.autoMode.defaultAgent);
|
|
28265
28339
|
if (!agent) {
|
|
28266
28340
|
logger?.error("rectification", "Agent not found, cannot retry");
|
|
28267
28341
|
break;
|
|
@@ -28277,7 +28351,10 @@ ${rectificationPrompt}`;
|
|
|
28277
28351
|
dangerouslySkipPermissions: resolvePermissions(config2, "rectification").skipPermissions,
|
|
28278
28352
|
pipelineStage: "rectification",
|
|
28279
28353
|
config: config2,
|
|
28280
|
-
maxInteractionTurns: config2.agent?.maxInteractionTurns
|
|
28354
|
+
maxInteractionTurns: config2.agent?.maxInteractionTurns,
|
|
28355
|
+
featureName,
|
|
28356
|
+
storyId: story.id,
|
|
28357
|
+
sessionRole: "implementer"
|
|
28281
28358
|
});
|
|
28282
28359
|
if (agentResult.success) {
|
|
28283
28360
|
logger?.info("rectification", `Agent ${label} session complete`, {
|
|
@@ -28292,7 +28369,7 @@ ${rectificationPrompt}`;
|
|
|
28292
28369
|
exitCode: agentResult.exitCode
|
|
28293
28370
|
});
|
|
28294
28371
|
}
|
|
28295
|
-
const retryVerification = await
|
|
28372
|
+
const retryVerification = await _rectificationDeps.runVerification({
|
|
28296
28373
|
workdir,
|
|
28297
28374
|
expectedFiles: getExpectedFiles(story),
|
|
28298
28375
|
command: testCommand,
|
|
@@ -28342,6 +28419,7 @@ ${rectificationPrompt}`;
|
|
|
28342
28419
|
}
|
|
28343
28420
|
return false;
|
|
28344
28421
|
}
|
|
28422
|
+
var _rectificationDeps;
|
|
28345
28423
|
var init_rectification_loop = __esm(() => {
|
|
28346
28424
|
init_agents();
|
|
28347
28425
|
init_config();
|
|
@@ -28350,6 +28428,10 @@ var init_rectification_loop = __esm(() => {
|
|
|
28350
28428
|
init_prd();
|
|
28351
28429
|
init_rectification();
|
|
28352
28430
|
init_runners();
|
|
28431
|
+
_rectificationDeps = {
|
|
28432
|
+
getAgent,
|
|
28433
|
+
runVerification: fullSuite
|
|
28434
|
+
};
|
|
28353
28435
|
});
|
|
28354
28436
|
|
|
28355
28437
|
// src/pipeline/stages/rectify.ts
|
|
@@ -30451,8 +30533,85 @@ async function checkStorySizeGate(config2, prd) {
|
|
|
30451
30533
|
var exports_precheck = {};
|
|
30452
30534
|
__export(exports_precheck, {
|
|
30453
30535
|
runPrecheck: () => runPrecheck,
|
|
30536
|
+
runEnvironmentPrecheck: () => runEnvironmentPrecheck,
|
|
30454
30537
|
EXIT_CODES: () => EXIT_CODES
|
|
30455
30538
|
});
|
|
30539
|
+
function getEarlyEnvironmentBlockers(workdir) {
|
|
30540
|
+
return [() => checkGitRepoExists(workdir), () => checkWorkingTreeClean(workdir), () => checkStaleLock(workdir)];
|
|
30541
|
+
}
|
|
30542
|
+
function getLateEnvironmentBlockers(config2, workdir) {
|
|
30543
|
+
return [
|
|
30544
|
+
() => checkAgentCLI(config2),
|
|
30545
|
+
() => checkDependenciesInstalled(workdir),
|
|
30546
|
+
() => checkTestCommand(config2),
|
|
30547
|
+
() => checkLintCommand(config2),
|
|
30548
|
+
() => checkTypecheckCommand(config2),
|
|
30549
|
+
() => checkGitUserConfigured(workdir)
|
|
30550
|
+
];
|
|
30551
|
+
}
|
|
30552
|
+
function getEnvironmentBlockers(config2, workdir) {
|
|
30553
|
+
return [...getEarlyEnvironmentBlockers(workdir), ...getLateEnvironmentBlockers(config2, workdir)];
|
|
30554
|
+
}
|
|
30555
|
+
function getEnvironmentWarnings(config2, workdir) {
|
|
30556
|
+
return [
|
|
30557
|
+
() => checkClaudeMdExists(workdir),
|
|
30558
|
+
() => checkDiskSpace(),
|
|
30559
|
+
() => checkOptionalCommands(config2, workdir),
|
|
30560
|
+
() => checkGitignoreCoversNax(workdir),
|
|
30561
|
+
() => checkPromptOverrideFiles(config2, workdir),
|
|
30562
|
+
() => checkMultiAgentHealth()
|
|
30563
|
+
];
|
|
30564
|
+
}
|
|
30565
|
+
function getProjectBlockers(prd) {
|
|
30566
|
+
return [() => checkPRDValid(prd)];
|
|
30567
|
+
}
|
|
30568
|
+
function getProjectWarnings(prd) {
|
|
30569
|
+
return [() => checkPendingStories(prd)];
|
|
30570
|
+
}
|
|
30571
|
+
function normalizeChecks(result) {
|
|
30572
|
+
return Array.isArray(result) ? result : [result];
|
|
30573
|
+
}
|
|
30574
|
+
async function runEnvironmentPrecheck(config2, workdir, options) {
|
|
30575
|
+
const format = options?.format ?? "human";
|
|
30576
|
+
const silent = options?.silent ?? false;
|
|
30577
|
+
const passed = [];
|
|
30578
|
+
const blockers = [];
|
|
30579
|
+
const warnings = [];
|
|
30580
|
+
for (const checkFn of getEnvironmentBlockers(config2, workdir)) {
|
|
30581
|
+
const checks3 = normalizeChecks(await checkFn());
|
|
30582
|
+
let blocked = false;
|
|
30583
|
+
for (const check2 of checks3) {
|
|
30584
|
+
if (!silent && format === "human")
|
|
30585
|
+
printCheckResult(check2);
|
|
30586
|
+
if (check2.passed) {
|
|
30587
|
+
passed.push(check2);
|
|
30588
|
+
} else {
|
|
30589
|
+
blockers.push(check2);
|
|
30590
|
+
blocked = true;
|
|
30591
|
+
break;
|
|
30592
|
+
}
|
|
30593
|
+
}
|
|
30594
|
+
if (blocked)
|
|
30595
|
+
break;
|
|
30596
|
+
}
|
|
30597
|
+
if (blockers.length === 0) {
|
|
30598
|
+
for (const checkFn of getEnvironmentWarnings(config2, workdir)) {
|
|
30599
|
+
for (const check2 of normalizeChecks(await checkFn())) {
|
|
30600
|
+
if (!silent && format === "human")
|
|
30601
|
+
printCheckResult(check2);
|
|
30602
|
+
if (check2.passed) {
|
|
30603
|
+
passed.push(check2);
|
|
30604
|
+
} else {
|
|
30605
|
+
warnings.push(check2);
|
|
30606
|
+
}
|
|
30607
|
+
}
|
|
30608
|
+
}
|
|
30609
|
+
}
|
|
30610
|
+
if (!silent && format === "json") {
|
|
30611
|
+
console.log(JSON.stringify({ passed: blockers.length === 0, blockers, warnings }, null, 2));
|
|
30612
|
+
}
|
|
30613
|
+
return { passed: blockers.length === 0, blockers, warnings };
|
|
30614
|
+
}
|
|
30456
30615
|
async function runPrecheck(config2, prd, options) {
|
|
30457
30616
|
const workdir = options?.workdir || process.cwd();
|
|
30458
30617
|
const format = options?.format || "human";
|
|
@@ -30461,47 +30620,33 @@ async function runPrecheck(config2, prd, options) {
|
|
|
30461
30620
|
const blockers = [];
|
|
30462
30621
|
const warnings = [];
|
|
30463
30622
|
const tier1Checks = [
|
|
30464
|
-
(
|
|
30465
|
-
()
|
|
30466
|
-
(
|
|
30467
|
-
() => checkPRDValid(prd),
|
|
30468
|
-
() => checkAgentCLI(config2),
|
|
30469
|
-
() => checkDependenciesInstalled(workdir),
|
|
30470
|
-
() => checkTestCommand(config2),
|
|
30471
|
-
() => checkLintCommand(config2),
|
|
30472
|
-
() => checkTypecheckCommand(config2),
|
|
30473
|
-
() => checkGitUserConfigured(workdir)
|
|
30623
|
+
...getEarlyEnvironmentBlockers(workdir),
|
|
30624
|
+
...getProjectBlockers(prd),
|
|
30625
|
+
...getLateEnvironmentBlockers(config2, workdir)
|
|
30474
30626
|
];
|
|
30627
|
+
let tier1Blocked = false;
|
|
30475
30628
|
for (const checkFn of tier1Checks) {
|
|
30476
|
-
const
|
|
30477
|
-
|
|
30478
|
-
|
|
30629
|
+
for (const check2 of normalizeChecks(await checkFn())) {
|
|
30630
|
+
if (format === "human")
|
|
30631
|
+
printCheckResult(check2);
|
|
30632
|
+
if (check2.passed) {
|
|
30633
|
+
passed.push(check2);
|
|
30634
|
+
} else {
|
|
30635
|
+
blockers.push(check2);
|
|
30636
|
+
tier1Blocked = true;
|
|
30637
|
+
break;
|
|
30638
|
+
}
|
|
30479
30639
|
}
|
|
30480
|
-
if (
|
|
30481
|
-
passed.push(result);
|
|
30482
|
-
} else {
|
|
30483
|
-
blockers.push(result);
|
|
30640
|
+
if (tier1Blocked)
|
|
30484
30641
|
break;
|
|
30485
|
-
}
|
|
30486
30642
|
}
|
|
30487
30643
|
let flaggedStories = [];
|
|
30488
30644
|
if (blockers.length === 0) {
|
|
30489
|
-
const tier2Checks = [
|
|
30490
|
-
() => checkClaudeMdExists(workdir),
|
|
30491
|
-
() => checkDiskSpace(),
|
|
30492
|
-
() => checkPendingStories(prd),
|
|
30493
|
-
() => checkOptionalCommands(config2, workdir),
|
|
30494
|
-
() => checkGitignoreCoversNax(workdir),
|
|
30495
|
-
() => checkPromptOverrideFiles(config2, workdir),
|
|
30496
|
-
() => checkMultiAgentHealth()
|
|
30497
|
-
];
|
|
30645
|
+
const tier2Checks = [...getEnvironmentWarnings(config2, workdir), ...getProjectWarnings(prd)];
|
|
30498
30646
|
for (const checkFn of tier2Checks) {
|
|
30499
|
-
const
|
|
30500
|
-
|
|
30501
|
-
for (const check2 of checksToProcess) {
|
|
30502
|
-
if (format === "human") {
|
|
30647
|
+
for (const check2 of normalizeChecks(await checkFn())) {
|
|
30648
|
+
if (format === "human")
|
|
30503
30649
|
printCheckResult(check2);
|
|
30504
|
-
}
|
|
30505
30650
|
if (check2.passed) {
|
|
30506
30651
|
passed.push(check2);
|
|
30507
30652
|
} else {
|
|
@@ -34258,6 +34403,8 @@ async function setupRun(options) {
|
|
|
34258
34403
|
} else {
|
|
34259
34404
|
logger?.warn("precheck", "Precheck validations skipped (--skip-precheck)");
|
|
34260
34405
|
}
|
|
34406
|
+
const { sweepStaleFeatureSessions: sweepStaleFeatureSessions2 } = await Promise.resolve().then(() => (init_adapter(), exports_adapter));
|
|
34407
|
+
await sweepStaleFeatureSessions2(workdir, feature).catch(() => {});
|
|
34261
34408
|
const lockAcquired = await acquireLock(workdir);
|
|
34262
34409
|
if (!lockAcquired) {
|
|
34263
34410
|
logger?.error("execution", "Another nax process is already running in this directory");
|
|
@@ -66342,6 +66489,15 @@ async function loadProjectStatusFile(projectDir) {
|
|
|
66342
66489
|
}
|
|
66343
66490
|
async function getFeatureSummary(featureName, featureDir) {
|
|
66344
66491
|
const prdPath = join13(featureDir, "prd.json");
|
|
66492
|
+
if (!existsSync11(prdPath)) {
|
|
66493
|
+
return {
|
|
66494
|
+
name: featureName,
|
|
66495
|
+
done: 0,
|
|
66496
|
+
failed: 0,
|
|
66497
|
+
pending: 0,
|
|
66498
|
+
total: 0
|
|
66499
|
+
};
|
|
66500
|
+
}
|
|
66345
66501
|
const prd = await loadPRD(prdPath);
|
|
66346
66502
|
const counts = countStories(prd);
|
|
66347
66503
|
const summary = {
|
|
@@ -66454,6 +66610,13 @@ async function displayAllFeatures(projectDir) {
|
|
|
66454
66610
|
}
|
|
66455
66611
|
async function displayFeatureDetails(featureName, featureDir) {
|
|
66456
66612
|
const prdPath = join13(featureDir, "prd.json");
|
|
66613
|
+
if (!existsSync11(prdPath)) {
|
|
66614
|
+
console.log(source_default.bold(`
|
|
66615
|
+
\uD83D\uDCCA ${featureName}
|
|
66616
|
+
`));
|
|
66617
|
+
console.log(source_default.dim(`No prd.json found. Run: nax plan -f ${featureName} --from <spec>`));
|
|
66618
|
+
return;
|
|
66619
|
+
}
|
|
66457
66620
|
const prd = await loadPRD(prdPath);
|
|
66458
66621
|
const counts = countStories(prd);
|
|
66459
66622
|
const status = await loadStatusFile(featureDir);
|
|
@@ -68888,6 +69051,7 @@ async function unlockCommand(options) {
|
|
|
68888
69051
|
init_config();
|
|
68889
69052
|
|
|
68890
69053
|
// src/execution/runner.ts
|
|
69054
|
+
init_adapter();
|
|
68891
69055
|
init_registry();
|
|
68892
69056
|
init_hooks();
|
|
68893
69057
|
init_logger2();
|
|
@@ -69301,6 +69465,7 @@ async function run(options) {
|
|
|
69301
69465
|
} finally {
|
|
69302
69466
|
stopHeartbeat();
|
|
69303
69467
|
cleanupCrashHandlers();
|
|
69468
|
+
await sweepFeatureSessions(workdir, feature).catch(() => {});
|
|
69304
69469
|
const { cleanupRun: cleanupRun2 } = await Promise.resolve().then(() => (init_run_cleanup(), exports_run_cleanup));
|
|
69305
69470
|
await cleanupRun2({
|
|
69306
69471
|
runId,
|
|
@@ -76762,7 +76927,7 @@ Run \`nax generate\` to regenerate agent config files (CLAUDE.md, AGENTS.md, .cu
|
|
|
76762
76927
|
console.log(source_default.dim(`
|
|
76763
76928
|
Next: nax features create <name>`));
|
|
76764
76929
|
});
|
|
76765
|
-
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) => {
|
|
76930
|
+
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("--force", "Force overwrite existing prd.json when using --plan", 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) => {
|
|
76766
76931
|
let workdir;
|
|
76767
76932
|
try {
|
|
76768
76933
|
workdir = validateDirectory(options.dir);
|
|
@@ -76806,6 +76971,25 @@ program2.command("run").description("Run the orchestration loop for a feature").
|
|
|
76806
76971
|
const featureDir = join43(naxDir, "features", options.feature);
|
|
76807
76972
|
const prdPath = join43(featureDir, "prd.json");
|
|
76808
76973
|
if (options.plan && options.from) {
|
|
76974
|
+
if (existsSync32(prdPath) && !options.force) {
|
|
76975
|
+
console.error(source_default.red(`Error: prd.json already exists for feature "${options.feature}".`));
|
|
76976
|
+
console.error(source_default.dim(" Use --force to overwrite, or run without --plan to use the existing PRD."));
|
|
76977
|
+
process.exit(1);
|
|
76978
|
+
}
|
|
76979
|
+
if (!options.skipPrecheck) {
|
|
76980
|
+
const { runEnvironmentPrecheck: runEnvironmentPrecheck2 } = await Promise.resolve().then(() => (init_precheck(), exports_precheck));
|
|
76981
|
+
console.log(source_default.dim(`
|
|
76982
|
+
[Pre-plan environment check]`));
|
|
76983
|
+
const envResult = await runEnvironmentPrecheck2(config2, workdir);
|
|
76984
|
+
if (!envResult.passed) {
|
|
76985
|
+
console.error(source_default.red(`
|
|
76986
|
+
\u274C Environment precheck failed \u2014 cannot proceed with planning.`));
|
|
76987
|
+
for (const b of envResult.blockers) {
|
|
76988
|
+
console.error(source_default.red(` ${b.name}: ${b.message}`));
|
|
76989
|
+
}
|
|
76990
|
+
process.exit(1);
|
|
76991
|
+
}
|
|
76992
|
+
}
|
|
76809
76993
|
try {
|
|
76810
76994
|
mkdirSync6(featureDir, { recursive: true });
|
|
76811
76995
|
const planLogId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
|
package/package.json
CHANGED
|
@@ -307,6 +307,88 @@ export async function readAcpSession(workdir: string, featureName: string, story
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
// Session sweep — close open sessions at run boundaries
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Close all open sessions tracked in the sidecar file for a feature.
|
|
318
|
+
* Called at run-end to ensure no sessions leak past the run boundary.
|
|
319
|
+
*/
|
|
320
|
+
export async function sweepFeatureSessions(workdir: string, featureName: string): Promise<void> {
|
|
321
|
+
const path = acpSessionsPath(workdir, featureName);
|
|
322
|
+
let sessions: Record<string, string>;
|
|
323
|
+
try {
|
|
324
|
+
const text = await Bun.file(path).text();
|
|
325
|
+
sessions = JSON.parse(text) as Record<string, string>;
|
|
326
|
+
} catch {
|
|
327
|
+
return; // No sidecar — nothing to sweep
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const entries = Object.entries(sessions);
|
|
331
|
+
if (entries.length === 0) return;
|
|
332
|
+
|
|
333
|
+
const logger = getSafeLogger();
|
|
334
|
+
logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
|
|
335
|
+
|
|
336
|
+
const cmdStr = "acpx claude";
|
|
337
|
+
const client = _acpAdapterDeps.createClient(cmdStr, workdir);
|
|
338
|
+
try {
|
|
339
|
+
await client.start();
|
|
340
|
+
for (const [, sessionName] of entries) {
|
|
341
|
+
try {
|
|
342
|
+
if (client.loadSession) {
|
|
343
|
+
const session = await client.loadSession(sessionName, "claude", "approve-reads");
|
|
344
|
+
if (session) {
|
|
345
|
+
await session.close().catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} finally {
|
|
353
|
+
await client.close().catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Clear sidecar after sweep
|
|
357
|
+
try {
|
|
358
|
+
await Bun.write(path, JSON.stringify({}, null, 2));
|
|
359
|
+
} catch (err) {
|
|
360
|
+
logger?.warn("acp-adapter", "[sweep] Failed to clear sidecar after sweep", { error: String(err) });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Sweep stale sessions if the sidecar file is older than maxAgeMs.
|
|
366
|
+
* Called at startup as a safety net for sessions orphaned by crashes.
|
|
367
|
+
*/
|
|
368
|
+
export async function sweepStaleFeatureSessions(
|
|
369
|
+
workdir: string,
|
|
370
|
+
featureName: string,
|
|
371
|
+
maxAgeMs = MAX_SESSION_AGE_MS,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const path = acpSessionsPath(workdir, featureName);
|
|
374
|
+
const file = Bun.file(path);
|
|
375
|
+
if (!(await file.exists())) return;
|
|
376
|
+
|
|
377
|
+
const ageMs = Date.now() - file.lastModified;
|
|
378
|
+
if (ageMs < maxAgeMs) return; // Recent sidecar — skip
|
|
379
|
+
|
|
380
|
+
getSafeLogger()?.info(
|
|
381
|
+
"acp-adapter",
|
|
382
|
+
`[sweep] Sidecar is ${Math.round(ageMs / 60000)}m old — sweeping stale sessions`,
|
|
383
|
+
{
|
|
384
|
+
featureName,
|
|
385
|
+
ageMs,
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
await sweepFeatureSessions(workdir, featureName);
|
|
390
|
+
}
|
|
391
|
+
|
|
310
392
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
311
393
|
// Output helpers
|
|
312
394
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -470,6 +552,9 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
470
552
|
|
|
471
553
|
let lastResponse: AcpSessionResponse | null = null;
|
|
472
554
|
let timedOut = false;
|
|
555
|
+
// Tracks whether the run completed successfully — used by finally to decide
|
|
556
|
+
// whether to close the session (success) or keep it open for retry (failure).
|
|
557
|
+
const runState = { succeeded: false };
|
|
473
558
|
const totalTokenUsage = { input_tokens: 0, output_tokens: 0 };
|
|
474
559
|
|
|
475
560
|
try {
|
|
@@ -525,13 +610,21 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
525
610
|
if (turnCount >= MAX_TURNS && options.interactionBridge) {
|
|
526
611
|
getSafeLogger()?.warn("acp-adapter", "Reached max turns limit", { sessionName, maxTurns: MAX_TURNS });
|
|
527
612
|
}
|
|
613
|
+
|
|
614
|
+
// Compute success here so finally can use it for conditional close.
|
|
615
|
+
runState.succeeded = !timedOut && lastResponse?.stopReason === "end_turn";
|
|
528
616
|
} finally {
|
|
529
|
-
// 6. Cleanup —
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
617
|
+
// 6. Cleanup — close session and clear sidecar only on success.
|
|
618
|
+
// On failure, keep session open so retry can resume with full context.
|
|
619
|
+
if (runState.succeeded) {
|
|
620
|
+
await closeAcpSession(session);
|
|
621
|
+
if (options.featureName && options.storyId) {
|
|
622
|
+
await clearAcpSession(options.workdir, options.featureName, options.storyId);
|
|
623
|
+
}
|
|
624
|
+
} else {
|
|
625
|
+
getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
|
|
534
626
|
}
|
|
627
|
+
await client.close().catch(() => {});
|
|
535
628
|
}
|
|
536
629
|
|
|
537
630
|
const durationMs = Date.now() - startTime;
|
|
@@ -85,6 +85,17 @@ async function loadProjectStatusFile(projectDir: string): Promise<NaxStatusFile
|
|
|
85
85
|
async function getFeatureSummary(featureName: string, featureDir: string): Promise<FeatureSummary> {
|
|
86
86
|
const prdPath = join(featureDir, "prd.json");
|
|
87
87
|
|
|
88
|
+
// Guard: prd.json may not exist (e.g. plan failed before writing it)
|
|
89
|
+
if (!existsSync(prdPath)) {
|
|
90
|
+
return {
|
|
91
|
+
name: featureName,
|
|
92
|
+
done: 0,
|
|
93
|
+
failed: 0,
|
|
94
|
+
pending: 0,
|
|
95
|
+
total: 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
// Load PRD for story counts
|
|
89
100
|
const prd = await loadPRD(prdPath);
|
|
90
101
|
const counts = countStories(prd);
|
|
@@ -240,6 +251,14 @@ async function displayAllFeatures(projectDir: string): Promise<void> {
|
|
|
240
251
|
/** Display single feature details */
|
|
241
252
|
async function displayFeatureDetails(featureName: string, featureDir: string): Promise<void> {
|
|
242
253
|
const prdPath = join(featureDir, "prd.json");
|
|
254
|
+
|
|
255
|
+
// Guard: prd.json may not exist (e.g. plan failed or feature just created)
|
|
256
|
+
if (!existsSync(prdPath)) {
|
|
257
|
+
console.log(chalk.bold(`\n📊 ${featureName}\n`));
|
|
258
|
+
console.log(chalk.dim(`No prd.json found. Run: nax plan -f ${featureName} --from <spec>`));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
243
262
|
const prd = await loadPRD(prdPath);
|
|
244
263
|
const counts = countStories(prd);
|
|
245
264
|
|
|
@@ -159,6 +159,10 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
|
|
|
159
159
|
logger?.warn("precheck", "Precheck validations skipped (--skip-precheck)");
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Sweep stale ACP sessions from previous crashed runs (safety net)
|
|
163
|
+
const { sweepStaleFeatureSessions } = await import("../../agents/acp/adapter");
|
|
164
|
+
await sweepStaleFeatureSessions(workdir, feature).catch(() => {});
|
|
165
|
+
|
|
162
166
|
// Acquire lock to prevent concurrent execution
|
|
163
167
|
const lockAcquired = await acquireLock(workdir);
|
|
164
168
|
if (!lockAcquired) {
|
package/src/execution/runner.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - runner-completion.ts: Acceptance loop, hooks, metrics
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { sweepFeatureSessions } from "../agents/acp/adapter";
|
|
16
17
|
import { createAgentRegistry } from "../agents/registry";
|
|
17
18
|
import type { NaxConfig } from "../config";
|
|
18
19
|
import type { LoadedHooksConfig } from "../hooks";
|
|
@@ -241,6 +242,9 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
241
242
|
// Cleanup crash handlers (MEM-1 fix)
|
|
242
243
|
cleanupCrashHandlers();
|
|
243
244
|
|
|
245
|
+
// Sweep any remaining open ACP sessions for this feature
|
|
246
|
+
await sweepFeatureSessions(workdir, feature).catch(() => {});
|
|
247
|
+
|
|
244
248
|
// Execute cleanup operations
|
|
245
249
|
const { cleanupRun } = await import("./lifecycle/run-cleanup");
|
|
246
250
|
await cleanupRun({
|
package/src/precheck/index.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* Runs all prechecks with formatted output. Stops on first Tier 1 blocker (fail-fast).
|
|
5
5
|
* Collects all Tier 2 warnings. Formats human-readable output with emoji indicators.
|
|
6
6
|
* Supports --json flag for machine-readable output.
|
|
7
|
+
*
|
|
8
|
+
* Check categories:
|
|
9
|
+
* - **Environment checks** — no PRD needed (git, deps, agent CLI, stale lock)
|
|
10
|
+
* - **Project checks** — require PRD (validation, story counts, story size gate)
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import type { NaxConfig } from "../config";
|
|
@@ -80,8 +84,136 @@ export interface PrecheckResultWithCode {
|
|
|
80
84
|
flaggedStories?: import("./story-size-gate").FlaggedStory[];
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Check list definitions — shared between runEnvironmentPrecheck and runPrecheck
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
type CheckFn = () => Promise<Check | Check[]>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Early environment checks — git repo, clean tree, stale lock.
|
|
95
|
+
* Fast checks that run first in both runEnvironmentPrecheck and runPrecheck.
|
|
96
|
+
* In runPrecheck, PRD validation is inserted after these (original order preserved).
|
|
97
|
+
*/
|
|
98
|
+
function getEarlyEnvironmentBlockers(workdir: string): CheckFn[] {
|
|
99
|
+
return [() => checkGitRepoExists(workdir), () => checkWorkingTreeClean(workdir), () => checkStaleLock(workdir)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Late environment checks — agent CLI, deps, commands, git user.
|
|
104
|
+
* Run after PRD validation in runPrecheck; all included in runEnvironmentPrecheck.
|
|
105
|
+
*/
|
|
106
|
+
function getLateEnvironmentBlockers(config: NaxConfig, workdir: string): CheckFn[] {
|
|
107
|
+
return [
|
|
108
|
+
() => checkAgentCLI(config),
|
|
109
|
+
() => checkDependenciesInstalled(workdir),
|
|
110
|
+
() => checkTestCommand(config),
|
|
111
|
+
() => checkLintCommand(config),
|
|
112
|
+
() => checkTypecheckCommand(config),
|
|
113
|
+
() => checkGitUserConfigured(workdir),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** All environment checks — no PRD needed. Used by runEnvironmentPrecheck. */
|
|
118
|
+
function getEnvironmentBlockers(config: NaxConfig, workdir: string): CheckFn[] {
|
|
119
|
+
return [...getEarlyEnvironmentBlockers(workdir), ...getLateEnvironmentBlockers(config, workdir)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Environment warnings — no PRD needed. */
|
|
123
|
+
function getEnvironmentWarnings(config: NaxConfig, workdir: string): CheckFn[] {
|
|
124
|
+
return [
|
|
125
|
+
() => checkClaudeMdExists(workdir),
|
|
126
|
+
() => checkDiskSpace(),
|
|
127
|
+
() => checkOptionalCommands(config, workdir),
|
|
128
|
+
() => checkGitignoreCoversNax(workdir),
|
|
129
|
+
() => checkPromptOverrideFiles(config, workdir),
|
|
130
|
+
() => checkMultiAgentHealth(),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Project checks — require PRD. */
|
|
135
|
+
function getProjectBlockers(prd: PRD): CheckFn[] {
|
|
136
|
+
return [() => checkPRDValid(prd)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Project warnings — require PRD. */
|
|
140
|
+
function getProjectWarnings(prd: PRD): CheckFn[] {
|
|
141
|
+
return [() => checkPendingStories(prd)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Normalize check result to array (some checks return Check[]) */
|
|
145
|
+
function normalizeChecks(result: Check | Check[]): Check[] {
|
|
146
|
+
return Array.isArray(result) ? result : [result];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Result from environment-only precheck */
|
|
150
|
+
export interface EnvironmentPrecheckResult {
|
|
151
|
+
/** Whether all environment checks passed (no blockers) */
|
|
152
|
+
passed: boolean;
|
|
153
|
+
/** Blocker check results */
|
|
154
|
+
blockers: Check[];
|
|
155
|
+
/** Warning check results */
|
|
156
|
+
warnings: Check[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run environment-only prechecks (no PRD needed).
|
|
161
|
+
*
|
|
162
|
+
* Use before plan phase to catch environment issues early,
|
|
163
|
+
* before expensive LLM calls are made.
|
|
164
|
+
*/
|
|
165
|
+
export async function runEnvironmentPrecheck(
|
|
166
|
+
config: NaxConfig,
|
|
167
|
+
workdir: string,
|
|
168
|
+
options?: { format?: "human" | "json"; silent?: boolean },
|
|
169
|
+
): Promise<EnvironmentPrecheckResult> {
|
|
170
|
+
const format = options?.format ?? "human";
|
|
171
|
+
const silent = options?.silent ?? false;
|
|
172
|
+
|
|
173
|
+
const passed: Check[] = [];
|
|
174
|
+
const blockers: Check[] = [];
|
|
175
|
+
const warnings: Check[] = [];
|
|
176
|
+
|
|
177
|
+
// Environment blockers — fail-fast
|
|
178
|
+
for (const checkFn of getEnvironmentBlockers(config, workdir)) {
|
|
179
|
+
const checks = normalizeChecks(await checkFn());
|
|
180
|
+
let blocked = false;
|
|
181
|
+
for (const check of checks) {
|
|
182
|
+
if (!silent && format === "human") printCheckResult(check);
|
|
183
|
+
if (check.passed) {
|
|
184
|
+
passed.push(check);
|
|
185
|
+
} else {
|
|
186
|
+
blockers.push(check);
|
|
187
|
+
blocked = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (blocked) break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Environment warnings — only if no blockers
|
|
195
|
+
if (blockers.length === 0) {
|
|
196
|
+
for (const checkFn of getEnvironmentWarnings(config, workdir)) {
|
|
197
|
+
for (const check of normalizeChecks(await checkFn())) {
|
|
198
|
+
if (!silent && format === "human") printCheckResult(check);
|
|
199
|
+
if (check.passed) {
|
|
200
|
+
passed.push(check);
|
|
201
|
+
} else {
|
|
202
|
+
warnings.push(check);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!silent && format === "json") {
|
|
209
|
+
console.log(JSON.stringify({ passed: blockers.length === 0, blockers, warnings }, null, 2));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { passed: blockers.length === 0, blockers, warnings };
|
|
213
|
+
}
|
|
214
|
+
|
|
83
215
|
/**
|
|
84
|
-
* Run all precheck validations.
|
|
216
|
+
* Run all precheck validations (environment + project).
|
|
85
217
|
* Returns result, exit code, and formatted output.
|
|
86
218
|
*/
|
|
87
219
|
export async function runPrecheck(
|
|
@@ -98,67 +230,46 @@ export async function runPrecheck(
|
|
|
98
230
|
const warnings: Check[] = [];
|
|
99
231
|
|
|
100
232
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
-
// Tier 1 Blockers
|
|
233
|
+
// Tier 1 Blockers — environment + project, fail-fast on first failure
|
|
102
234
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
235
|
|
|
236
|
+
// Original order preserved: early env → PRD valid → late env
|
|
237
|
+
// checkPRDValid at position 4 ensures test environments that lack agent CLI
|
|
238
|
+
// still get EXIT_CODES.INVALID_PRD (2) rather than a generic blocker (1)
|
|
104
239
|
const tier1Checks = [
|
|
105
|
-
(
|
|
106
|
-
()
|
|
107
|
-
(
|
|
108
|
-
() => checkPRDValid(prd),
|
|
109
|
-
() => checkAgentCLI(config),
|
|
110
|
-
() => checkDependenciesInstalled(workdir),
|
|
111
|
-
() => checkTestCommand(config),
|
|
112
|
-
() => checkLintCommand(config),
|
|
113
|
-
() => checkTypecheckCommand(config),
|
|
114
|
-
() => checkGitUserConfigured(workdir),
|
|
240
|
+
...getEarlyEnvironmentBlockers(workdir),
|
|
241
|
+
...getProjectBlockers(prd),
|
|
242
|
+
...getLateEnvironmentBlockers(config, workdir),
|
|
115
243
|
];
|
|
116
244
|
|
|
245
|
+
let tier1Blocked = false;
|
|
117
246
|
for (const checkFn of tier1Checks) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
blockers.push(result);
|
|
128
|
-
// Fail-fast: stop on first blocker
|
|
129
|
-
break;
|
|
247
|
+
for (const check of normalizeChecks(await checkFn())) {
|
|
248
|
+
if (format === "human") printCheckResult(check);
|
|
249
|
+
if (check.passed) {
|
|
250
|
+
passed.push(check);
|
|
251
|
+
} else {
|
|
252
|
+
blockers.push(check);
|
|
253
|
+
tier1Blocked = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
130
256
|
}
|
|
257
|
+
if (tier1Blocked) break;
|
|
131
258
|
}
|
|
132
259
|
|
|
133
260
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
-
// Tier 2 Warnings
|
|
261
|
+
// Tier 2 Warnings — environment + project, run all regardless of failures
|
|
135
262
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
263
|
|
|
137
264
|
let flaggedStories: import("./story-size-gate").FlaggedStory[] = [];
|
|
138
265
|
|
|
139
266
|
// Only run Tier 2 if no blockers
|
|
140
267
|
if (blockers.length === 0) {
|
|
141
|
-
const tier2Checks = [
|
|
142
|
-
() => checkClaudeMdExists(workdir),
|
|
143
|
-
() => checkDiskSpace(),
|
|
144
|
-
() => checkPendingStories(prd),
|
|
145
|
-
() => checkOptionalCommands(config, workdir),
|
|
146
|
-
() => checkGitignoreCoversNax(workdir),
|
|
147
|
-
() => checkPromptOverrideFiles(config, workdir),
|
|
148
|
-
() => checkMultiAgentHealth(),
|
|
149
|
-
];
|
|
268
|
+
const tier2Checks = [...getEnvironmentWarnings(config, workdir), ...getProjectWarnings(prd)];
|
|
150
269
|
|
|
151
270
|
for (const checkFn of tier2Checks) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
// Handle both single checks and arrays of checks
|
|
155
|
-
const checksToProcess = Array.isArray(result) ? result : [result];
|
|
156
|
-
|
|
157
|
-
for (const check of checksToProcess) {
|
|
158
|
-
if (format === "human") {
|
|
159
|
-
printCheckResult(check);
|
|
160
|
-
}
|
|
161
|
-
|
|
271
|
+
for (const check of normalizeChecks(await checkFn())) {
|
|
272
|
+
if (format === "human") printCheckResult(check);
|
|
162
273
|
if (check.passed) {
|
|
163
274
|
passed.push(check);
|
|
164
275
|
} else {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Used by: src/pipeline/stages/rectify.ts, src/execution/lifecycle/run-regression.ts
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { getAgent } from "../agents";
|
|
10
|
+
import { getAgent as _getAgent } from "../agents";
|
|
11
11
|
import type { NaxConfig } from "../config";
|
|
12
12
|
import { resolveModel } from "../config";
|
|
13
13
|
import { resolvePermissions } from "../config/permissions";
|
|
@@ -16,7 +16,7 @@ import { getSafeLogger } from "../logger";
|
|
|
16
16
|
import type { UserStory } from "../prd";
|
|
17
17
|
import { getExpectedFiles } from "../prd";
|
|
18
18
|
import { type RectificationState, createRectificationPrompt, shouldRetryRectification } from "./rectification";
|
|
19
|
-
import { fullSuite as
|
|
19
|
+
import { fullSuite as _fullSuite } from "./runners";
|
|
20
20
|
|
|
21
21
|
export interface RectificationLoopOptions {
|
|
22
22
|
config: NaxConfig;
|
|
@@ -26,11 +26,21 @@ export interface RectificationLoopOptions {
|
|
|
26
26
|
timeoutSeconds: number;
|
|
27
27
|
testOutput: string;
|
|
28
28
|
promptPrefix?: string;
|
|
29
|
+
featureName?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Injectable dependencies
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const _rectificationDeps = {
|
|
37
|
+
getAgent: _getAgent as (name: string) => import("../agents/types").AgentAdapter | undefined,
|
|
38
|
+
runVerification: _fullSuite as typeof _fullSuite,
|
|
39
|
+
};
|
|
40
|
+
|
|
31
41
|
/** Run the rectification retry loop. Returns true if all failures were fixed. */
|
|
32
42
|
export async function runRectificationLoop(opts: RectificationLoopOptions): Promise<boolean> {
|
|
33
|
-
const { config, workdir, story, testCommand, timeoutSeconds, testOutput, promptPrefix } = opts;
|
|
43
|
+
const { config, workdir, story, testCommand, timeoutSeconds, testOutput, promptPrefix, featureName } = opts;
|
|
34
44
|
const logger = getSafeLogger();
|
|
35
45
|
const rectificationConfig = config.execution.rectification;
|
|
36
46
|
const testSummary = parseBunTestOutput(testOutput);
|
|
@@ -59,7 +69,7 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
|
|
|
59
69
|
let rectificationPrompt = createRectificationPrompt(testSummary.failures, story, rectificationConfig);
|
|
60
70
|
if (promptPrefix) rectificationPrompt = `${promptPrefix}\n\n${rectificationPrompt}`;
|
|
61
71
|
|
|
62
|
-
const agent = getAgent(config.autoMode.defaultAgent);
|
|
72
|
+
const agent = _rectificationDeps.getAgent(config.autoMode.defaultAgent);
|
|
63
73
|
if (!agent) {
|
|
64
74
|
logger?.error("rectification", "Agent not found, cannot retry");
|
|
65
75
|
break;
|
|
@@ -78,6 +88,9 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
|
|
|
78
88
|
pipelineStage: "rectification",
|
|
79
89
|
config,
|
|
80
90
|
maxInteractionTurns: config.agent?.maxInteractionTurns,
|
|
91
|
+
featureName,
|
|
92
|
+
storyId: story.id,
|
|
93
|
+
sessionRole: "implementer",
|
|
81
94
|
});
|
|
82
95
|
|
|
83
96
|
if (agentResult.success) {
|
|
@@ -94,7 +107,7 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
|
|
|
94
107
|
});
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
const retryVerification = await runVerification({
|
|
110
|
+
const retryVerification = await _rectificationDeps.runVerification({
|
|
98
111
|
workdir,
|
|
99
112
|
expectedFiles: getExpectedFiles(story),
|
|
100
113
|
command: testCommand,
|