@nathapp/nax 0.30.0 → 0.31.1

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
@@ -54,6 +54,7 @@ import {
54
54
  planCommand,
55
55
  pluginsListCommand,
56
56
  promptsCommand,
57
+ promptsInitCommand,
57
58
  runsListCommand,
58
59
  runsShowCommand,
59
60
  } from "../src/cli";
@@ -849,8 +850,10 @@ program
849
850
  // ── prompts ──────────────────────────────────────────
850
851
  program
851
852
  .command("prompts")
852
- .description("Assemble prompts for stories without executing agents")
853
- .requiredOption("-f, --feature <name>", "Feature name")
853
+ .description("Assemble or initialize prompts")
854
+ .option("-f, --feature <name>", "Feature name (required unless using --init)")
855
+ .option("--init", "Initialize default prompt templates", false)
856
+ .option("--force", "Overwrite existing template files", false)
854
857
  .option("--story <id>", "Filter to a single story ID (e.g., US-003)")
855
858
  .option("--out <dir>", "Output directory for prompt files (default: stdout)")
856
859
  .option("-d, --dir <path>", "Project directory", process.cwd())
@@ -864,6 +867,26 @@ program
864
867
  process.exit(1);
865
868
  }
866
869
 
870
+ // Handle --init command
871
+ if (options.init) {
872
+ try {
873
+ await promptsInitCommand({
874
+ workdir,
875
+ force: options.force,
876
+ });
877
+ } catch (err) {
878
+ console.error(chalk.red(`Error: ${(err as Error).message}`));
879
+ process.exit(1);
880
+ }
881
+ return;
882
+ }
883
+
884
+ // Handle regular prompts command (requires --feature)
885
+ if (!options.feature) {
886
+ console.error(chalk.red("Error: --feature is required (unless using --init)"));
887
+ process.exit(1);
888
+ }
889
+
867
890
  // Load config
868
891
  const config = await loadConfig(workdir);
869
892
 
