@nathapp/nax 0.46.3 → 0.47.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 +1200 -744
- package/package.json +1 -1
- package/src/cli/generate.ts +73 -11
- package/src/cli/init-context.ts +57 -0
- package/src/cli/init.ts +14 -1
- package/src/cli/plan.ts +30 -7
- package/src/config/loader.ts +34 -1
- package/src/config/merge.ts +37 -0
- package/src/context/generator.ts +85 -0
- 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,
|
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 { discoverPackages } 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,7 @@ 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
|
+
discoverPackages: (repoRoot: string): Promise<string[]> => discoverPackages(repoRoot),
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -85,11 +87,17 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
85
87
|
|
|
86
88
|
// Scan codebase for context
|
|
87
89
|
logger?.info("plan", "Scanning codebase...");
|
|
88
|
-
const scan = await
|
|
90
|
+
const [scan, discoveredPackages, pkg] = await Promise.all([
|
|
91
|
+
_deps.scanCodebase(workdir),
|
|
92
|
+
_deps.discoverPackages(workdir),
|
|
93
|
+
_deps.readPackageJson(workdir),
|
|
94
|
+
]);
|
|
89
95
|
const codebaseContext = buildCodebaseContext(scan);
|
|
90
96
|
|
|
97
|
+
// MW-007: convert absolute paths to repo-relative for prompt readability
|
|
98
|
+
const relativePackages = discoveredPackages.map((p) => p.replace(`${workdir}/`, ""));
|
|
99
|
+
|
|
91
100
|
// Auto-detect project name
|
|
92
|
-
const pkg = await _deps.readPackageJson(workdir);
|
|
93
101
|
const projectName = detectProjectName(workdir, pkg);
|
|
94
102
|
|
|
95
103
|
// Compute output path early — needed for interactive file-write prompt
|
|
@@ -107,7 +115,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
107
115
|
let rawResponse: string;
|
|
108
116
|
if (options.auto) {
|
|
109
117
|
// One-shot: use CLI adapter directly — simple completion doesn't need ACP session overhead
|
|
110
|
-
const prompt = buildPlanningPrompt(specContent, codebaseContext);
|
|
118
|
+
const prompt = buildPlanningPrompt(specContent, codebaseContext, undefined, relativePackages);
|
|
111
119
|
const cliAdapter = _deps.getAgent(agentName);
|
|
112
120
|
if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
113
121
|
rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config });
|
|
@@ -122,7 +130,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
122
130
|
}
|
|
123
131
|
} else {
|
|
124
132
|
// Interactive: agent writes PRD JSON directly to outputPath (avoids output truncation)
|
|
125
|
-
const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath);
|
|
133
|
+
const prompt = buildPlanningPrompt(specContent, codebaseContext, outputPath, relativePackages);
|
|
126
134
|
const adapter = _deps.getAgent(agentName, config);
|
|
127
135
|
if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
128
136
|
const interactionBridge = createCliInteractionBridge();
|
|
@@ -278,8 +286,23 @@ function buildCodebaseContext(scan: CodebaseScan): string {
|
|
|
278
286
|
* - Output schema (exact prd.json JSON structure)
|
|
279
287
|
* - Complexity classification guide
|
|
280
288
|
* - Test strategy guide
|
|
289
|
+
* - MW-007: Monorepo hint and package list when packages are detected
|
|
281
290
|
*/
|
|
282
|
-
function buildPlanningPrompt(
|
|
291
|
+
function buildPlanningPrompt(
|
|
292
|
+
specContent: string,
|
|
293
|
+
codebaseContext: string,
|
|
294
|
+
outputFilePath?: string,
|
|
295
|
+
packages?: string[],
|
|
296
|
+
): string {
|
|
297
|
+
const isMonorepo = packages && packages.length > 0;
|
|
298
|
+
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".`
|
|
300
|
+
: "";
|
|
301
|
+
|
|
302
|
+
const workdirField = isMonorepo
|
|
303
|
+
? `\n "workdir": "string — optional, relative path to package (e.g. \\"packages/api\\"). Omit for root-level stories.",`
|
|
304
|
+
: "";
|
|
305
|
+
|
|
283
306
|
return `You are a senior software architect generating a product requirements document (PRD) as JSON.
|
|
284
307
|
|
|
285
308
|
## Spec
|
|
@@ -288,7 +311,7 @@ ${specContent}
|
|
|
288
311
|
|
|
289
312
|
## Codebase Context
|
|
290
313
|
|
|
291
|
-
${codebaseContext}
|
|
314
|
+
${codebaseContext}${monorepoHint}
|
|
292
315
|
|
|
293
316
|
## Output Schema
|
|
294
317
|
|
|
@@ -307,7 +330,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
307
330
|
"description": "string — detailed description of the story",
|
|
308
331
|
"acceptanceCriteria": ["string — each AC line"],
|
|
309
332
|
"tags": ["string — routing tags, e.g. feature, security, api"],
|
|
310
|
-
"dependencies": ["string — story IDs this story depends on"]
|
|
333
|
+
"dependencies": ["string — story IDs this story depends on"],${workdirField}
|
|
311
334
|
"status": "pending",
|
|
312
335
|
"passes": false,
|
|
313
336
|
"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
|
+
}
|
package/src/context/generator.ts
CHANGED
|
@@ -129,5 +129,90 @@ async function generateAll(options: GenerateOptions, config: NaxConfig): Promise
|
|
|
129
129
|
return results;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Result from generateForPackage */
|
|
133
|
+
export interface PackageGenerationResult {
|
|
134
|
+
packageDir: string;
|
|
135
|
+
outputFile: string;
|
|
136
|
+
content: string;
|
|
137
|
+
written: boolean;
|
|
138
|
+
error?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Discover packages that have nax/context.md files.
|
|
143
|
+
*
|
|
144
|
+
* Scans up to 2 levels deep (one-level and two-level patterns).
|
|
145
|
+
*
|
|
146
|
+
* @param repoRoot - Absolute repo root to scan
|
|
147
|
+
* @returns Array of package directory paths (absolute)
|
|
148
|
+
*/
|
|
149
|
+
export async function discoverPackages(repoRoot: string): Promise<string[]> {
|
|
150
|
+
const packages: string[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (const pattern of ["*/nax/context.md", "*/*/nax/context.md"]) {
|
|
154
|
+
const glob = new Bun.Glob(pattern);
|
|
155
|
+
for await (const match of glob.scan(repoRoot)) {
|
|
156
|
+
// match is e.g. "packages/api/nax/context.md" — strip trailing /nax/context.md
|
|
157
|
+
const pkgRelative = match.replace(/\/nax\/context\.md$/, "");
|
|
158
|
+
const pkgAbsolute = join(repoRoot, pkgRelative);
|
|
159
|
+
if (!seen.has(pkgAbsolute)) {
|
|
160
|
+
seen.add(pkgAbsolute);
|
|
161
|
+
packages.push(pkgAbsolute);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return packages;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Generate the claude CLAUDE.md for a specific package.
|
|
171
|
+
*
|
|
172
|
+
* Reads `<packageDir>/nax/context.md` and writes `<packageDir>/CLAUDE.md`.
|
|
173
|
+
* Per-package CLAUDE.md contains only package-specific content — Claude Code's
|
|
174
|
+
* native directory hierarchy merges root CLAUDE.md + package CLAUDE.md at runtime.
|
|
175
|
+
*/
|
|
176
|
+
export async function generateForPackage(
|
|
177
|
+
packageDir: string,
|
|
178
|
+
config: NaxConfig,
|
|
179
|
+
dryRun = false,
|
|
180
|
+
): Promise<PackageGenerationResult> {
|
|
181
|
+
const contextPath = join(packageDir, "nax", "context.md");
|
|
182
|
+
|
|
183
|
+
if (!existsSync(contextPath)) {
|
|
184
|
+
return {
|
|
185
|
+
packageDir,
|
|
186
|
+
outputFile: "CLAUDE.md",
|
|
187
|
+
content: "",
|
|
188
|
+
written: false,
|
|
189
|
+
error: `context.md not found: ${contextPath}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const options: GenerateOptions = {
|
|
195
|
+
contextPath,
|
|
196
|
+
outputDir: packageDir,
|
|
197
|
+
workdir: packageDir,
|
|
198
|
+
dryRun,
|
|
199
|
+
autoInject: true,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const result = await generateFor("claude", options, config);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
packageDir,
|
|
206
|
+
outputFile: result.outputFile,
|
|
207
|
+
content: result.content,
|
|
208
|
+
written: result.written,
|
|
209
|
+
error: result.error,
|
|
210
|
+
};
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
213
|
+
return { packageDir, outputFile: "CLAUDE.md", content: "", written: false, error };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
132
217
|
export { generateFor, generateAll };
|
|
133
218
|
export type { AgentType };
|
|
@@ -127,14 +127,33 @@ export async function buildStoryContext(prd: PRD, story: UserStory, _config: Nax
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Load package-level context.md content if it exists.
|
|
132
|
+
*
|
|
133
|
+
* Reads <packageWorkdir>/nax/context.md and returns its content, or null
|
|
134
|
+
* if the file does not exist.
|
|
135
|
+
*
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
async function loadPackageContextMd(packageWorkdir: string): Promise<string | null> {
|
|
139
|
+
const contextPath = `${packageWorkdir}/nax/context.md`;
|
|
140
|
+
const file = Bun.file(contextPath);
|
|
141
|
+
if (!(await file.exists())) return null;
|
|
142
|
+
return file.text();
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
/**
|
|
131
146
|
* Build story context returning both markdown and element-level data.
|
|
132
147
|
* Used by `nax prompts` CLI for accurate frontmatter token counts.
|
|
148
|
+
*
|
|
149
|
+
* When `packageWorkdir` is provided (absolute path of story.workdir),
|
|
150
|
+
* appends the package-level nax/context.md after the root context.
|
|
133
151
|
*/
|
|
134
152
|
export async function buildStoryContextFull(
|
|
135
153
|
prd: PRD,
|
|
136
154
|
story: UserStory,
|
|
137
155
|
config: NaxConfig,
|
|
156
|
+
packageWorkdir?: string,
|
|
138
157
|
): Promise<{ markdown: string; builtContext: BuiltContext } | undefined> {
|
|
139
158
|
try {
|
|
140
159
|
const storyContext: StoryContext = {
|
|
@@ -152,11 +171,23 @@ export async function buildStoryContextFull(
|
|
|
152
171
|
|
|
153
172
|
const built = await buildContext(storyContext, budget);
|
|
154
173
|
|
|
155
|
-
|
|
174
|
+
// MW-003: append package-level context.md if workdir is set
|
|
175
|
+
let packageSection = "";
|
|
176
|
+
if (packageWorkdir) {
|
|
177
|
+
const pkgContent = await loadPackageContextMd(packageWorkdir);
|
|
178
|
+
if (pkgContent) {
|
|
179
|
+
packageSection = `\n---\n\n${pkgContent.trim()}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (built.elements.length === 0 && !packageSection) {
|
|
156
184
|
return undefined;
|
|
157
185
|
}
|
|
158
186
|
|
|
159
|
-
|
|
187
|
+
const baseMarkdown = built.elements.length > 0 ? formatContextAsMarkdown(built) : "";
|
|
188
|
+
const markdown = packageSection ? `${baseMarkdown}${packageSection}` : baseMarkdown;
|
|
189
|
+
|
|
190
|
+
return { markdown, builtContext: built };
|
|
160
191
|
} catch (error) {
|
|
161
192
|
const logger = getSafeLogger();
|
|
162
193
|
logger?.warn("context", "Context builder failed", {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { join } from "node:path";
|
|
23
24
|
import type { ContextElement } from "../../context/types";
|
|
24
25
|
import { buildStoryContextFull } from "../../execution/helpers";
|
|
25
26
|
import { getLogger } from "../../logger";
|
|
@@ -33,8 +34,11 @@ export const contextStage: PipelineStage = {
|
|
|
33
34
|
async execute(ctx: PipelineContext): Promise<StageResult> {
|
|
34
35
|
const logger = getLogger();
|
|
35
36
|
|
|
37
|
+
// MW-003: resolve package workdir for per-package context.md loading
|
|
38
|
+
const packageWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : undefined;
|
|
39
|
+
|
|
36
40
|
// Build context from PRD with element-level tracking
|
|
37
|
-
const result = await buildStoryContextFull(ctx.prd, ctx.story, ctx.config);
|
|
41
|
+
const result = await buildStoryContextFull(ctx.prd, ctx.story, ctx.config, packageWorkdir);
|
|
38
42
|
|
|
39
43
|
// SOFT FAILURE: Empty context is acceptable — agent can work without PRD context
|
|
40
44
|
// This happens when no relevant stories/context is found, which is normal
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
33
35
|
import { getAgent, validateAgentForTier } from "../../agents";
|
|
34
36
|
import { resolveModel } from "../../config";
|
|
35
37
|
import { resolvePermissions } from "../../config/permissions";
|
|
@@ -40,6 +42,22 @@ import { runThreeSessionTdd } from "../../tdd";
|
|
|
40
42
|
import { autoCommitIfDirty, detectMergeConflict } from "../../utils/git";
|
|
41
43
|
import type { PipelineContext, PipelineStage, StageResult } from "../types";
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the effective working directory for a story.
|
|
47
|
+
* When story.workdir is set, returns join(repoRoot, story.workdir).
|
|
48
|
+
* Otherwise returns the repo root unchanged.
|
|
49
|
+
*
|
|
50
|
+
* MW-001 runtime check: throws if the resolved workdir does not exist on disk.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveStoryWorkdir(repoRoot: string, storyWorkdir?: string): string {
|
|
53
|
+
if (!storyWorkdir) return repoRoot;
|
|
54
|
+
const resolved = join(repoRoot, storyWorkdir);
|
|
55
|
+
if (!existsSync(resolved)) {
|
|
56
|
+
throw new Error(`[execution] story.workdir "${storyWorkdir}" does not exist at "${resolved}"`);
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
62
|
* Detect if agent output contains ambiguity signals
|
|
45
63
|
* Checks for keywords that indicate the agent is unsure about the implementation
|
|
@@ -128,11 +146,13 @@ export const executionStage: PipelineStage = {
|
|
|
128
146
|
lite: isLiteMode,
|
|
129
147
|
});
|
|
130
148
|
|
|
149
|
+
const effectiveWorkdir = _executionDeps.resolveStoryWorkdir(ctx.workdir, ctx.story.workdir);
|
|
150
|
+
|
|
131
151
|
const tddResult = await runThreeSessionTdd({
|
|
132
152
|
agent,
|
|
133
153
|
story: ctx.story,
|
|
134
154
|
config: ctx.config,
|
|
135
|
-
workdir:
|
|
155
|
+
workdir: effectiveWorkdir,
|
|
136
156
|
modelTier: ctx.routing.modelTier,
|
|
137
157
|
featureName: ctx.prd.feature,
|
|
138
158
|
contextMarkdown: ctx.contextMarkdown,
|
|
@@ -212,9 +232,11 @@ export const executionStage: PipelineStage = {
|
|
|
212
232
|
});
|
|
213
233
|
}
|
|
214
234
|
|
|
235
|
+
const storyWorkdir = _executionDeps.resolveStoryWorkdir(ctx.workdir, ctx.story.workdir);
|
|
236
|
+
|
|
215
237
|
const result = await agent.run({
|
|
216
238
|
prompt: ctx.prompt,
|
|
217
|
-
workdir:
|
|
239
|
+
workdir: storyWorkdir,
|
|
218
240
|
modelTier: ctx.routing.modelTier,
|
|
219
241
|
modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
|
|
220
242
|
timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
|
|
@@ -258,7 +280,7 @@ export const executionStage: PipelineStage = {
|
|
|
258
280
|
ctx.agentResult = result;
|
|
259
281
|
|
|
260
282
|
// BUG-058: Auto-commit if agent left uncommitted changes (single-session/test-after)
|
|
261
|
-
await autoCommitIfDirty(
|
|
283
|
+
await autoCommitIfDirty(storyWorkdir, "execution", "single-session", ctx.story.id);
|
|
262
284
|
|
|
263
285
|
// merge-conflict trigger: detect CONFLICT markers in agent output
|
|
264
286
|
const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
|
|
@@ -327,4 +349,5 @@ export const _executionDeps = {
|
|
|
327
349
|
checkMergeConflict,
|
|
328
350
|
isAmbiguousOutput,
|
|
329
351
|
checkStoryAmbiguity,
|
|
352
|
+
resolveStoryWorkdir,
|
|
330
353
|
};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
// RE-ARCH: rewrite
|
|
14
|
+
import { join } from "node:path";
|
|
14
15
|
import { checkSecurityReview, isTriggerEnabled } from "../../interaction/triggers";
|
|
15
16
|
import { getLogger } from "../../logger";
|
|
16
17
|
import { reviewOrchestrator } from "../../review/orchestrator";
|
|
@@ -25,12 +26,16 @@ export const reviewStage: PipelineStage = {
|
|
|
25
26
|
|
|
26
27
|
logger.info("review", "Running review phase", { storyId: ctx.story.id });
|
|
27
28
|
|
|
29
|
+
// MW-010: scope review to package directory when story.workdir is set
|
|
30
|
+
const effectiveWorkdir = ctx.story.workdir ? join(ctx.workdir, ctx.story.workdir) : ctx.workdir;
|
|
31
|
+
|
|
28
32
|
const result = await reviewOrchestrator.review(
|
|
29
33
|
ctx.config.review,
|
|
30
|
-
|
|
34
|
+
effectiveWorkdir,
|
|
31
35
|
ctx.config.execution,
|
|
32
36
|
ctx.plugins,
|
|
33
37
|
ctx.storyGitRef,
|
|
38
|
+
ctx.story.workdir, // MW-010: scope changed-file checks to package
|
|
34
39
|
);
|
|
35
40
|
|
|
36
41
|
ctx.reviewResult = result.builtIn;
|