@nathapp/nax 0.48.4 → 0.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.48.4",
3
+ "version": "0.49.1",
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,16 @@ 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, {
112
+ model: options.modelDef.model,
113
+ config: options.config,
114
+ });
115
+ const testCode = extractTestCode(rawOutput);
114
116
 
115
117
  const refinedJsonContent = JSON.stringify(
116
118
  refinedCriteria.map((c, i) => ({
@@ -694,8 +694,13 @@ export class AcpAgentAdapter implements AgentAdapter {
694
694
 
695
695
  let session: AcpSession | null = null;
696
696
  try {
697
- // complete() is one-shot — ephemeral session, no session name, no sidecar
698
- session = await client.createSession({ agentName: this.name, permissionMode });
697
+ // complete() is one-shot — ephemeral session, no sidecar
698
+ // Use caller-provided sessionName if available (aids tracing), otherwise timestamp-based
699
+ session = await client.createSession({
700
+ agentName: this.name,
701
+ permissionMode,
702
+ sessionName: _options?.sessionName,
703
+ });
699
704
 
700
705
  // Enforce timeout via Promise.race — session.prompt() can hang indefinitely
701
706
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -70,7 +70,7 @@ export function parseAcpxJsonOutput(rawOutput: string): {
70
70
  }
71
71
  }
72
72
 
73
- // Final result with token breakdown (camelCase from acpx)
73
+ // Final result with token breakdown
74
74
  if (event.id !== undefined && event.result && typeof event.result === "object") {
75
75
  const result = event.result as Record<string, unknown>;
76
76
 
@@ -135,6 +135,8 @@ class SpawnAcpSession implements AcpSession {
135
135
  "acpx",
136
136
  "--cwd",
137
137
  this.cwd,
138
+ "--format",
139
+ "json",
138
140
  ...(this.permissionMode === "approve-all" ? ["--approve-all"] : []),
139
141
  "--model",
140
142
  this.model,
@@ -127,6 +127,12 @@ export interface CompleteOptions {
127
127
  * Pass when available so complete() honours permissionProfile / dangerouslySkipPermissions.
128
128
  */
129
129
  config?: NaxConfig;
130
+ /**
131
+ * Named session to use for this completion call.
132
+ * If omitted, a timestamp-based ephemeral session name is generated.
133
+ * Pass a meaningful name (e.g. "nax-decompose-us-001") to aid debugging.
134
+ */
135
+ sessionName?: string;
130
136
  }
131
137
 
132
138
  /**
package/src/cli/plan.ts CHANGED
@@ -138,7 +138,17 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
138
138
  const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages, packageDetails);
139
139
  const cliAdapter = _deps.getAgent(agentName);
140
140
  if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
141
- rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config });
141
+ let autoModel: string | undefined;
142
+ try {
143
+ const planTier = config?.plan?.model ?? "balanced";
144
+ const { resolveModel } = await import("../config/schema");
145
+ const models = config?.models as Record<string, unknown> | undefined;
146
+ const entry = models?.[planTier] ?? models?.balanced;
147
+ if (entry) autoModel = resolveModel(entry as Parameters<typeof resolveModel>[0]).model;
148
+ } catch {
149
+ // fall through — complete() will use its own fallback
150
+ }
151
+ rawResponse = await cliAdapter.complete(prompt, { model: autoModel, jsonMode: true, workdir, config });
142
152
  // CLI adapter returns {"type":"result","result":"..."} envelope — unwrap it
143
153
  try {
144
154
  const envelope = JSON.parse(rawResponse) as Record<string, unknown>;
@@ -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", {
@@ -65,9 +65,28 @@ async function runDecompose(
65
65
  if (!agent) {
66
66
  throw new Error(`[decompose] Agent "${config.autoMode.defaultAgent}" not found — cannot decompose`);
67
67
  }
68
+
69
+ // Resolve decompose model: config.decompose.model tier → actual model string
70
+ const decomposeTier = naxDecompose?.model ?? "balanced";
71
+ let decomposeModel: string | undefined;
72
+ try {
73
+ const { resolveModel } = await import("../../config/schema");
74
+ const models = config.models as Record<string, unknown>;
75
+ const entry = models[decomposeTier] ?? models.balanced;
76
+ if (entry) decomposeModel = resolveModel(entry as Parameters<typeof resolveModel>[0]).model;
77
+ } catch {
78
+ // resolveModel can throw on malformed entries — fall through to let complete() handle it
79
+ }
80
+
81
+ const storySessionName = `nax-decompose-${story.id.toLowerCase()}`;
68
82
  const adapter = {
69
83
  async decompose(prompt: string): Promise<string> {
70
- return agent.complete(prompt, { jsonMode: true, config });
84
+ return agent.complete(prompt, {
85
+ model: decomposeModel,
86
+ jsonMode: true,
87
+ config,
88
+ sessionName: storySessionName,
89
+ });
71
90
  },
72
91
  };
73
92