@nathapp/nax 0.46.3 → 0.48.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.46.3",
3
+ "version": "0.48.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@ import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import chalk from "chalk";
11
11
  import { loadConfig } from "../config/loader";
12
- import { generateAll, generateFor } from "../context/generator";
12
+ import { discoverPackages, generateAll, generateFor, generateForPackage } from "../context/generator";
13
13
  import type { AgentType } from "../context/types";
14
14
 
15
15
  /** Options for `nax generate` */
@@ -24,6 +24,17 @@ export interface GenerateCommandOptions {
24
24
  dryRun?: boolean;
25
25
  /** Disable auto-injection of project metadata */
26
26
  noAutoInject?: boolean;
27
+ /**
28
+ * Generate for a specific package directory (relative to repo root).
29
+ * Reads <package>/nax/context.md, writes <package>/CLAUDE.md.
30
+ * @example "packages/api"
31
+ */
32
+ package?: string;
33
+ /**
34
+ * Generate for all discovered packages.
35
+ * Auto-discovers packages with nax/context.md up to 2 levels deep.
36
+ */
37
+ allPackages?: boolean;
27
38
  }
28
39
 
29
40
  const VALID_AGENTS: AgentType[] = ["claude", "codex", "opencode", "cursor", "windsurf", "aider", "gemini"];
