@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/bin/nax.ts +20 -0
- package/dist/nax.js +1344 -747
- package/package.json +1 -1
- package/src/cli/generate.ts +86 -13
- package/src/cli/init-context.ts +57 -0
- package/src/cli/init.ts +14 -1
- package/src/cli/plan.ts +139 -8
- package/src/config/loader.ts +34 -1
- package/src/config/merge.ts +37 -0
- package/src/config/runtime-types.ts +12 -0
- package/src/context/generator.ts +181 -1
- package/src/execution/story-context.ts +33 -2
- package/src/pipeline/stages/context.ts +5 -1
- package/src/pipeline/stages/execution.ts +26 -3
- package/src/pipeline/stages/review.ts +6 -1
- package/src/pipeline/stages/verify.ts +23 -7
- package/src/prd/schema.ts +17 -0
- package/src/prd/types.ts +6 -0
- package/src/precheck/checks-system.ts +25 -87
- package/src/review/orchestrator.ts +6 -2
- package/src/verification/smart-runner.ts +24 -2
package/package.json
CHANGED
package/src/cli/generate.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/cli/init-context.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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(
|
|
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": {
|
package/src/config/loader.ts
CHANGED
|
@@ -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) */
|