@nathapp/nax 0.48.3 → 0.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.48.3",
3
+ "version": "0.49.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,9 +94,8 @@ ${criteriaList}
94
94
 
95
95
  ${strategyInstructions}Generate a complete acceptance.test.ts file using bun:test framework. Each AC maps to exactly one test named "AC-N: <description>".
96
96
 
97
- Use this structure:
97
+ Structure example (do NOT wrap in markdown fences — output raw TypeScript only):
98
98
 
99
- \`\`\`typescript
100
99
  import { describe, test, expect } from "bun:test";
101
100
 
102
101
  describe("${options.featureName} - Acceptance Tests", () => {
@@ -104,13 +103,13 @@ describe("${options.featureName} - Acceptance Tests", () => {
104
103
  // Test implementation
105
104
  });
106
105
  });
107
- \`\`\`
108
106
 
109
- Respond with ONLY the TypeScript test code (no markdown code fences, no explanation).`;
107
+ IMPORTANT: Output raw TypeScript code only. Do NOT use markdown code fences (\`\`\`typescript or \`\`\`). Start directly with the import statement.`;
110
108
 
111
109
  logger.info("acceptance", "Generating tests from PRD refined criteria", { count: refinedCriteria.length });
112
110
 
113
- const testCode = await _generatorPRDDeps.adapter.complete(prompt, { config: options.config });
111
+ const rawOutput = await _generatorPRDDeps.adapter.complete(prompt, { config: options.config });
112
+ const testCode = extractTestCode(rawOutput);
114
113
 
115
114
  const refinedJsonContent = JSON.stringify(
116
115
  refinedCriteria.map((c, i) => ({
@@ -97,6 +97,7 @@ export async function promptsCommand(options: PromptsCommandOptions): Promise<st
97
97
  // Build initial pipeline context
98
98
  const ctx: PipelineContext = {
99
99
  config,
100
+ effectiveConfig: config,
100
101
  prd,
101
102
  story,
102
103
  stories: [story], // Single story, not batch
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Per-Package Config Merge Utility (MW-008)
2
+ * Per-Package Config Merge Utility (MW-008, v0.49.0 expansion)
3
3
  *
4
- * Only quality.commands is mergeable routing, plugins, execution,
5
- * and agents stay root-only.
4
+ * Merges a package-level partial config override into a root config.
5
+ * Covers all fields that make sense at the per-package level.
6
+ * Root-only fields (models, autoMode, routing, agent, etc.) are unchanged.
6
7
  */
7
8
 
8
9
  import type { NaxConfig } from "./schema";
@@ -10,27 +11,72 @@ import type { NaxConfig } from "./schema";
10
11
  /**
11
12
  * Merge a package-level partial config override into a root config.
12
13
  *
13
- * Only quality.commands keys are merged. All other sections remain
14
- * unchanged from the root config.
14
+ * Mergeable sections:
15
+ * - execution: smartTestRunner, regressionGate (deep), verificationTimeoutSeconds
16
+ * - review: enabled, checks, commands (deep), pluginMode
17
+ * - acceptance: enabled, generateTests, testPath
18
+ * - quality: requireTests, requireTypecheck, requireLint, commands (deep)
19
+ * - context: testCoverage (deep)
20
+ *
21
+ * All other sections (models, autoMode, routing, agent, generate, tdd,
22
+ * decompose, plan, constitution, interaction) remain root-only.
15
23
  *
16
24
  * @param root - Full root NaxConfig (already validated)
17
- * @param packageOverride - Partial package-level override (only quality.commands honored)
25
+ * @param packageOverride - Partial package-level override
18
26
  * @returns New merged NaxConfig (immutable — does not mutate inputs)
19
27
  */
20
28
  export function mergePackageConfig(root: NaxConfig, packageOverride: Partial<NaxConfig>): NaxConfig {
21
- const packageCommands = packageOverride.quality?.commands;
29
+ const hasAnyMergeableField =
30
+ packageOverride.execution !== undefined ||
31
+ packageOverride.review !== undefined ||
32
+ packageOverride.acceptance !== undefined ||
33
+ packageOverride.quality !== undefined ||
34
+ packageOverride.context !== undefined;
22
35
 
23
- if (!packageCommands) {
36
+ if (!hasAnyMergeableField) {
24
37
  return root;
25
38
  }
26
39
 
27
40
  return {
28
41
  ...root,
42
+ execution: {
43
+ ...root.execution,
44
+ ...packageOverride.execution,
45
+ smartTestRunner: packageOverride.execution?.smartTestRunner ?? root.execution.smartTestRunner,
46
+ regressionGate: {
47
+ ...root.execution.regressionGate,
48
+ ...packageOverride.execution?.regressionGate,
49
+ },
50
+ verificationTimeoutSeconds:
51
+ packageOverride.execution?.verificationTimeoutSeconds ?? root.execution.verificationTimeoutSeconds,
52
+ },
53
+ review: {
54
+ ...root.review,
55
+ ...packageOverride.review,
56
+ commands: {
57
+ ...root.review.commands,
58
+ ...packageOverride.review?.commands,
59
+ },
60
+ },
61
+ acceptance: {
62
+ ...root.acceptance,
63
+ ...packageOverride.acceptance,
64
+ },
29
65
  quality: {
30
66
  ...root.quality,
67
+ requireTests: packageOverride.quality?.requireTests ?? root.quality.requireTests,
68
+ requireTypecheck: packageOverride.quality?.requireTypecheck ?? root.quality.requireTypecheck,
69
+ requireLint: packageOverride.quality?.requireLint ?? root.quality.requireLint,
31
70
  commands: {
32
71
  ...root.quality.commands,
33
- ...packageCommands,
72
+ ...packageOverride.quality?.commands,
73
+ },
74
+ },
75
+ context: {
76
+ ...root.context,
77
+ testCoverage: {
78
+ ...root.context.testCoverage,
79
+ ...packageOverride.context?.testCoverage,
34
80
  },
35
81
  },
36
82
  };
@@ -5,6 +5,8 @@
5
5
  * Extracted from sequential-executor.ts to slim it below 120 lines.
6
6
  */
7
7
 
8
+ import { join } from "node:path";
9
+ import { loadConfigForWorkdir } from "../config/loader";
8
10
  import { getSafeLogger } from "../logger";
9
11
  import type { StoryMetrics } from "../metrics";
10
12
  import { runPipeline } from "../pipeline/runner";
@@ -64,8 +66,14 @@ export async function runIteration(
64
66
  // BUG-067: Accumulate cost from all prior failed attempts (stored in priorFailures by handleTierEscalation)
65
67
  const accumulatedAttemptCost = (story.priorFailures || []).reduce((sum, f) => sum + (f.cost || 0), 0);
66
68
 
69
+ // PKG-003: Resolve per-package effective config once per story (not per-stage)
70
+ const effectiveConfig = story.workdir
71
+ ? await _iterationRunnerDeps.loadConfigForWorkdir(join(ctx.workdir, "nax", "config.json"), story.workdir)
72
+ : ctx.config;
73
+
67
74
  const pipelineContext: PipelineContext = {
68
75
  config: ctx.config,
76
+ effectiveConfig,
69
77
  prd,
70
78
  story,
71
79
  stories: storiesToExecute,
@@ -140,3 +148,10 @@ export async function runIteration(
140
148
  reason: pipelineResult.reason,
141
149
  };
142
150
  }
151
+
152
+ /**
153
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
154
+ */
155
+ export const _iterationRunnerDeps = {
156
+ loadConfigForWorkdir,
157
+ };
@@ -134,6 +134,7 @@ async function executeFixStory(
134
134
  );
135
135
  const fixContext: PipelineContext = {
136
136
  config: ctx.config,
137
+ effectiveConfig: ctx.config,
137
138
  prd,
138
139
  story,
139
140
  stories: [story],
@@ -177,6 +178,7 @@ export async function runAcceptanceLoop(ctx: AcceptanceLoopContext): Promise<Acc
177
178
  const firstStory = prd.userStories[0];
178
179
  const acceptanceContext: PipelineContext = {
179
180
  config: ctx.config,
181
+ effectiveConfig: ctx.config,
180
182
  prd,
181
183
  story: firstStory,
182
184
  stories: [firstStory],
@@ -148,6 +148,7 @@ export async function executeParallel(
148
148
  // Build context for this batch (shared across all stories in batch)
149
149
  const baseContext = {
150
150
  config,
151
+ effectiveConfig: config,
151
152
  prd: currentPrd,
152
153
  featureDir,
153
154
  hooks,
@@ -90,6 +90,7 @@ export async function rectifyConflictedStory(options: RectifyConflictedStoryOpti
90
90
 
91
91
  const pipelineContext = {
92
92
  config,
93
+ effectiveConfig: config,
93
94
  prd,
94
95
  story,
95
96
  stories: [story],
@@ -28,6 +28,7 @@ export async function executeStoryInWorktree(
28
28
  try {
29
29
  const pipelineContext: PipelineContext = {
30
30
  ...context,
31
+ effectiveConfig: context.effectiveConfig ?? context.config,
31
32
  story,
32
33
  stories: [story],
33
34
  workdir: worktreePath,
@@ -72,6 +72,7 @@ export async function executeSequential(
72
72
  logger?.info("execution", "Running pre-run pipeline (acceptance test setup)");
73
73
  const preRunCtx: PipelineContext = {
74
74
  config: ctx.config,
75
+ effectiveConfig: ctx.config,
75
76
  prd,
76
77
  workdir: ctx.workdir,
77
78
  featureDir: ctx.featureDir,
@@ -92,7 +92,8 @@ export const acceptanceStage: PipelineStage = {
92
92
  // Only run when:
93
93
  // 1. Acceptance validation is enabled
94
94
  // 2. All stories are complete
95
- if (!ctx.config.acceptance.enabled) {
95
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
96
+ if (!effectiveConfig.acceptance.enabled) {
96
97
  return false;
97
98
  }
98
99
 
@@ -106,6 +107,9 @@ export const acceptanceStage: PipelineStage = {
106
107
  async execute(ctx: PipelineContext): Promise<StageResult> {
107
108
  const logger = getLogger();
108
109
 
110
+ // PKG-004: use centrally resolved effective config
111
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
112
+
109
113
  logger.info("acceptance", "Running acceptance tests");
110
114
 
111
115
  // Build path to acceptance test file
@@ -114,7 +118,7 @@ export const acceptanceStage: PipelineStage = {
114
118
  return { action: "continue" };
115
119
  }
116
120
 
117
- const testPath = path.join(ctx.featureDir, ctx.config.acceptance.testPath);
121
+ const testPath = path.join(ctx.featureDir, effectiveConfig.acceptance.testPath);
118
122
 
119
123
  // Check if test file exists
120
124
  const testFile = Bun.file(testPath);
@@ -19,8 +19,10 @@
19
19
  * - `escalate` — max attempts exhausted or agent unavailable
20
20
  */
21
21
 
22
+ import { join } from "node:path";
22
23
  import { getAgent } from "../../agents";
23
24
  import { resolveModel } from "../../config";
25
+ import { loadConfigForWorkdir } from "../../config/loader";
24
26
  import { resolvePermissions } from "../../config/permissions";
25
27
  import { getLogger } from "../../logger";
26
28
  import type { UserStory } from "../../prd";
@@ -34,7 +36,7 @@ export const autofixStage: PipelineStage = {
34
36
  enabled(ctx: PipelineContext): boolean {
35
37
  if (!ctx.reviewResult) return false;
36
38
  if (ctx.reviewResult.success) return false;
37
- const autofixEnabled = ctx.config.quality.autofix?.enabled ?? true;
39
+ const autofixEnabled = (ctx.effectiveConfig ?? ctx.config).quality.autofix?.enabled ?? true;
38
40
  return autofixEnabled;
39
41
  },
40
42
 
@@ -51,14 +53,19 @@ export const autofixStage: PipelineStage = {
51
53
  return { action: "continue" };
52
54
  }
53
55
 
54
- const lintFixCmd = ctx.config.quality.commands.lintFix;
55
- const formatFixCmd = ctx.config.quality.commands.formatFix;
56
+ // PKG-004: use centrally resolved effective config (ctx.effectiveConfig set once per story)
57
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
58
+ const lintFixCmd = effectiveConfig.quality.commands.lintFix;
59
+ const formatFixCmd = effectiveConfig.quality.commands.formatFix;
60
+
61
+ // Effective workdir for running commands (scoped to package if monorepo)
62
+ const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
56
63
 
57
64
  // Phase 1: Mechanical fix (if commands are configured)
58
65
  if (lintFixCmd || formatFixCmd) {
59
66
  if (lintFixCmd) {
60
67
  pipelineEventBus.emit({ type: "autofix:started", storyId: ctx.story.id, command: lintFixCmd });
61
- const lintResult = await _autofixDeps.runCommand(lintFixCmd, ctx.workdir);
68
+ const lintResult = await _autofixDeps.runCommand(lintFixCmd, effectiveWorkdir);
62
69
  logger.debug("autofix", `lintFix exit=${lintResult.exitCode}`, { storyId: ctx.story.id });
63
70
  if (lintResult.exitCode !== 0) {
64
71
  logger.warn("autofix", "lintFix command failed — may not have fixed all issues", {
@@ -70,7 +77,7 @@ export const autofixStage: PipelineStage = {
70
77
 
71
78
  if (formatFixCmd) {
72
79
  pipelineEventBus.emit({ type: "autofix:started", storyId: ctx.story.id, command: formatFixCmd });
73
- const fmtResult = await _autofixDeps.runCommand(formatFixCmd, ctx.workdir);
80
+ const fmtResult = await _autofixDeps.runCommand(formatFixCmd, effectiveWorkdir);
74
81
  logger.debug("autofix", `formatFix exit=${fmtResult.exitCode}`, { storyId: ctx.story.id });
75
82
  if (fmtResult.exitCode !== 0) {
76
83
  logger.warn("autofix", "formatFix command failed — may not have fixed all issues", {
@@ -155,7 +162,8 @@ Commit your fixes when done.`;
155
162
 
156
163
  async function runAgentRectification(ctx: PipelineContext): Promise<boolean> {
157
164
  const logger = getLogger();
158
- const maxAttempts = ctx.config.quality.autofix?.maxAttempts ?? 2;
165
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
166
+ const maxAttempts = effectiveConfig.quality.autofix?.maxAttempts ?? 2;
159
167
  const failedChecks = collectFailedChecks(ctx);
160
168
 
161
169
  if (failedChecks.length === 0) {
@@ -224,4 +232,4 @@ async function runAgentRectification(ctx: PipelineContext): Promise<boolean> {
224
232
  /**
225
233
  * Injectable deps for testing.
226
234
  */
227
- export const _autofixDeps = { runCommand, recheckReview, runAgentRectification };
235
+ export const _autofixDeps = { runCommand, recheckReview, runAgentRectification, loadConfigForWorkdir };
@@ -16,6 +16,7 @@
16
16
  * - `session-failure` → escalate
17
17
  * - `tests-failing` → escalate
18
18
  * - `verifier-rejected` → escalate
19
+ * - `greenfield-no-tests` → escalate (tier-escalation switches to test-after)
19
20
  * - no category / unknown → pause (backward compatible)
20
21
  *
21
22
  * @example
@@ -111,6 +112,11 @@ export function routeTddFailure(
111
112
  return { action: "escalate" };
112
113
  }
113
114
 
115
+ // S5: greenfield-no-tests → escalate so tier-escalation can switch to test-after
116
+ if (failureCategory === "greenfield-no-tests") {
117
+ return { action: "escalate" };
118
+ }
119
+
114
120
  // Default: no category or unknown — backward-compatible pause for human review
115
121
  return {
116
122
  action: "pause",
@@ -34,6 +34,9 @@ export const promptStage: PipelineStage = {
34
34
  const logger = getLogger();
35
35
  const isBatch = ctx.stories.length > 1;
36
36
 
37
+ // PKG-004: use centrally resolved effective config
38
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
39
+
37
40
  let prompt: string;
38
41
  if (isBatch) {
39
42
  const builder = PromptBuilder.for("batch")
@@ -41,7 +44,7 @@ export const promptStage: PipelineStage = {
41
44
  .stories(ctx.stories)
42
45
  .context(ctx.contextMarkdown)
43
46
  .constitution(ctx.constitution?.content)
44
- .testCommand(ctx.config.quality?.commands?.test);
47
+ .testCommand(effectiveConfig.quality?.commands?.test);
45
48
  prompt = await builder.build();
46
49
  } else {
47
50
  // Both test-after and tdd-simple use the tdd-simple prompt (RED/GREEN/REFACTOR)
@@ -51,7 +54,7 @@ export const promptStage: PipelineStage = {
51
54
  .story(ctx.story)
52
55
  .context(ctx.contextMarkdown)
53
56
  .constitution(ctx.constitution?.content)
54
- .testCommand(ctx.config.quality?.commands?.test);
57
+ .testCommand(effectiveConfig.quality?.commands?.test);
55
58
  prompt = await builder.build();
56
59
  }
57
60
 
@@ -59,13 +59,15 @@ export const rectifyStage: PipelineStage = {
59
59
  testOutput,
60
60
  });
61
61
 
62
- const testCommand = ctx.config.review?.commands?.test ?? ctx.config.quality.commands.test ?? "bun test";
62
+ // PKG-004: use centrally resolved effective config
63
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
64
+ const testCommand = effectiveConfig.review?.commands?.test ?? effectiveConfig.quality.commands.test ?? "bun test";
63
65
  const fixed = await _rectifyDeps.runRectificationLoop({
64
66
  config: ctx.config,
65
67
  workdir: ctx.workdir,
66
68
  story: ctx.story,
67
69
  testCommand,
68
- timeoutSeconds: ctx.config.execution.verificationTimeoutSeconds,
70
+ timeoutSeconds: effectiveConfig.execution.verificationTimeoutSeconds,
69
71
  testOutput,
70
72
  });
71
73
 
@@ -24,24 +24,28 @@ export const regressionStage: PipelineStage = {
24
24
  name: "regression",
25
25
 
26
26
  enabled(ctx: PipelineContext): boolean {
27
- const mode = ctx.config.execution.regressionGate?.mode ?? "deferred";
27
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
28
+ const mode = effectiveConfig.execution.regressionGate?.mode ?? "deferred";
28
29
  if (mode !== "per-story") return false;
29
30
  // Only run when verify passed (or was skipped/not set)
30
31
  if (ctx.verifyResult && !ctx.verifyResult.success) return false;
31
- const gateEnabled = ctx.config.execution.regressionGate?.enabled ?? true;
32
+ const gateEnabled = effectiveConfig.execution.regressionGate?.enabled ?? true;
32
33
  return gateEnabled;
33
34
  },
34
35
 
35
36
  skipReason(ctx: PipelineContext): string {
36
- const mode = ctx.config.execution.regressionGate?.mode ?? "deferred";
37
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
38
+ const mode = effectiveConfig.execution.regressionGate?.mode ?? "deferred";
37
39
  if (mode !== "per-story") return `not needed (regression mode is '${mode}', not 'per-story')`;
38
40
  return "disabled (regression gate not enabled in config)";
39
41
  },
40
42
 
41
43
  async execute(ctx: PipelineContext): Promise<StageResult> {
42
44
  const logger = getLogger();
43
- const testCommand = ctx.config.review?.commands?.test ?? ctx.config.quality.commands.test ?? "bun test";
44
- const timeoutSeconds = ctx.config.execution.regressionGate?.timeoutSeconds ?? 120;
45
+ // PKG-004: use centrally resolved effective config
46
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
47
+ const testCommand = effectiveConfig.review?.commands?.test ?? effectiveConfig.quality.commands.test ?? "bun test";
48
+ const timeoutSeconds = effectiveConfig.execution.regressionGate?.timeoutSeconds ?? 120;
45
49
 
46
50
  logger.info("regression", "Running full-suite regression gate", { storyId: ctx.story.id });
47
51
 
@@ -50,7 +54,7 @@ export const regressionStage: PipelineStage = {
50
54
  testCommand,
51
55
  timeoutSeconds,
52
56
  storyId: ctx.story.id,
53
- acceptOnTimeout: ctx.config.execution.regressionGate?.acceptOnTimeout ?? true,
57
+ acceptOnTimeout: effectiveConfig.execution.regressionGate?.acceptOnTimeout ?? true,
54
58
  config: ctx.config,
55
59
  };
56
60
 
@@ -19,20 +19,23 @@ import type { PipelineContext, PipelineStage, StageResult } from "../types";
19
19
 
20
20
  export const reviewStage: PipelineStage = {
21
21
  name: "review",
22
- enabled: (ctx) => ctx.config.review.enabled,
22
+ enabled: (ctx) => (ctx.effectiveConfig ?? ctx.config).review.enabled,
23
23
 
24
24
  async execute(ctx: PipelineContext): Promise<StageResult> {
25
25
  const logger = getLogger();
26
26
 
27
+ // PKG-004: use centrally resolved effective config
28
+ const effectiveConfig = ctx.effectiveConfig ?? ctx.config;
29
+
27
30
  logger.info("review", "Running review phase", { storyId: ctx.story.id });
28
31
 
29
32
  // MW-010: scope review to package directory when story.workdir is set
30
33
  const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
31
34
 
32
35
  const result = await reviewOrchestrator.review(
33
- ctx.config.review,
36
+ effectiveConfig.review,
34
37
  effectiveWorkdir,
35
- ctx.config.execution,
38
+ effectiveConfig.execution,
36
39
  ctx.plugins,
37
40
  ctx.storyGitRef,
38
41
  ctx.story.workdir, // MW-010: scope changed-file checks to package
@@ -49,10 +52,10 @@ export const reviewStage: PipelineStage = {
49
52
 
50
53
  if (result.pluginFailed) {
51
54
  // security-review trigger: prompt before permanently failing
52
- if (ctx.interaction && isTriggerEnabled("security-review", ctx.config)) {
55
+ if (ctx.interaction && isTriggerEnabled("security-review", effectiveConfig)) {
53
56
  const shouldContinue = await _reviewDeps.checkSecurityReview(
54
57
  { featureName: ctx.prd.feature, storyId: ctx.story.id },
55
- ctx.config,
58
+ effectiveConfig,
56
59
  ctx.interaction,
57
60
  );
58
61
  if (!shouldContinue) {
@@ -67,11 +70,12 @@ export const reviewStage: PipelineStage = {
67
70
  return { action: "fail", reason: `Review failed: ${result.failureReason}` };
68
71
  }
69
72
 
70
- logger.warn("review", "Review failed (built-in checks) — escalating for retry", {
73
+ logger.warn("review", "Review failed (built-in checks) — handing off to autofix", {
71
74
  reason: result.failureReason,
72
75
  storyId: ctx.story.id,
73
76
  });
74
- return { action: "escalate", reason: `Review failed: ${result.failureReason}` };
77
+ // ctx.reviewResult is already set with success:false autofixStage handles it next
78
+ return { action: "continue" };
75
79
  }
76
80
 
77
81
  logger.info("review", "Review passed", {