@@ -33,10 +44,70 @@ const VALID_AGENTS: AgentType[] = ["claude", "codex", "opencode", "cursor", "win
33
44
  */
34
45
  export async function generateCommand(options: GenerateCommandOptions): Promise<void> {
35
46
  const workdir = process.cwd();
47
+ const dryRun = options.dryRun ?? false;
48
+
49
+ // Load config early — needed for all paths
50
+ let config: Awaited<ReturnType<typeof loadConfig>>;
51
+ try {
52
+ config = await loadConfig(workdir);
53
+ } catch {
54
+ config = {} as Awaited<ReturnType<typeof loadConfig>>;
55
+ }
56
+
57
+ // --all-packages: discover and generate for all packages
58
+ if (options.allPackages) {
59
+ if (dryRun) {
60
+ console.log(chalk.yellow("⚠ Dry run — no files will be written"));
61
+ }
62
+ console.log(chalk.blue("→ Discovering packages with nax/context.md..."));
63
+ const packages = await discoverPackages(workdir);
64
+
65
+ if (packages.length === 0) {
66
+ console.log(chalk.yellow(" No packages found (no */nax/context.md or */*/nax/context.md)"));
67
+ return;
68
+ }
69
+
70
+ console.log(chalk.blue(`→ Generating CLAUDE.md for ${packages.length} package(s)...`));
71
+ let errorCount = 0;
72
+
73
+ for (const pkgDir of packages) {
74
+ const result = await generateForPackage(pkgDir, config, dryRun);
75
+ if (result.error) {
76
+ console.error(chalk.red(`✗ ${pkgDir}: ${result.error}`));
77
+ errorCount++;
78
+ } else {
79
+ const suffix = dryRun ? " (dry run)" : "";
80
+ console.log(chalk.green(`✓ ${pkgDir}/${result.outputFile} (${result.content.length} bytes${suffix})`));
81
+ }
82
+ }
83
+
84
+ if (errorCount > 0) {
85
+ console.error(chalk.red(`\n✗ ${errorCount} generation(s) failed`));
86
+ process.exit(1);
87
+ }
88
+ return;
89
+ }
90
+
91
+ // --package: generate for a specific package
92
+ if (options.package) {
93
+ const packageDir = join(workdir, options.package);
94
+ if (dryRun) {
95
+ console.log(chalk.yellow("⚠ Dry run — no files will be written"));
96
+ }
97
+ console.log(chalk.blue(`→ Generating CLAUDE.md for package: ${options.package}`));
98
+ const result = await generateForPackage(packageDir, config, dryRun);
99
+ if (result.error) {
100
+ console.error(chalk.red(`✗ ${result.error}`));
101
+ process.exit(1);
102
+ }
103
+ const suffix = dryRun ? " (dry run)" : "";
104
+ console.log(chalk.green(`✓ ${options.package}/${result.outputFile} (${result.content.length} bytes${suffix})`));
105
+ return;
106
+ }
107
+
36
108
  const contextPath = options.context ? join(workdir, options.context) : join(workdir, "nax/context.md");
37
109
  const outputDir = options.output ? join(workdir, options.output) : workdir;
38
110
  const autoInject = !options.noAutoInject;
39
- const dryRun = options.dryRun ?? false;
40
111
 
41
112
  // Validate context file
42
113
  if (!existsSync(contextPath)) {
@@ -61,15 +132,6 @@ export async function generateCommand(options: GenerateCommandOptions): Promise<
61
132
  console.log(chalk.dim(" Auto-injecting project metadata..."));
62
133
  }
63
134
 
64
- // Load config for metadata injection (best-effort, use defaults if not found)
65
- let config: Awaited<ReturnType<typeof loadConfig>>;
66
- try {
67
- config = await loadConfig(workdir);
68
- } catch {
69
- // Config not required for generate — use empty defaults
70
- config = {} as Awaited<ReturnType<typeof loadConfig>>;
71
- }
72
-
73
135
  const genOptions = {
74
136
  contextPath,
75
137
  outputDir,
@@ -80,6 +142,7 @@ export async function generateCommand(options: GenerateCommandOptions): Promise<
80
142
 
81
143
  try {
82
144
  if (options.agent) {
145
+ // CLI --agent flag: single specific agent (overrides config)
83
146
  const agent = options.agent as AgentType;
84
147
  console.log(chalk.blue(`→ Generating config for ${agent}...`));
85
148
 
@@ -93,9 +156,19 @@ export async function generateCommand(options: GenerateCommandOptions): Promise<
93
156
  const suffix = dryRun ? " (dry run)" : "";
94
157
  console.log(chalk.green(`✓ ${agent} → ${result.outputFile} (${result.content.length} bytes${suffix})`));
95
158
  } else {
96
- console.log(chalk.blue("→ Generating configs for all agents..."));
159
+ // No --agent flag: use config.generate.agents filter, or generate all
160
+ const configAgents = config?.generate?.agents;
161
+ const agentFilter = configAgents && configAgents.length > 0 ? configAgents : null;
162
+
163
+ if (agentFilter) {
164
+ console.log(chalk.blue(`→ Generating configs for: ${agentFilter.join(", ")} (from config)...`));
165
+ } else {
166
+ console.log(chalk.blue("→ Generating configs for all agents..."));
167
+ }
168
+
169
+ const allResults = await generateAll(genOptions, config);
170
+ const results = agentFilter ? allResults.filter((r) => agentFilter.includes(r.agent as AgentType)) : allResults;
97
171
 
98
- const results = await generateAll(genOptions, config);
99
172
  let errorCount = 0;
100
173
 
101
174
  for (const result of results) {
@@ -312,6 +312,63 @@ Keep it under 2000 tokens. Use markdown formatting. Be specific to the detected
312
312
  }
313
313
  }
314
314
 
315
+ /**
316
+ * Generate a minimal package context.md template.
317
+ *
318
+ * @param packagePath - Relative path of the package (e.g. "packages/api")
319
+ */
320
+ export function generatePackageContextTemplate(packagePath: string): string {
321
+ const packageName = packagePath.split("/").pop() ?? packagePath;
322
+ return `# ${packageName} — Context
323
+
324
+ <!-- Package-specific conventions. Root context.md provides shared rules. -->
325
+
326
+ ## Tech Stack
327
+
328
+ <!-- TODO: Document this package's tech stack -->
329
+
330
+ ## Commands
331
+
332
+ | Command | Purpose |
333
+ |:--------|:--------|
334
+ | \`bun test\` | Unit tests |
335
+
336
+ ## Development Guidelines
337
+
338
+ <!-- TODO: Document package-specific guidelines -->
339
+ `;
340
+ }
341
+
342
+ /**
343
+ * Initialize per-package nax/context.md scaffold.
344
+ *
345
+ * Creates \`<packageDir>/nax/context.md\` with a minimal template.
346
+ * Does not overwrite an existing file unless force is set.
347
+ *
348
+ * @param repoRoot - Absolute path to repo root
349
+ * @param packagePath - Relative path of the package (e.g. "packages/api")
350
+ * @param force - Overwrite existing file
351
+ */
352
+ export async function initPackage(repoRoot: string, packagePath: string, force = false): Promise<void> {
353
+ const logger = getLogger();
354
+ const packageDir = join(repoRoot, packagePath);
355
+ const naxDir = join(packageDir, "nax");
356
+ const contextPath = join(naxDir, "context.md");
357
+
358
+ if (existsSync(contextPath) && !force) {
359
+ logger.info("init", "Package context.md already exists (use --force to overwrite)", { path: contextPath });
360
+ return;
361
+ }
362
+
363
+ if (!existsSync(naxDir)) {
364
+ await mkdir(naxDir, { recursive: true });
365
+ }
366
+
367
+ const content = generatePackageContextTemplate(packagePath);
368
+ await Bun.write(contextPath, content);
369
+ logger.info("init", "Created package context.md", { path: contextPath });
370
+ }
371
+
315
372
  /**
316
373
  * Initialize context.md for a project
317
374
  */
package/src/cli/init.ts CHANGED
@@ -9,7 +9,7 @@ import { mkdir } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
  import { globalConfigDir, projectConfigDir } from "../config/paths";
11
11
  import { getLogger } from "../logger";
12
- import { initContext } from "./init-context";
12
+ import { initContext, initPackage } from "./init-context";
13
13
  import { buildInitConfig, detectStack } from "./init-detect";
14
14
  import type { ProjectStack } from "./init-detect";
15
15
  import { promptsInitCommand } from "./prompts";
@@ -20,6 +20,11 @@ export interface InitOptions {
20
20
  global?: boolean;
21
21
  /** Project root (default: cwd) */
22
22
  projectRoot?: string;
23
+ /**
24
+ * Initialize a per-package nax/context.md scaffold.
25
+ * Relative path from repo root, e.g. "packages/api".
26
+ */
27
+ package?: string;
23
28
  }
24
29
 
25
30
  /** Options for initProject */
@@ -277,6 +282,14 @@ export async function initProject(projectRoot: string, options?: InitProjectOpti
277
282
  export async function initCommand(options: InitOptions = {}): Promise<void> {
278
283
  if (options.global) {
279
284
  await initGlobal();
285
+ } else if (options.package) {
286
+ const projectRoot = options.projectRoot ?? process.cwd();
287
+ await initPackage(projectRoot, options.package);
288
+ console.log("\n[OK] Package scaffold created.");
289
+ console.log(` Created: ${options.package}/nax/context.md`);
290
+ console.log("\nNext steps:");
291
+ console.log(` 1. Review ${options.package}/nax/context.md and fill in TODOs`);
292
+ console.log(` 2. Run: nax generate --package ${options.package}`);
280
293
  } else {
281
294
  const projectRoot = options.projectRoot ?? process.cwd();
282
295
  await initProject(projectRoot);
package/src/cli/plan.ts CHANGED
@@ -17,6 +17,7 @@ import type { CodebaseScan } from "../analyze/types";
17
17
  import type { NaxConfig } from "../config";
18
18
  import { resolvePermissions } from "../config/permissions";
19
19
  import { COMPLEXITY_GUIDE, GROUPING_RULES, TEST_STRATEGY_GUIDE } from "../config/test-strategy";
20
+ import { discoverWorkspacePackages } from "../context/generator";
20
21
  import { PidRegistry } from "../execution/pid-registry";
21
22
  import { getLogger } from "../logger";
22
23
  import { validatePlanOutput } from "../prd/schema";
@@ -41,6 +42,15 @@ export const _deps = {
41
42
  },
42
43
  mkdirp: (path: string): Promise<void> => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
43
44
  existsSync: (path: string): boolean => existsSync(path),
45
+ discoverWorkspacePackages: (repoRoot: string): Promise<string[]> => discoverWorkspacePackages(repoRoot),
46
+ readPackageJsonAt: (path: string): Promise<Record<string, unknown> | null> =>
47
+ Bun.file(path)
48
+ .json()
49
+ .catch(() => null),
50
+ createInteractionBridge: (): {
51
+ detectQuestion: (text: string) => Promise<boolean>;
52
+ onQuestionDetected: (text: string) => Promise<string>;
53
+ } => createCliInteractionBridge(),
44
54
  };
45
55
 
46
56
  // ─────────────────────────────────────────────────────────────────────────────
@@ -85,11 +95,29 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
85
95
 
86
96
  // Scan codebase for context
87
97
  logger?.info("plan", "Scanning codebase...");
88
- const scan = await _deps.scanCodebase(workdir);
98
+ const [scan, discoveredPackages, pkg] = await Promise.all([
99
+ _deps.scanCodebase(workdir),
100
+ _deps.discoverWorkspacePackages(workdir),
101
+ _deps.readPackageJson(workdir),
102
+ ]);
89
103
  const codebaseContext = buildCodebaseContext(scan);
90
104
 
105
+ // Normalize to repo-relative paths (discoverWorkspacePackages returns relative,
106
+ // but mocks/legacy callers may return absolute — strip workdir prefix if present)
107
+ const relativePackages = discoveredPackages.map((p) => (p.startsWith("/") ? p.replace(`${workdir}/`, "") : p));
108
+
109
+ // Scan per-package tech stacks for richer monorepo planning context
110
+ const packageDetails =
111
+ relativePackages.length > 0
112
+ ? await Promise.all(
113
+ relativePackages.map(async (rel) => {
114
+ const pkgJson = await _deps.readPackageJsonAt(join(workdir, rel, "package.json"));
115
+ return buildPackageSummary(rel, pkgJson);
116
+ }),
117
+ )
118
+ : [];
119
+
91
120
  // Auto-detect project name
92
- const pkg = await _deps.readPackageJson(workdir);
93
121
  const projectName = detectProjectName(workdir, pkg);
94
122
 
95
123
  // Compute output path early — needed for interactive file-write prompt
@@ -107,7 +135,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
107
135
  let rawResponse: string;
108
136
  if (options.auto) {
109
137
  // One-shot: use CLI adapter directly — simple completion doesn't need ACP session overhead
110
- const prompt = buildPlanningPrompt(specContent, codebaseContext);
138
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages, packageDetails);
111
139
  const cliAdapter = _deps.getAgent(agentName);
112
140
  if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
113
141
  rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config });
@@ -122,10 +150,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
122
150
  }
123
151
  } else {
124
152
  // Interactive: agent writes PRD JSON directly to outputPath (avoids output truncation)
125
- const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath);
153
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages, packageDetails);
126
154
  const adapter = _deps.getAgent(agentName, config);
127
155
  if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
128
- const interactionBridge = createCliInteractionBridge();
156
+ const interactionBridge = _deps.createInteractionBridge();
129
157
  const pidRegistry = new PidRegistry(workdir);
130
158
  const resolvedPerm = resolvePermissions(config, "plan");
131
159
  const resolvedModel = config?.plan?.model ?? "balanced";
@@ -238,6 +266,91 @@ function detectProjectName(workdir: string, pkg: Record<string, unknown> | null)
238
266
  return "unknown";
239
267
  }
