@nathapp/nax 0.47.0 → 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/dist/nax.js CHANGED
@@ -22210,7 +22210,7 @@ var package_default;
22210
22210
  var init_package = __esm(() => {
22211
22211
  package_default = {
22212
22212
  name: "@nathapp/nax",
22213
- version: "0.47.0",
22213
+ version: "0.48.0",
22214
22214
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
22215
22215
  type: "module",
22216
22216
  bin: {
@@ -22283,8 +22283,8 @@ var init_version = __esm(() => {
22283
22283
  NAX_VERSION = package_default.version;
22284
22284
  NAX_COMMIT = (() => {
22285
22285
  try {
22286
- if (/^[0-9a-f]{6,10}$/.test("ed0a660"))
22287
- return "ed0a660";
22286
+ if (/^[0-9a-f]{6,10}$/.test("3188738"))
22287
+ return "3188738";
22288
22288
  } catch {}
22289
22289
  try {
22290
22290
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -66438,7 +66438,7 @@ init_test_strategy();
66438
66438
 
66439
66439
  // src/context/generator.ts
66440
66440
  init_path_security2();
66441
- import { existsSync as existsSync10 } from "fs";
66441
+ import { existsSync as existsSync10, readFileSync } from "fs";
66442
66442
  import { join as join11 } from "path";
66443
66443
 
66444
66444
  // src/context/injector.ts
@@ -66852,6 +66852,73 @@ async function discoverPackages(repoRoot) {
66852
66852
  }
66853
66853
  return packages;
66854
66854
  }
66855
+ async function discoverWorkspacePackages(repoRoot) {
66856
+ const existing = await discoverPackages(repoRoot);
66857
+ if (existing.length > 0) {
66858
+ return existing.map((p) => p.replace(`${repoRoot}/`, ""));
66859
+ }
66860
+ const seen = new Set;
66861
+ const results = [];
66862
+ async function resolveGlobs(patterns) {
66863
+ for (const pattern of patterns) {
66864
+ if (pattern.startsWith("!"))
66865
+ continue;
66866
+ const base = pattern.replace(/\/+$/, "");
66867
+ const pkgPattern = base.endsWith("*") ? `${base}/package.json` : `${base}/*/package.json`;
66868
+ const g = new Bun.Glob(pkgPattern);
66869
+ for await (const match of g.scan(repoRoot)) {
66870
+ const rel = match.replace(/\/package\.json$/, "");
66871
+ if (!seen.has(rel)) {
66872
+ seen.add(rel);
66873
+ results.push(rel);
66874
+ }
66875
+ }
66876
+ }
66877
+ }
66878
+ const turboPath = join11(repoRoot, "turbo.json");
66879
+ if (existsSync10(turboPath)) {
66880
+ try {
66881
+ const turbo = JSON.parse(readFileSync(turboPath, "utf-8"));
66882
+ if (Array.isArray(turbo.packages)) {
66883
+ await resolveGlobs(turbo.packages);
66884
+ }
66885
+ } catch {}
66886
+ }
66887
+ const pkgPath = join11(repoRoot, "package.json");
66888
+ if (existsSync10(pkgPath)) {
66889
+ try {
66890
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
66891
+ const ws = pkg.workspaces;
66892
+ const patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
66893
+ if (patterns.length > 0)
66894
+ await resolveGlobs(patterns);
66895
+ } catch {}
66896
+ }
66897
+ const pnpmPath = join11(repoRoot, "pnpm-workspace.yaml");
66898
+ if (existsSync10(pnpmPath)) {
66899
+ try {
66900
+ const raw = readFileSync(pnpmPath, "utf-8");
66901
+ const lines = raw.split(`
66902
+ `);
66903
+ let inPackages = false;
66904
+ const patterns = [];
66905
+ for (const line of lines) {
66906
+ if (/^packages\s*:/.test(line)) {
66907
+ inPackages = true;
66908
+ continue;
66909
+ }
66910
+ if (inPackages && /^\s+-\s+/.test(line)) {
66911
+ patterns.push(line.replace(/^\s+-\s+['"]?/, "").replace(/['"]?\s*$/, ""));
66912
+ } else if (inPackages && !/^\s/.test(line)) {
66913
+ break;
66914
+ }
66915
+ }
66916
+ if (patterns.length > 0)
66917
+ await resolveGlobs(patterns);
66918
+ } catch {}
66919
+ }
66920
+ return results.sort();
66921
+ }
66855
66922
  async function generateForPackage(packageDir, config2, dryRun = false) {
66856
66923
  const contextPath = join11(packageDir, "nax", "context.md");
66857
66924
  if (!existsSync10(contextPath)) {
@@ -67059,7 +67126,9 @@ var _deps2 = {
67059
67126
  },
67060
67127
  mkdirp: (path) => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
67061
67128
  existsSync: (path) => existsSync11(path),
67062
- discoverPackages: (repoRoot) => discoverPackages(repoRoot)
67129
+ discoverWorkspacePackages: (repoRoot) => discoverWorkspacePackages(repoRoot),
67130
+ readPackageJsonAt: (path) => Bun.file(path).json().catch(() => null),
67131
+ createInteractionBridge: () => createCliInteractionBridge()
67063
67132
  };
67064
67133
  async function planCommand(workdir, config2, options) {
67065
67134
  const naxDir = join12(workdir, "nax");
@@ -67072,11 +67141,15 @@ async function planCommand(workdir, config2, options) {
67072
67141
  logger?.info("plan", "Scanning codebase...");
67073
67142
  const [scan, discoveredPackages, pkg] = await Promise.all([
67074
67143
  _deps2.scanCodebase(workdir),
67075
- _deps2.discoverPackages(workdir),
67144
+ _deps2.discoverWorkspacePackages(workdir),
67076
67145
  _deps2.readPackageJson(workdir)
67077
67146
  ]);
67078
67147
  const codebaseContext = buildCodebaseContext2(scan);
67079
- const relativePackages = discoveredPackages.map((p) => p.replace(`${workdir}/`, ""));
67148
+ const relativePackages = discoveredPackages.map((p) => p.startsWith("/") ? p.replace(`${workdir}/`, "") : p);
67149
+ const packageDetails = relativePackages.length > 0 ? await Promise.all(relativePackages.map(async (rel) => {
67150
+ const pkgJson = await _deps2.readPackageJsonAt(join12(workdir, rel, "package.json"));
67151
+ return buildPackageSummary(rel, pkgJson);
67152
+ })) : [];
67080
67153
  const projectName = detectProjectName(workdir, pkg);
67081
67154
  const branchName = options.branch ?? `feat/${options.feature}`;
67082
67155
  const outputDir = join12(naxDir, "features", options.feature);
@@ -67086,7 +67159,7 @@ async function planCommand(workdir, config2, options) {
67086
67159
  const timeoutSeconds = config2?.execution?.sessionTimeoutSeconds ?? 600;
67087
67160
  let rawResponse;
67088
67161
  if (options.auto) {
67089
- const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages);
67162
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages, packageDetails);
67090
67163
  const cliAdapter = _deps2.getAgent(agentName);
67091
67164
  if (!cliAdapter)
67092
67165
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
@@ -67098,11 +67171,11 @@ async function planCommand(workdir, config2, options) {
67098
67171
  }
67099
67172
  } catch {}
67100
67173
  } else {
67101
- const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages);
67174
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages, packageDetails);
67102
67175
  const adapter = _deps2.getAgent(agentName, config2);
67103
67176
  if (!adapter)
67104
67177
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
67105
- const interactionBridge = createCliInteractionBridge();
67178
+ const interactionBridge = _deps2.createInteractionBridge();
67106
67179
  const pidRegistry = new PidRegistry(workdir);
67107
67180
  const resolvedPerm = resolvePermissions(config2, "plan");
67108
67181
  const resolvedModel = config2?.plan?.model ?? "balanced";
@@ -67180,6 +67253,66 @@ function detectProjectName(workdir, pkg) {
67180
67253
  }
67181
67254
  return "unknown";
67182
67255
  }
67256
+ var FRAMEWORK_PATTERNS = [
67257
+ [/\bnext\b/, "Next.js"],
67258
+ [/\bnuxt\b/, "Nuxt"],
67259
+ [/\bremix\b/, "Remix"],
67260
+ [/\bexpress\b/, "Express"],
67261
+ [/\bfastify\b/, "Fastify"],
67262
+ [/\bhono\b/, "Hono"],
67263
+ [/\bnestjs|@nestjs\b/, "NestJS"],
67264
+ [/\breact\b/, "React"],
67265
+ [/\bvue\b/, "Vue"],
67266
+ [/\bsvelte\b/, "Svelte"],
67267
+ [/\bastro\b/, "Astro"],
67268
+ [/\belectron\b/, "Electron"]
67269
+ ];
67270
+ var TEST_RUNNER_PATTERNS = [
67271
+ [/\bvitest\b/, "vitest"],
67272
+ [/\bjest\b/, "jest"],
67273
+ [/\bmocha\b/, "mocha"],
67274
+ [/\bava\b/, "ava"]
67275
+ ];
67276
+ var KEY_DEP_PATTERNS = [
67277
+ [/\bprisma\b/, "prisma"],
67278
+ [/\bdrizzle-orm\b/, "drizzle"],
67279
+ [/\btypeorm\b/, "typeorm"],
67280
+ [/\bmongoose\b/, "mongoose"],
67281
+ [/\bsqlite\b|better-sqlite/, "sqlite"],
67282
+ [/\bstripe\b/, "stripe"],
67283
+ [/\bgraphql\b/, "graphql"],
67284
+ [/\btrpc\b/, "tRPC"],
67285
+ [/\bzod\b/, "zod"],
67286
+ [/\btailwind\b/, "tailwind"]
67287
+ ];
67288
+ function buildPackageSummary(rel, pkg) {
67289
+ const name = typeof pkg?.name === "string" ? pkg.name : rel;
67290
+ const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies };
67291
+ const depNames = Object.keys(allDeps).join(" ");
67292
+ const scripts = pkg?.scripts ?? {};
67293
+ const testScript = scripts.test ?? "";
67294
+ const runtime = testScript.includes("bun ") ? "bun" : testScript.includes("node ") ? "node" : "unknown";
67295
+ const framework = FRAMEWORK_PATTERNS.find(([re]) => re.test(depNames))?.[1] ?? "";
67296
+ const testRunner = TEST_RUNNER_PATTERNS.find(([re]) => re.test(depNames))?.[1] ?? (testScript.includes("bun test") ? "bun:test" : "");
67297
+ const keyDeps = KEY_DEP_PATTERNS.filter(([re]) => re.test(depNames)).map(([, label]) => label);
67298
+ return { path: rel, name, runtime, framework, testRunner, keyDeps };
67299
+ }
67300
+ function buildPackageDetailsSection(details) {
67301
+ if (details.length === 0)
67302
+ return "";
67303
+ const rows = details.map((d) => {
67304
+ const stack = [d.framework, d.testRunner, ...d.keyDeps].filter(Boolean).join(", ") || "\u2014";
67305
+ return `| \`${d.path}\` | ${d.name} | ${stack} |`;
67306
+ });
67307
+ return `
67308
+ ## Package Tech Stacks
67309
+
67310
+ | Path | Package | Stack |
67311
+ |:-----|:--------|:------|
67312
+ ${rows.join(`
67313
+ `)}
67314
+ `;
67315
+ }
67183
67316
  function buildCodebaseContext2(scan) {
67184
67317
  const sections = [];
67185
67318
  sections.push(`## Codebase Structure
@@ -67206,15 +67339,16 @@ function buildCodebaseContext2(scan) {
67206
67339
  return sections.join(`
67207
67340
  `);
67208
67341
  }
67209
- function buildPlanningPrompt(specContent, codebaseContext, outputFilePath, packages) {
67342
+ function buildPlanningPrompt(specContent, codebaseContext, outputFilePath, packages, packageDetails) {
67210
67343
  const isMonorepo = packages && packages.length > 0;
67344
+ const packageDetailsSection = packageDetails && packageDetails.length > 0 ? buildPackageDetailsSection(packageDetails) : "";
67211
67345
  const monorepoHint = isMonorepo ? `
67212
67346
  ## Monorepo Context
67213
67347
 
67214
67348
  This is a monorepo. Detected packages:
67215
67349
  ${packages.map((p) => `- ${p}`).join(`
67216
67350
  `)}
67217
-
67351
+ ${packageDetailsSection}
67218
67352
  For each user story, set the "workdir" field to the relevant package path (e.g. "packages/api"). Stories that span the root should omit "workdir".` : "";
67219
67353
  const workdirField = isMonorepo ? `
67220
67354
  "workdir": "string \u2014 optional, relative path to package (e.g. \\"packages/api\\"). Omit for root-level stories.",` : "";
@@ -68709,8 +68843,15 @@ async function generateCommand(options) {
68709
68843
  const suffix = dryRun ? " (dry run)" : "";
68710
68844
  console.log(source_default.green(`\u2713 ${agent} \u2192 ${result.outputFile} (${result.content.length} bytes${suffix})`));
68711
68845
  } else {
68712
- console.log(source_default.blue("\u2192 Generating configs for all agents..."));
68713
- const results = await generateAll(genOptions, config2);
68846
+ const configAgents = config2?.generate?.agents;
68847
+ const agentFilter = configAgents && configAgents.length > 0 ? configAgents : null;
68848
+ if (agentFilter) {
68849
+ console.log(source_default.blue(`\u2192 Generating configs for: ${agentFilter.join(", ")} (from config)...`));
68850
+ } else {
68851
+ console.log(source_default.blue("\u2192 Generating configs for all agents..."));
68852
+ }
68853
+ const allResults = await generateAll(genOptions, config2);
68854
+ const results = agentFilter ? allResults.filter((r) => agentFilter.includes(r.agent)) : allResults;
68714
68855
  let errorCount = 0;
68715
68856
  for (const result of results) {
68716
68857
  if (result.error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -142,6 +142,7 @@ export async function generateCommand(options: GenerateCommandOptions): Promise<
142
142
 
143
143
  try {
144
144
  if (options.agent) {
145
+ // CLI --agent flag: single specific agent (overrides config)
145
146
  const agent = options.agent as AgentType;
146
147
  console.log(chalk.blue(`→ Generating config for ${agent}...`));
147
148
 
@@ -155,9 +156,19 @@ export async function generateCommand(options: GenerateCommandOptions): Promise<
155
156
  const suffix = dryRun ? " (dry run)" : "";
156
157
  console.log(chalk.green(`✓ ${agent} → ${result.outputFile} (${result.content.length} bytes${suffix})`));
157
158
  } else {
158
- 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;
159
171
 
160
- const results = await generateAll(genOptions, config);
161
172
  let errorCount = 0;
162
173
 
163
174
  for (const result of results) {
package/src/cli/plan.ts CHANGED
@@ -17,7 +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 { discoverPackages } from "../context/generator";
20
+ import { discoverWorkspacePackages } from "../context/generator";
21
21
  import { PidRegistry } from "../execution/pid-registry";
22
22
  import { getLogger } from "../logger";
23
23
  import { validatePlanOutput } from "../prd/schema";
@@ -42,7 +42,15 @@ export const _deps = {
42
42
  },
43
43
  mkdirp: (path: string): Promise<void> => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
44
44
  existsSync: (path: string): boolean => existsSync(path),
45
- discoverPackages: (repoRoot: string): Promise<string[]> => discoverPackages(repoRoot),
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(),
46
54
  };
47
55
 
48
56
  // ─────────────────────────────────────────────────────────────────────────────
@@ -89,13 +97,25 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
89
97
  logger?.info("plan", "Scanning codebase...");
90
98
  const [scan, discoveredPackages, pkg] = await Promise.all([
91
99
  _deps.scanCodebase(workdir),
92
- _deps.discoverPackages(workdir),
100
+ _deps.discoverWorkspacePackages(workdir),
93
101
  _deps.readPackageJson(workdir),
94
102
  ]);
95
103
  const codebaseContext = buildCodebaseContext(scan);
96
104
 
97
- // MW-007: convert absolute paths to repo-relative for prompt readability
98
- const relativePackages = discoveredPackages.map((p) => p.replace(`${workdir}/`, ""));
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
+ : [];
99
119
 
100
120
  // Auto-detect project name
101
121
  const projectName = detectProjectName(workdir, pkg);
@@ -115,7 +135,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
115
135
  let rawResponse: string;
116
136
  if (options.auto) {
117
137
  // One-shot: use CLI adapter directly — simple completion doesn't need ACP session overhead
118
- const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages);
138
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages, packageDetails);
119
139
  const cliAdapter = _deps.getAgent(agentName);
120
140
  if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
121
141
  rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config });
@@ -130,10 +150,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
130
150
  }
131
151
  } else {
132
152
  // Interactive: agent writes PRD JSON directly to outputPath (avoids output truncation)
133
- const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages);
153
+ const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages, packageDetails);
134
154
  const adapter = _deps.getAgent(agentName, config);
135
155
  if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
136
- const interactionBridge = createCliInteractionBridge();
156
+ const interactionBridge = _deps.createInteractionBridge();
137
157
  const pidRegistry = new PidRegistry(workdir);
138
158
  const resolvedPerm = resolvePermissions(config, "plan");
139
159
  const resolvedModel = config?.plan?.model ?? "balanced";
@@ -246,6 +266,91 @@ function detectProjectName(workdir: string, pkg: Record<string, unknown> | null)
246
266
  return "unknown";
247
267
  }
248
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
+
249
354
  /**
250
355
  * Build codebase context markdown from scan results.
251
356
  */
@@ -293,10 +398,13 @@ function buildPlanningPrompt(
293
398
  codebaseContext: string,
294
399
  outputFilePath?: string,
295
400
  packages?: string[],
401
+ packageDetails?: PackageSummary[],
296
402
  ): string {
297
403
  const isMonorepo = packages && packages.length > 0;
404
+ const packageDetailsSection =
405
+ packageDetails && packageDetails.length > 0 ? buildPackageDetailsSection(packageDetails) : "";
298
406
  const monorepoHint = isMonorepo
299
- ? `\n## Monorepo Context\n\nThis is a monorepo. Detected packages:\n${packages.map((p) => `- ${p}`).join("\n")}\n\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".`
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".`
300
408
  : "";
301
409
 
302
410
  const workdirField = isMonorepo
@@ -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) */
@@ -5,7 +5,7 @@
5
5
  * Replaces the old constitution generator.
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import type { NaxConfig } from "../config";
11
11
  import { validateFilePath } from "../config/path-security";
@@ -166,6 +166,101 @@ export async function discoverPackages(repoRoot: string): Promise<string[]> {
166
166
  return packages;
167
167
  }
168
168
 
169
+ /**
170
+ * Discover packages from workspace manifests (turbo.json, package.json workspaces,
171
+ * pnpm-workspace.yaml). Used as fallback when no nax/context.md files exist yet.
172
+ *
173
+ * Returns relative paths (e.g. "packages/api") sorted alphabetically.
174
+ */
175
+ export async function discoverWorkspacePackages(repoRoot: string): Promise<string[]> {
176
+ // 1. Prefer packages that already have nax/context.md
177
+ const existing = await discoverPackages(repoRoot);
178
+ if (existing.length > 0) {
179
+ return existing.map((p) => p.replace(`${repoRoot}/`, ""));
180
+ }
181
+
182
+ const seen = new Set<string>();
183
+ const results: string[] = [];
184
+
185
+ async function resolveGlobs(patterns: string[]): Promise<void> {
186
+ for (const pattern of patterns) {
187
+ if (pattern.startsWith("!")) continue; // skip negations
188
+ // Convert workspace pattern to package.json glob so Bun can scan files
189
+ // "packages/*" → "packages/*/package.json"
190
+ // "packages/**" → "packages/**/package.json"
191
+ const base = pattern.replace(/\/+$/, ""); // strip trailing slashes
192
+ const pkgPattern = base.endsWith("*") ? `${base}/package.json` : `${base}/*/package.json`;
193
+
194
+ const g = new Bun.Glob(pkgPattern);
195
+ for await (const match of g.scan(repoRoot)) {
196
+ const rel = match.replace(/\/package\.json$/, "");
197
+ if (!seen.has(rel)) {
198
+ seen.add(rel);
199
+ results.push(rel);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ // 2. turbo v2+: turbo.json top-level "packages" array
206
+ const turboPath = join(repoRoot, "turbo.json");
207
+ if (existsSync(turboPath)) {
208
+ try {
209
+ const turbo = JSON.parse(readFileSync(turboPath, "utf-8")) as Record<string, unknown>;
210
+ if (Array.isArray(turbo.packages)) {
211
+ await resolveGlobs(turbo.packages as string[]);
212
+ }
213
+ } catch {
214
+ // malformed turbo.json — skip
215
+ }
216
+ }
217
+
218
+ // 3. root package.json "workspaces" (npm/yarn/bun/turbo v1)
219
+ const pkgPath = join(repoRoot, "package.json");
220
+ if (existsSync(pkgPath)) {
221
+ try {
222
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
223
+ const ws = pkg.workspaces;
224
+ const patterns: string[] = Array.isArray(ws)
225
+ ? (ws as string[])
226
+ : Array.isArray((ws as Record<string, unknown>)?.packages)
227
+ ? ((ws as Record<string, unknown>).packages as string[])
228
+ : [];
229
+ if (patterns.length > 0) await resolveGlobs(patterns);
230
+ } catch {
231
+ // malformed package.json — skip
232
+ }
233
+ }
234
+
235
+ // 4. pnpm-workspace.yaml
236
+ const pnpmPath = join(repoRoot, "pnpm-workspace.yaml");
237
+ if (existsSync(pnpmPath)) {
238
+ try {
239
+ const raw = readFileSync(pnpmPath, "utf-8");
240
+ // Simple YAML parse for "packages:\n - 'packages/*'" without full YAML dep
241
+ const lines = raw.split("\n");
242
+ let inPackages = false;
243
+ const patterns: string[] = [];
244
+ for (const line of lines) {
245
+ if (/^packages\s*:/.test(line)) {
246
+ inPackages = true;
247
+ continue;
248
+ }
249
+ if (inPackages && /^\s+-\s+/.test(line)) {
250
+ patterns.push(line.replace(/^\s+-\s+['"]?/, "").replace(/['"]?\s*$/, ""));
251
+ } else if (inPackages && !/^\s/.test(line)) {
252
+ break;
253
+ }
254
+ }
255
+ if (patterns.length > 0) await resolveGlobs(patterns);
256
+ } catch {
257
+ // malformed yaml — skip
258
+ }
259
+ }
260
+
261
+ return results.sort();
262
+ }
263
+
169
264
  /**
170
265
  * Generate the claude CLAUDE.md for a specific package.
171
266
  *