package/dist/nax.js CHANGED
@@ -19505,7 +19505,7 @@ var package_default;
19505
19505
  var init_package = __esm(() => {
19506
19506
  package_default = {
19507
19507
  name: "@nathapp/nax",
19508
- version: "0.30.0",
19508
+ version: "0.31.1",
19509
19509
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
19510
19510
  type: "module",
19511
19511
  bin: {
@@ -19567,8 +19567,8 @@ var init_version = __esm(() => {
19567
19567
  NAX_VERSION = package_default.version;
19568
19568
  NAX_COMMIT = (() => {
19569
19569
  try {
19570
- if (/^[0-9a-f]{6,10}$/.test("f7c3de1"))
19571
- return "f7c3de1";
19570
+ if (/^[0-9a-f]{6,10}$/.test("ab045bf"))
19571
+ return "ab045bf";
19572
19572
  } catch {}
19573
19573
  try {
19574
19574
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -19621,6 +19621,11 @@ async function loadPRD(path) {
19621
19621
  story.escalations = story.escalations ?? [];
19622
19622
  story.dependencies = story.dependencies ?? [];
19623
19623
  story.tags = story.tags ?? [];
19624
+ const rawStatus = story.status;
19625
+ if (rawStatus === "open")
19626
+ story.status = "pending";
19627
+ if (rawStatus === "done")
19628
+ story.status = "passed";
19624
19629
  story.status = story.status ?? "pending";
19625
19630
  story.acceptanceCriteria = story.acceptanceCriteria ?? [];
19626
19631
  story.storyPoints = story.storyPoints ?? 1;
@@ -23980,7 +23985,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
23980
23985
  prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" }).withLoader(workdir, config2).story(story).context(contextMarkdown).build();
23981
23986
  break;
23982
23987
  case "verifier":
23983
- prompt = await PromptBuilder.for("verifier").withLoader(workdir, config2).story(story).build();
23988
+ prompt = await PromptBuilder.for("verifier").withLoader(workdir, config2).story(story).context(contextMarkdown).build();
23984
23989
  break;
23985
23990
  }
23986
23991
  const logger = getLogger();
@@ -23996,6 +24001,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
23996
24001
  if (!result.success && result.pid) {
23997
24002
  await cleanupProcessTree(result.pid);
23998
24003
  }
24004
+ await autoCommitIfDirty(workdir, role, story.id);
23999
24005
  let isolation;
24000
24006
  if (!skipIsolation) {
24001
24007
  if (role === "test-writer") {
@@ -24042,6 +24048,38 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
24042
24048
  estimatedCost: result.estimatedCost
24043
24049
  };
24044
24050
  }
24051
+ async function autoCommitIfDirty(workdir, role, storyId) {
24052
+ const logger = getLogger();
24053
+ try {
24054
+ const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
24055
+ cwd: workdir,
24056
+ stdout: "pipe",
24057
+ stderr: "pipe"
24058
+ });
24059
+ const statusOutput = await new Response(statusProc.stdout).text();
24060
+ await statusProc.exited;
24061
+ if (!statusOutput.trim())
24062
+ return;
24063
+ logger.warn("tdd", `Agent did not commit after ${role} session \u2014 auto-committing`, {
24064
+ role,
24065
+ storyId,
24066
+ dirtyFiles: statusOutput.trim().split(`
24067
+ `).length
24068
+ });
24069
+ const addProc = Bun.spawn(["git", "add", "-A"], {
24070
+ cwd: workdir,
24071
+ stdout: "pipe",
24072
+ stderr: "pipe"
24073
+ });
24074
+ await addProc.exited;
24075
+ const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
24076
+ cwd: workdir,
24077
+ stdout: "pipe",
24078
+ stderr: "pipe"
24079
+ });
24080
+ await commitProc.exited;
24081
+ } catch {}
24082
+ }
24045
24083
  var init_session_runner = __esm(() => {
24046
24084
  init_config();
24047
24085
  init_logger2();
@@ -24492,6 +24530,34 @@ function routeTddFailure(failureCategory, isLiteMode, ctx, reviewReason) {
24492
24530
  reason: reviewReason || "Three-session TDD requires review"
24493
24531
  };
24494
24532
  }
24533
+ async function autoCommitIfDirty2(workdir, role, storyId) {
24534
+ try {
24535
+ const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
24536
+ cwd: workdir,
24537
+ stdout: "pipe",
24538
+ stderr: "pipe"
24539
+ });
24540
+ const statusOutput = await new Response(statusProc.stdout).text();
24541
+ await statusProc.exited;
24542
+ if (!statusOutput.trim())
24543
+ return;
24544
+ const logger = getLogger();
24545
+ logger.warn("execution", `Agent did not commit after ${role} session \u2014 auto-committing`, {
24546
+ role,
24547
+ storyId,
24548
+ dirtyFiles: statusOutput.trim().split(`
24549
+ `).length
24550
+ });
24551
+ const addProc = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
24552
+ await addProc.exited;
24553
+ const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
24554
+ cwd: workdir,
24555
+ stdout: "pipe",
24556
+ stderr: "pipe"
24557
+ });
24558
+ await commitProc.exited;
24559
+ } catch {}
24560
+ }
24495
24561
  var executionStage, _executionDeps;
24496
24562
  var init_execution = __esm(() => {
24497
24563
  init_agents();
@@ -24573,6 +24639,7 @@ var init_execution = __esm(() => {
24573
24639
  dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions
24574
24640
  });
24575
24641
  ctx.agentResult = result;
24642
+ await autoCommitIfDirty2(ctx.workdir, "single-session", ctx.story.id);
24576
24643
  const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
24577
24644
  if (_executionDeps.detectMergeConflict(combinedOutput) && ctx.interaction && isTriggerEnabled("merge-conflict", ctx.config)) {
24578
24645
  const shouldProceed = await _executionDeps.checkMergeConflict({ featureName: ctx.prd.feature, storyId: ctx.story.id }, ctx.config, ctx.interaction);
@@ -26889,14 +26956,14 @@ async function checkPendingStories(prd) {
26889
26956
  message: passed ? `${pendingStories.length} pending stories found` : "no pending stories to execute"
26890
26957
  };
26891
26958
  }
26892
- async function checkOptionalCommands(config2) {
26959
+ async function checkOptionalCommands(config2, workdir) {
26893
26960
  const missing = [];
26894
- if (!config2.execution.lintCommand) {
26961
+ const hasLint = config2.quality?.commands?.lint || config2.execution?.lintCommand || await hasPackageScript(workdir, "lint");
26962
+ const hasTypecheck = config2.quality?.commands?.typecheck || config2.execution?.typecheckCommand || await hasPackageScript(workdir, "typecheck");
26963
+ if (!hasLint)
26895
26964
  missing.push("lint");
26896
- }
26897
- if (!config2.execution.typecheckCommand) {
26965
+ if (!hasTypecheck)
26898
26966
  missing.push("typecheck");
26899
- }
26900
26967
  const passed = missing.length === 0;
26901
26968
  return {
26902
26969
  name: "optional-commands-configured",
@@ -26905,6 +26972,14 @@ async function checkOptionalCommands(config2) {
26905
26972
  message: passed ? "All optional commands configured" : `Optional commands not configured: ${missing.join(", ")}`
26906
26973
  };
26907
26974
  }
26975
+ async function hasPackageScript(workdir, name) {
26976
+ try {
26977
+ const pkg = await Bun.file(`${workdir}/package.json`).json();
26978
+ return Boolean(pkg?.scripts?.[name]);
26979
+ } catch {
26980
+ return false;
26981
+ }
26982
+ }
26908
26983
  async function checkGitignoreCoversNax(workdir) {
26909
26984
  const gitignorePath = `${workdir}/.gitignore`;
26910
26985
  const exists = existsSync23(gitignorePath);
@@ -27096,7 +27171,7 @@ async function runPrecheck(config2, prd, options) {
27096
27171
  () => checkClaudeMdExists(workdir),
27097
27172
  () => checkDiskSpace(),
27098
27173
  () => checkPendingStories(prd),
27099
- () => checkOptionalCommands(config2),
27174
+ () => checkOptionalCommands(config2, workdir),
27100
27175
  () => checkGitignoreCoversNax(workdir),
27101
27176
  () => checkPromptOverrideFiles(config2, workdir)
27102
27177
  ];
@@ -62763,11 +62838,119 @@ function buildFrontmatter(story, ctx, role) {
62763
62838
  `)}
62764
62839
  `;
62765
62840
  }
62841
+ var TEMPLATE_ROLES = [
62842
+ { file: "test-writer.md", role: "test-writer" },
62843
+ { file: "implementer.md", role: "implementer", variant: "standard" },
62844
+ { file: "verifier.md", role: "verifier" },
62845
+ { file: "single-session.md", role: "single-session" }
62846
+ ];
62847
+ var TEMPLATE_HEADER = `<!--
62848
+ This file controls the role-body section of the nax prompt for this role.
62849
+ Edit the content below to customize the task instructions given to the agent.
62850
+
62851
+ NON-OVERRIDABLE SECTIONS (always injected by nax, cannot be changed here):
62852
+ - Isolation rules (scope, file access boundaries)
62853
+ - Story context (acceptance criteria, description, dependencies)
62854
+ - Conventions (project coding standards)
62855
+
62856
+ To activate overrides, add to your nax/config.json:
62857
+ { "prompts": { "overrides": { "<role>": "nax/templates/<role>.md" } } }
62858
+ -->
62859
+
62860
+ `;
62861
+ async function promptsInitCommand(options) {
62862
+ const { workdir, force = false } = options;
62863
+ const templatesDir = join18(workdir, "nax", "templates");
62864
+ mkdirSync3(templatesDir, { recursive: true });
62865
+ const existingFiles = TEMPLATE_ROLES.map((t) => t.file).filter((f) => existsSync15(join18(templatesDir, f)));
62866
+ if (existingFiles.length > 0 && !force) {
62867
+ console.warn(`[WARN] nax/templates/ already contains files: ${existingFiles.join(", ")}. No files overwritten.
62868
+ Pass --force to overwrite existing templates.`);
62869
+ return [];
62870
+ }
62871
+ const written = [];
62872
+ for (const template of TEMPLATE_ROLES) {
62873
+ const filePath = join18(templatesDir, template.file);
62874
+ const roleBody = template.role === "implementer" ? buildRoleTaskSection(template.role, template.variant) : buildRoleTaskSection(template.role);
62875
+ const content = TEMPLATE_HEADER + roleBody;
62876
+ await Bun.write(filePath, content);
62877
+ written.push(filePath);
62878
+ }
62879
+ console.log(`[OK] Written ${written.length} template files to nax/templates/:`);
62880
+ for (const filePath of written) {
62881
+ console.log(` - ${filePath.replace(`${workdir}/`, "")}`);
62882
+ }
62883
+ await autoWirePromptsConfig(workdir);
62884
+ return written;
62885
+ }
62886
+ async function autoWirePromptsConfig(workdir) {
62887
+ const configPath = join18(workdir, "nax.config.json");
62888
+ if (!existsSync15(configPath)) {
62889
+ const exampleConfig = JSON.stringify({
62890
+ prompts: {
62891
+ overrides: {
62892
+ "test-writer": "nax/templates/test-writer.md",
62893
+ implementer: "nax/templates/implementer.md",
62894
+ verifier: "nax/templates/verifier.md",
62895
+ "single-session": "nax/templates/single-session.md"
62896
+ }
62897
+ }
62898
+ }, null, 2);
62899
+ console.log(`
62900
+ No nax.config.json found. To activate overrides, create nax/config.json with:
62901
+ ${exampleConfig}`);
62902
+ return;
62903
+ }
62904
+ const configFile = Bun.file(configPath);
62905
+ const configContent = await configFile.text();
62906
+ const config2 = JSON.parse(configContent);
62907
+ if (config2.prompts?.overrides && Object.keys(config2.prompts.overrides).length > 0) {
62908
+ console.log(`[INFO] prompts.overrides already configured in nax.config.json. Skipping auto-wiring.
62909
+ ` + " To reset overrides, remove the prompts.overrides section and re-run this command.");
62910
+ return;
62911
+ }
62912
+ const overrides = {
62913
+ "test-writer": "nax/templates/test-writer.md",
62914
+ implementer: "nax/templates/implementer.md",
62915
+ verifier: "nax/templates/verifier.md",
62916
+ "single-session": "nax/templates/single-session.md"
62917
+ };
62918
+ if (!config2.prompts) {
62919
+ config2.prompts = {};
62920
+ }
62921
+ config2.prompts.overrides = overrides;
62922
+ const updatedConfig = formatConfigJson(config2);
62923
+ await Bun.write(configPath, updatedConfig);
62924
+ console.log("[OK] Auto-wired prompts.overrides in nax.config.json");
62925
+ }
62926
+ function formatConfigJson(config2) {
62927
+ const lines = ["{"];
62928
+ const keys = Object.keys(config2);
62929
+ for (let i = 0;i < keys.length; i++) {
62930
+ const key = keys[i];
62931
+ const value = config2[key];
62932
+ const isLast = i === keys.length - 1;
62933
+ if (key === "prompts" && typeof value === "object" && value !== null) {
62934
+ const promptsObj = value;
62935
+ if (promptsObj.overrides) {
62936
+ const overridesJson = JSON.stringify(promptsObj.overrides);
62937
+ lines.push(` "${key}": { "overrides": ${overridesJson} }${isLast ? "" : ","}`);
62938
+ } else {
62939
+ lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
62940
+ }
62941
+ } else {
62942
+ lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
62943
+ }
62944
+ }
62945
+ lines.push("}");
62946
+ return lines.join(`
62947
+ `);
62948
+ }
62766
62949
  async function handleThreeSessionTddPrompts(story, ctx, outputDir, logger) {
62767
62950
  const [testWriterPrompt, implementerPrompt, verifierPrompt] = await Promise.all([
62768
62951
  PromptBuilder.for("test-writer", { isolation: "strict" }).withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
62769
62952
  PromptBuilder.for("implementer", { variant: "standard" }).withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
62770
- PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).build()
62953
+ PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build()
62771
62954
  ]);
62772
62955
  const sessions = [
62773
62956
  { role: "test-writer", prompt: testWriterPrompt },
@@ -72689,7 +72872,7 @@ program2.command("accept").description("Override failed acceptance criteria").re
72689
72872
  process.exit(1);
72690
72873
  }
72691
72874
  });
72692
- program2.command("prompts").description("Assemble prompts for stories without executing agents").requiredOption("-f, --feature <name>", "Feature name").option("--story <id>", "Filter to a single story ID (e.g., US-003)").option("--out <dir>", "Output directory for prompt files (default: stdout)").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (options) => {
72875
+ program2.command("prompts").description("Assemble or initialize prompts").option("-f, --feature <name>", "Feature name (required unless using --init)").option("--init", "Initialize default prompt templates", false).option("--force", "Overwrite existing template files", false).option("--story <id>", "Filter to a single story ID (e.g., US-003)").option("--out <dir>", "Output directory for prompt files (default: stdout)").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (options) => {
72693
72876
  let workdir;
72694
72877
  try {
72695
72878
  workdir = validateDirectory(options.dir);
@@ -72697,6 +72880,22 @@ program2.command("prompts").description("Assemble prompts for stories without ex
72697
72880
  console.error(source_default.red(`Invalid directory: ${err.message}`));
72698
72881
  process.exit(1);
72699
72882
  }
72883
+ if (options.init) {
72884
+ try {
72885
+ await promptsInitCommand({
72886
+ workdir,
72887
+ force: options.force
72888
+ });
72889
+ } catch (err) {
72890
+ console.error(source_default.red(`Error: ${err.message}`));
72891
+ process.exit(1);
72892
+ }
72893
+ return;
72894
+ }
72895
+ if (!options.feature) {
72896
+ console.error(source_default.red("Error: --feature is required (unless using --init)"));
72897
+ process.exit(1);
72898
+ }
72700
72899
  const config2 = await loadConfig(workdir);
72701
72900
  try {
72702
72901
  const processedStories = await promptsCommand({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -20,7 +20,9 @@ export {
20
20
  } from "./runs";
21
21
  export {
22
22
  promptsCommand,
23
+ promptsInitCommand,
23
24
  type PromptsCommandOptions,
25
+ type PromptsInitCommandOptions,
24
26
  } from "./prompts";
25
27
  export { initCommand, type InitOptions } from "./init";
26
28
  export { pluginsListCommand } from "./plugins";
@@ -18,6 +18,7 @@ import { constitutionStage, contextStage, promptStage, routingStage } from "../p
18
18
  import type { UserStory } from "../prd";
19
19
  import { loadPRD } from "../prd";
20
20
  import { PromptBuilder } from "../prompts";
21
+ import { buildRoleTaskSection } from "../prompts/sections/role-task";
21
22
 
22
23
  export interface PromptsCommandOptions {
23
24
  /** Feature name */
@@ -240,6 +241,189 @@ function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string)
240
241
  return `${lines.join("\n")}\n`;
241
242
  }
242
243
 
244
+ export interface PromptsInitCommandOptions {
245
+ /** Working directory (project root) */
246
+ workdir: string;
247
+ /** Overwrite existing files if true */
248
+ force?: boolean;
249
+ }
250
+
251
+ const TEMPLATE_ROLES = [
252
+ { file: "test-writer.md", role: "test-writer" as const },
253
+ { file: "implementer.md", role: "implementer" as const, variant: "standard" as const },
254
+ { file: "verifier.md", role: "verifier" as const },
255
+ { file: "single-session.md", role: "single-session" as const },
256
+ ] as const;
257
+
258
+ const TEMPLATE_HEADER = `<!--
259
+ This file controls the role-body section of the nax prompt for this role.
260
+ Edit the content below to customize the task instructions given to the agent.
261
+
262
+ NON-OVERRIDABLE SECTIONS (always injected by nax, cannot be changed here):
263
+ - Isolation rules (scope, file access boundaries)
264
+ - Story context (acceptance criteria, description, dependencies)
265
+ - Conventions (project coding standards)
266
+
267
+ To activate overrides, add to your nax/config.json:
268
+ { "prompts": { "overrides": { "<role>": "nax/templates/<role>.md" } } }
269
+ -->
270
+
271
+ `;
272
+
273
+ /**
274
+ * Execute the `nax prompts --init` command.
275
+ *
276
+ * Creates nax/templates/ and writes 4 default role-body template files.
277
+ * Auto-wires prompts.overrides in nax.config.json if the file exists and overrides are not already set.
278
+ * Returns the list of file paths written. Returns empty array if files
279
+ * already exist and force is not set.
280
+ *
281
+ * @param options - Command options
282
+ * @returns Array of file paths written
283
+ */
284
+ export async function promptsInitCommand(options: PromptsInitCommandOptions): Promise<string[]> {
285
+ const { workdir, force = false } = options;
286
+ const templatesDir = join(workdir, "nax", "templates");
287
+
288
+ mkdirSync(templatesDir, { recursive: true });
289
+
290
+ // Check for existing files
291
+ const existingFiles = TEMPLATE_ROLES.map((t) => t.file).filter((f) => existsSync(join(templatesDir, f)));
292
+
293
+ if (existingFiles.length > 0 && !force) {
294
+ console.warn(
295
+ `[WARN] nax/templates/ already contains files: ${existingFiles.join(", ")}. No files overwritten.\n Pass --force to overwrite existing templates.`,
296
+ );
297
+ return [];
298
+ }
299
+
300
+ const written: string[] = [];
301
+
302
+ for (const template of TEMPLATE_ROLES) {
303
+ const filePath = join(templatesDir, template.file);
304
+ const roleBody =
305
+ template.role === "implementer"
306
+ ? buildRoleTaskSection(template.role, template.variant)
307
+ : buildRoleTaskSection(template.role);
308
+ const content = TEMPLATE_HEADER + roleBody;
309
+ await Bun.write(filePath, content);
310
+ written.push(filePath);
311
+ }
312
+
313
+ console.log(`[OK] Written ${written.length} template files to nax/templates/:`);
314
+ for (const filePath of written) {
315
+ console.log(` - ${filePath.replace(`${workdir}/`, "")}`);
316
+ }
317
+
318
+ // Auto-wire prompts.overrides in nax.config.json
319
+ await autoWirePromptsConfig(workdir);
320
+
321
+ return written;
322
+ }
323
+
324
+ /**
325
+ * Auto-wire prompts.overrides in nax.config.json after template init.
326
+ *
327
+ * If nax.config.json exists and prompts.overrides is not already set,
328
+ * add the override paths. If overrides are already set, print a note.
329
+ * If nax.config.json doesn't exist, print manual instructions.
330
+ *
331
+ * @param workdir - Project working directory
332
+ */
333
+ async function autoWirePromptsConfig(workdir: string): Promise<void> {
334
+ const configPath = join(workdir, "nax.config.json");
335
+
336
+ // If config file doesn't exist, print manual instructions
337
+ if (!existsSync(configPath)) {
338
+ const exampleConfig = JSON.stringify(
339
+ {
340
+ prompts: {
341
+ overrides: {
342
+ "test-writer": "nax/templates/test-writer.md",
343
+ implementer: "nax/templates/implementer.md",
344
+ verifier: "nax/templates/verifier.md",
345
+ "single-session": "nax/templates/single-session.md",
346
+ },
347
+ },
348
+ },
349
+ null,
350
+ 2,
351
+ );
352
+ console.log(`\nNo nax.config.json found. To activate overrides, create nax/config.json with:\n${exampleConfig}`);
353
+ return;
354
+ }
355
+
356
+ // Read existing config
357
+ const configFile = Bun.file(configPath);
358
+ const configContent = await configFile.text();
359
+ const config = JSON.parse(configContent);
360
+
361
+ // Check if prompts.overrides is already set
362
+ if (config.prompts?.overrides && Object.keys(config.prompts.overrides).length > 0) {
363
+ console.log(
364
+ "[INFO] prompts.overrides already configured in nax.config.json. Skipping auto-wiring.\n" +
365
+ " To reset overrides, remove the prompts.overrides section and re-run this command.",
366
+ );
367
+ return;
368
+ }
369
+
370
+ // Build the override paths
371
+ const overrides = {
372
+ "test-writer": "nax/templates/test-writer.md",
373
+ implementer: "nax/templates/implementer.md",
374
+ verifier: "nax/templates/verifier.md",
375
+ "single-session": "nax/templates/single-session.md",
376
+ };
377
+
378
+ // Add or update prompts section
379
+ if (!config.prompts) {
380
+ config.prompts = {};
381
+ }
382
+ config.prompts.overrides = overrides;
383
+
384
+ // Write config with custom formatting that avoids 4-space indentation
385
+ // by putting the overrides object on a single line
386
+ const updatedConfig = formatConfigJson(config);
387
+ await Bun.write(configPath, updatedConfig);
388
+
389
+ console.log("[OK] Auto-wired prompts.overrides in nax.config.json");
390
+ }
391
+
392
+ /**
393
+ * Format config JSON with 2-space indentation, keeping overrides object inline.
394
+ *
395
+ * This avoids 4-space indentation by putting the overrides object on the same line.
396
+ *
397
+ * @param config - Configuration object
398
+ * @returns Formatted JSON string
399
+ */
400
+ function formatConfigJson(config: Record<string, unknown>): string {
401
+ const lines: string[] = ["{"];
402
+
403
+ const keys = Object.keys(config);
404
+ for (let i = 0; i < keys.length; i++) {
405
+ const key = keys[i];
406
+ const value = config[key];
407
+ const isLast = i === keys.length - 1;
408
+
409
+ if (key === "prompts" && typeof value === "object" && value !== null) {
410
+ // Special handling for prompts object - keep overrides inline
411
+ const promptsObj = value as Record<string, unknown>;
412
+ if (promptsObj.overrides) {
413
+ const overridesJson = JSON.stringify(promptsObj.overrides);
414
+ lines.push(` "${key}": { "overrides": ${overridesJson} }${isLast ? "" : ","}`);
415
+ } else {
416
+ lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
417
+ }
418
+ } else {
419
+ lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
420
+ }
421
+ }
422
+
423
+ lines.push("}");
424
+ return lines.join("\n");
425
+ }
426
+
243
427
  /**
244
428
  * Handle three-session TDD prompts by building separate prompts for each role.
245
429
  *
@@ -266,7 +450,7 @@ async function handleThreeSessionTddPrompts(
266
450
  .story(story)
267
451
  .context(ctx.contextMarkdown)
268
452
  .build(),
269
- PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).build(),
453
+ PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
270
454
  ]);
271
455
 
272
456
  const sessions = [
@@ -199,6 +199,9 @@ export const executionStage: PipelineStage = {
199
199
 
200
200
  ctx.agentResult = result;
201
201
 
202
+ // BUG-058: Auto-commit if agent left uncommitted changes (single-session/test-after)
203
+ await autoCommitIfDirty(ctx.workdir, "single-session", ctx.story.id);
204
+
202
205
  // merge-conflict trigger: detect CONFLICT markers in agent output
203
206
  const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
204
207
  if (
@@ -267,3 +270,40 @@ export const _executionDeps = {
267
270
  isAmbiguousOutput,
268
271
  checkStoryAmbiguity,
269
272
  };
273
+
274
+ /**
275
+ * BUG-058: Auto-commit safety net for single-session/test-after.
276
+ * Mirrors the same function in tdd/session-runner.ts for three-session TDD.
277
+ */
278
+ async function autoCommitIfDirty(workdir: string, role: string, storyId: string): Promise<void> {
279
+ try {
280
+ const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
281
+ cwd: workdir,
282
+ stdout: "pipe",
283
+ stderr: "pipe",
284
+ });
285
+ const statusOutput = await new Response(statusProc.stdout).text();
286
+ await statusProc.exited;
287
+
288
+ if (!statusOutput.trim()) return;
289
+
290
+ const logger = getLogger();
291
+ logger.warn("execution", `Agent did not commit after ${role} session — auto-committing`, {
292
+ role,
293
+ storyId,
294
+ dirtyFiles: statusOutput.trim().split("\n").length,
295
+ });
296
+
297
+ const addProc = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
298
+ await addProc.exited;
299
+
300
+ const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
301
+ cwd: workdir,
302
+ stdout: "pipe",
303
+ stderr: "pipe",
304
+ });
305
+ await commitProc.exited;
306
+ } catch {
307
+ // Silently ignore — auto-commit is best-effort
308
+ }
309
+ }
package/src/prd/index.ts CHANGED
@@ -49,6 +49,10 @@ export async function loadPRD(path: string): Promise<PRD> {
49
49
  story.escalations = story.escalations ?? [];
50
50
  story.dependencies = story.dependencies ?? [];
51
51
  story.tags = story.tags ?? [];
52
+ // Normalize aliases: "open" → "pending", "done" → "passed"
53
+ const rawStatus = story.status as string;
54
+ if (rawStatus === "open") story.status = "pending";
55
+ if (rawStatus === "done") story.status = "passed";
52
56
  story.status = story.status ?? "pending";
53
57
  story.acceptanceCriteria = story.acceptanceCriteria ?? [];
54
58
  story.storyPoints = story.storyPoints ?? 1;
@@ -90,15 +90,19 @@ export async function checkPendingStories(prd: PRD): Promise<Check> {
90
90
  /**
91
91
  * Check if optional commands are configured.
92
92
  */
93
- export async function checkOptionalCommands(config: NaxConfig): Promise<Check> {
93
+ export async function checkOptionalCommands(config: NaxConfig, workdir: string): Promise<Check> {
94
94
  const missing: string[] = [];
95
95
 
96
- if (!config.execution.lintCommand) {
97
- missing.push("lint");
98
- }
99
- if (!config.execution.typecheckCommand) {
100
- missing.push("typecheck");
101
- }
96
+ // Check quality.commands first, then execution config, then package.json fallback
97
+ const hasLint =
98
+ config.quality?.commands?.lint || config.execution?.lintCommand || (await hasPackageScript(workdir, "lint"));
99
+ const hasTypecheck =
100
+ config.quality?.commands?.typecheck ||
101
+ config.execution?.typecheckCommand ||
102
+ (await hasPackageScript(workdir, "typecheck"));
103
+
104
+ if (!hasLint) missing.push("lint");
105
+ if (!hasTypecheck) missing.push("typecheck");
102
106
 
103
107
  const passed = missing.length === 0;
104
108
 
@@ -110,6 +114,16 @@ export async function checkOptionalCommands(config: NaxConfig): Promise<Check> {
110
114
  };
111
115
  }
112
116
 
117
+ /** Check if package.json has a script by name */
118
+ async function hasPackageScript(workdir: string, name: string): Promise<boolean> {
119
+ try {
120
+ const pkg = await Bun.file(`${workdir}/package.json`).json();
121
+ return Boolean(pkg?.scripts?.[name]);
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
113
127
  /**
114
128
  * Check if .gitignore covers nax runtime files.
115
129
  * Patterns: nax.lock, runs/, test/tmp/
@@ -141,7 +141,7 @@ export async function runPrecheck(
141
141
  () => checkClaudeMdExists(workdir),
142
142
  () => checkDiskSpace(),
143
143
  () => checkPendingStories(prd),
144
- () => checkOptionalCommands(config),
144
+ () => checkOptionalCommands(config, workdir),
145
145
  () => checkGitignoreCoversNax(workdir),
146
146
  () => checkPromptOverrideFiles(config, workdir),
147
147
  ];
@@ -103,7 +103,11 @@ export async function runTddSession(
103
103
  .build();
104
104
  break;
105
105
  case "verifier":
106
- prompt = await PromptBuilder.for("verifier").withLoader(workdir, config).story(story).build();
106
+ prompt = await PromptBuilder.for("verifier")
107
+ .withLoader(workdir, config)
108
+ .story(story)
109
+ .context(contextMarkdown)
110
+ .build();
107
111
  break;
108
112
  }
109
113
 
@@ -125,6 +129,9 @@ export async function runTddSession(
125
129
  await cleanupProcessTree(result.pid);
126
130
  }
127
131
 
132
+ // BUG-058: Auto-commit if agent left uncommitted changes
133
+ await autoCommitIfDirty(workdir, role, story.id);
134
+
128
135
  // Check isolation based on role and skipIsolation flag.
129
136
  let isolation: IsolationCheck | undefined;
130
137
  if (!skipIsolation) {
@@ -177,3 +184,51 @@ export async function runTddSession(
177
184
  estimatedCost: result.estimatedCost,
178
185
  };
179
186
  }
187
+
188
+ /**
189
+ * BUG-058: Auto-commit safety net.
190
+ *
191
+ * If the agent left uncommitted changes, stage and commit them automatically.
192
+ * This prevents the review stage from failing with "uncommitted changes" errors.
193
+ * Only triggers when the agent forgot — if tree is clean, this is a no-op.
194
+ */
195
+ async function autoCommitIfDirty(workdir: string, role: string, storyId: string): Promise<void> {
196
+ const logger = getLogger();
197
+
198
+ // Check if working tree is dirty
199
+ try {
200
+ const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
201
+ cwd: workdir,
202
+ stdout: "pipe",
203
+ stderr: "pipe",
204
+ });
205
+ const statusOutput = await new Response(statusProc.stdout).text();
206
+ await statusProc.exited;
207
+
208
+ if (!statusOutput.trim()) return; // Clean tree, nothing to do
209
+
210
+ logger.warn("tdd", `Agent did not commit after ${role} session — auto-committing`, {
211
+ role,
212
+ storyId,
213
+ dirtyFiles: statusOutput.trim().split("\n").length,
214
+ });
215
+
216
+ // Stage all changes
217
+ const addProc = Bun.spawn(["git", "add", "-A"], {
218
+ cwd: workdir,
219
+ stdout: "pipe",
220
+ stderr: "pipe",
221
+ });
222
+ await addProc.exited;
223
+
224
+ // Commit with descriptive message
225
+ const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
226
+ cwd: workdir,
227
+ stdout: "pipe",
228
+ stderr: "pipe",
229
+ });
230
+ await commitProc.exited;
231
+ } catch {
232
+ // Silently ignore — auto-commit is best-effort
233
+ }
234
+ }