240
268
 
269
+ /** Compact per-package summary for the planning prompt. */
270
+ interface PackageSummary {
271
+ path: string;
272
+ name: string;
273
+ runtime: string;
274
+ framework: string;
275
+ testRunner: string;
276
+ keyDeps: string[];
277
+ }
278
+
279
+ const FRAMEWORK_PATTERNS: [RegExp, string][] = [
280
+ [/\bnext\b/, "Next.js"],
281
+ [/\bnuxt\b/, "Nuxt"],
282
+ [/\bremix\b/, "Remix"],
283
+ [/\bexpress\b/, "Express"],
284
+ [/\bfastify\b/, "Fastify"],
285
+ [/\bhono\b/, "Hono"],
286
+ [/\bnestjs|@nestjs\b/, "NestJS"],
287
+ [/\breact\b/, "React"],
288
+ [/\bvue\b/, "Vue"],
289
+ [/\bsvelte\b/, "Svelte"],
290
+ [/\bastro\b/, "Astro"],
291
+ [/\belectron\b/, "Electron"],
292
+ ];
293
+
294
+ const TEST_RUNNER_PATTERNS: [RegExp, string][] = [
295
+ [/\bvitest\b/, "vitest"],
296
+ [/\bjest\b/, "jest"],
297
+ [/\bmocha\b/, "mocha"],
298
+ [/\bava\b/, "ava"],
299
+ ];
300
+
301
+ const KEY_DEP_PATTERNS: [RegExp, string][] = [
302
+ [/\bprisma\b/, "prisma"],
303
+ [/\bdrizzle-orm\b/, "drizzle"],
304
+ [/\btypeorm\b/, "typeorm"],
305
+ [/\bmongoose\b/, "mongoose"],
306
+ [/\bsqlite\b|better-sqlite/, "sqlite"],
307
+ [/\bstripe\b/, "stripe"],
308
+ [/\bgraphql\b/, "graphql"],
309
+ [/\btrpc\b/, "tRPC"],
310
+ [/\bzod\b/, "zod"],
311
+ [/\btailwind\b/, "tailwind"],
312
+ ];
313
+
314
+ /**
315
+ * Build a compact summary of a package's tech stack from its package.json.
316
+ */
317
+ function buildPackageSummary(rel: string, pkg: Record<string, unknown> | null): PackageSummary {
318
+ const name = typeof pkg?.name === "string" ? pkg.name : rel;
319
+ const allDeps = { ...(pkg?.dependencies as object | undefined), ...(pkg?.devDependencies as object | undefined) };
320
+ const depNames = Object.keys(allDeps).join(" ");
321
+ const scripts = (pkg?.scripts ?? {}) as Record<string, string>;
322
+
323
+ // Detect runtime from scripts or lock files
324
+ const testScript = scripts.test ?? "";
325
+ const runtime = testScript.includes("bun ") ? "bun" : testScript.includes("node ") ? "node" : "unknown";
326
+
327
+ // Detect framework
328
+ const framework = FRAMEWORK_PATTERNS.find(([re]) => re.test(depNames))?.[1] ?? "";
329
+
330
+ // Detect test runner
331
+ const testRunner =
332
+ TEST_RUNNER_PATTERNS.find(([re]) => re.test(depNames))?.[1] ?? (testScript.includes("bun test") ? "bun:test" : "");
333
+
334
+ // Key deps
335
+ const keyDeps = KEY_DEP_PATTERNS.filter(([re]) => re.test(depNames)).map(([, label]) => label);
336
+
337
+ return { path: rel, name, runtime, framework, testRunner, keyDeps };
338
+ }
339
+
340
+ /**
341
+ * Render per-package summaries as a compact markdown table for the prompt.
342
+ */
343
+ function buildPackageDetailsSection(details: PackageSummary[]): string {
344
+ if (details.length === 0) return "";
345
+
346
+ const rows = details.map((d) => {
347
+ const stack = [d.framework, d.testRunner, ...d.keyDeps].filter(Boolean).join(", ") || "—";
348
+ return `| \`${d.path}\` | ${d.name} | ${stack} |`;
349
+ });
350
+
351
+ return `\n## Package Tech Stacks\n\n| Path | Package | Stack |\n|:-----|:--------|:------|\n${rows.join("\n")}\n`;
352
+ }
353
+
241
354
  /**
242
355
  * Build codebase context markdown from scan results.
243
356
  */
