@nathapp/nax 0.43.0 → 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 CHANGED
@@ -69,7 +69,7 @@ import { unlockCommand } from "../src/commands/unlock";
69
69
  import { DEFAULT_CONFIG, findProjectDir, loadConfig, validateDirectory } from "../src/config";
70
70
  import { run } from "../src/execution";
71
71
  import { loadHooksConfig } from "../src/hooks";
72
- import { type LogLevel, initLogger } from "../src/logger";
72
+ import { type LogLevel, initLogger, resetLogger } from "../src/logger";
73
73
  import { countStories, loadPRD } from "../src/prd";
74
74
  import { PipelineEventEmitter, type StoryDisplayState, renderTui } from "../src/tui";
75
75
  import { NAX_VERSION } from "../src/version";
@@ -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,7 +344,35 @@ 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 {
369
+ // Initialize plan logger before calling planCommand — writes to features/<feature>/plan-<ts>.jsonl
370
+ mkdirSync(featureDir, { recursive: true });
371
+ const planLogId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
372
+ const planLogPath = join(featureDir, `plan-${planLogId}.jsonl`);
373
+ initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
374
+ console.log(chalk.dim(` [Plan log: ${planLogPath}]`));
375
+
347
376
  console.log(chalk.dim(" [Planning phase: generating PRD from spec]"));
348
377
  const generatedPrdPath = await planCommand(workdir, config, {
349
378
  from: options.from,
@@ -391,6 +420,9 @@ program
391
420
  process.exit(1);
392
421
  }
393
422
 
423
+ // Reset plan logger (if plan phase ran) so the run logger can be initialized fresh
424
+ resetLogger();
425
+
394
426
  // Create run directory and JSONL log file path
395
427
  const runsDir = join(featureDir, "runs");
396
428
  mkdirSync(runsDir, { 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
- await closeAcpSession(session);
19504
- await client.close().catch(() => {});
19505
- if (options.featureName && options.storyId) {
19506
- await clearAcpSession(options.workdir, options.featureName, options.storyId);
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.43.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("d725ea0"))
22045
- return "d725ea0";
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 fullSuite({
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
- () => checkGitRepoExists(workdir),
30465
- () => checkWorkingTreeClean(workdir),
30466
- () => checkStaleLock(workdir),
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 result = await checkFn();
30477
- if (format === "human") {
30478
- printCheckResult(result);
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 (result.passed) {
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 result = await checkFn();
30500
- const checksToProcess = Array.isArray(result) ? result : [result];
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,7 +76971,31 @@ 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 {
76994
+ mkdirSync6(featureDir, { recursive: true });
76995
+ const planLogId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
76996
+ const planLogPath = join43(featureDir, `plan-${planLogId}.jsonl`);
76997
+ initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
76998
+ console.log(source_default.dim(` [Plan log: ${planLogPath}]`));
76810
76999
  console.log(source_default.dim(" [Planning phase: generating PRD from spec]"));
76811
77000
  const generatedPrdPath = await planCommand(workdir, config2, {
76812
77001
  from: options.from,
@@ -76841,6 +77030,7 @@ program2.command("run").description("Run the orchestration loop for a feature").
76841
77030
  console.error(source_default.red(`Feature "${options.feature}" not found or missing prd.json`));
76842
77031
  process.exit(1);
76843
77032
  }
77033
+ resetLogger();
76844
77034
  const runsDir = join43(featureDir, "runs");
76845
77035
  mkdirSync6(runsDir, { recursive: true });
76846
77036
  const runId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 — always close session and client, then clear sidecar
530
- await closeAcpSession(session);
531
- await client.close().catch(() => {});
532
- if (options.featureName && options.storyId) {
533
- await clearAcpSession(options.workdir, options.featureName, options.storyId);
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) {
@@ -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({
@@ -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 - fail-fast on first failure
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
- () => checkGitRepoExists(workdir),
106
- () => checkWorkingTreeClean(workdir),
107
- () => checkStaleLock(workdir),
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 result = await checkFn();
119
-
120
- if (format === "human") {
121
- printCheckResult(result);
122
- }
123
-
124
- if (result.passed) {
125
- passed.push(result);
126
- } else {
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 - run all regardless of failures
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 result = await checkFn();
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 runVerification } from "./runners";
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,