@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.46.3",
3
+ "version": "0.47.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,
@@ -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 _deps.scanCodebase(workdir);
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(specContent: string, codebaseContext: string, outputFilePath?: string): string {
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": {
@@ -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
+ }
@@ -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
- if (built.elements.length === 0) {
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
- return { markdown: formatContextAsMarkdown(built), builtContext: built };
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: ctx.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: ctx.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(ctx.workdir, "execution", "single-session", ctx.story.id);
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
- ctx.workdir,
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;