@@ -278,8 +391,26 @@ function buildCodebaseContext(scan: CodebaseScan): string {
278
391
  * - Output schema (exact prd.json JSON structure)
279
392
  * - Complexity classification guide
280
393
  * - Test strategy guide
394
+ * - MW-007: Monorepo hint and package list when packages are detected
281
395
  */
282
- function buildPlanningPrompt(specContent: string, codebaseContext: string, outputFilePath?: string): string {
396
+ function buildPlanningPrompt(
397
+ specContent: string,
398
+ codebaseContext: string,
399
+ outputFilePath?: string,
400
+ packages?: string[],
401
+ packageDetails?: PackageSummary[],
402
+ ): string {
403
+ const isMonorepo = packages && packages.length > 0;
404
+ const packageDetailsSection =
405
+ packageDetails && packageDetails.length > 0 ? buildPackageDetailsSection(packageDetails) : "";
406
+ const monorepoHint = isMonorepo
407
+ ? `\n## Monorepo Context\n\nThis is a monorepo. Detected packages:\n${packages.map((p) => `- ${p}`).join("\n")}\n${packageDetailsSection}\nFor each user story, set the "workdir" field to the relevant package path (e.g. "packages/api"). Stories that span the root should omit "workdir".`
408
+ : "";
409
+
410
+ const workdirField = isMonorepo
411
+ ? `\n "workdir": "string — optional, relative path to package (e.g. \\"packages/api\\"). Omit for root-level stories.",`
412
+ : "";
413
+
283
414
  return `You are a senior software architect generating a product requirements document (PRD) as JSON.
284
415
 
285
416
  ## Spec
@@ -288,7 +419,7 @@ ${specContent}
288
419
 
289
420
  ## Codebase Context
290
421
 
291
- ${codebaseContext}
422
+ ${codebaseContext}${monorepoHint}
292
423
 
293
424
  ## Output Schema
294
425
 
@@ -307,7 +438,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
307
438
  "description": "string — detailed description of the story",
308
439
  "acceptanceCriteria": ["string — each AC line"],
309
440
  "tags": ["string — routing tags, e.g. feature, security, api"],
310
- "dependencies": ["string — story IDs this story depends on"],
441
+ "dependencies": ["string — story IDs this story depends on"],${workdirField}
311
442
  "status": "pending",
312
443
  "passes": false,
313
444
  "routing": {
@@ -5,9 +5,10 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from "node:fs";
8
- import { join, resolve } from "node:path";
8
+ import { dirname, join, resolve } from "node:path";
9
9
  import { getLogger } from "../logger";
10
10
  import { loadJsonFile } from "../utils/json-file";
11
+ import { mergePackageConfig } from "./merge";
11
12
  import { deepMergeConfig } from "./merger";
12
13
  import { MAX_DIRECTORY_DEPTH } from "./path-security";
13
14
  import { globalConfigDir } from "./paths";
@@ -108,3 +109,35 @@ export async function loadConfig(projectDir?: string, cliOverrides?: Record<stri
108
109
 
109
110
  return result.data as NaxConfig;
110
111
  }
112
+
113
+ /**
114
+ * Load config for a specific working directory (monorepo package).
115
+ *
116
+ * Resolution order:
117
+ * 1. Load root nax/config.json via loadConfig()
118
+ * 2. If packageDir is set, check <repoRoot>/<packageDir>/nax/config.json
119
+ * 3. If package config exists → merge quality.commands over root
120
+ * 4. Return merged config
121
+ *
122
+ * @param rootConfigPath - Absolute path to the root nax/config.json
123
+ * @param packageDir - Package directory relative to repo root (e.g. "packages/api")
124
+ */
125
+ export async function loadConfigForWorkdir(rootConfigPath: string, packageDir?: string): Promise<NaxConfig> {
126
+ const rootNaxDir = dirname(rootConfigPath);
127
+ const rootConfig = await loadConfig(rootNaxDir);
128
+
129
+ if (!packageDir) {
130
+ return rootConfig;
131
+ }
132
+
133
+ const repoRoot = dirname(rootNaxDir);
134
+ const packageConfigPath = join(repoRoot, packageDir, "nax", "config.json");
135
+
136
+ const packageOverride = await loadJsonFile<Partial<NaxConfig>>(packageConfigPath, "config");
137
+
138
+ if (!packageOverride) {
139
+ return rootConfig;
140
+ }
141
+
142
+ return mergePackageConfig(rootConfig, packageOverride);
143
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-Package Config Merge Utility (MW-008)
3
+ *
4
+ * Only quality.commands is mergeable — routing, plugins, execution,
5
+ * and agents stay root-only.
6
+ */
7
+
8
+ import type { NaxConfig } from "./schema";
9
+
10
+ /**
11
+ * Merge a package-level partial config override into a root config.
12
+ *
13
+ * Only quality.commands keys are merged. All other sections remain
14
+ * unchanged from the root config.
15
+ *
16
+ * @param root - Full root NaxConfig (already validated)
17
+ * @param packageOverride - Partial package-level override (only quality.commands honored)
18
+ * @returns New merged NaxConfig (immutable — does not mutate inputs)
19
+ */
20
+ export function mergePackageConfig(root: NaxConfig, packageOverride: Partial<NaxConfig>): NaxConfig {
21
+ const packageCommands = packageOverride.quality?.commands;
22
+
23
+ if (!packageCommands) {
24
+ return root;
25
+ }
26
+
27
+ return {
28
+ ...root,
29
+ quality: {
30
+ ...root.quality,
31
+ commands: {
32
+ ...root.quality.commands,
33
+ ...packageCommands,
34
+ },
35
+ },
36
+ };
37
+ }
@@ -476,6 +476,18 @@ export interface NaxConfig {
476
476
  decompose?: DecomposeConfig;
477
477
  /** Agent protocol settings (ACP-003) */
478
478
  agent?: AgentConfig;
479
+ /** Generate settings */
480
+ generate?: GenerateConfig;
481
+ }
482
+
483
+ /** Generate command configuration */
484
+ export interface GenerateConfig {
485
+ /**
486
+ * Agents to generate config files for (default: all).
487
+ * Restricts `nax generate` to only the listed agents.
488
+ * @example ["claude", "opencode"]
489
+ */
490
+ agents?: Array<"claude" | "codex" | "opencode" | "cursor" | "windsurf" | "aider" | "gemini">;
479
491
  }
480
492
 
481
493
  /** Agent protocol configuration (ACP-003) */