@nathapp/nax 0.41.0 → 0.42.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.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,11 +12,12 @@
12
12
  "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit",
14
14
  "lint": "bun x biome check src/ bin/",
15
- "test": "NAX_SKIP_PRECHECK=1 bun test test/ --timeout=60000",
16
- "test:watch": "bun test --watch",
17
- "test:unit": "bun test ./test/unit/ --timeout=60000",
18
- "test:integration": "bun test ./test/integration/ --timeout=60000",
19
- "test:ui": "bun test ./test/ui/ --timeout=60000",
15
+ "test": "CI=1 NAX_SKIP_PRECHECK=1 bun test test/ --timeout=60000",
16
+ "test:watch": "CI=1 bun test --watch",
17
+ "test:unit": "CI=1 NAX_SKIP_PRECHECK=1 bun test ./test/unit/ --timeout=60000",
18
+ "test:integration": "CI=1 NAX_SKIP_PRECHECK=1 bun test ./test/integration/ --timeout=60000",
19
+ "test:ui": "CI=1 bun test ./test/ui/ --timeout=60000",
20
+ "test:real": "NAX_SKIP_PRECHECK=1 bun test test/ --timeout=60000",
20
21
  "check-test-overlap": "bun run scripts/check-test-overlap.ts",
21
22
  "check-dead-tests": "bun run scripts/check-dead-tests.ts",
22
23
  "check:test-sizes": "bun run scripts/check-test-sizes.ts",
@@ -505,7 +505,9 @@ export class AcpAgentAdapter implements AgentAdapter {
505
505
  }
506
506
  }
507
507
 
508
- if (turnCount >= MAX_TURNS) {
508
+ // Only warn if we exhausted turns while still receiving questions (interactive mode).
509
+ // In non-interactive mode (MAX_TURNS=1) the loop always completes in 1 turn — not a warning.
510
+ if (turnCount >= MAX_TURNS && options.interactionBridge) {
509
511
  getSafeLogger()?.warn("acp-adapter", "Reached max turns limit", { sessionName, maxTurns: MAX_TURNS });
510
512
  }
511
513
  } finally {
@@ -586,10 +588,6 @@ export class AcpAgentAdapter implements AgentAdapter {
586
588
  }
587
589
 
588
590
  async plan(options: PlanOptions): Promise<PlanResult> {
589
- if (options.interactive) {
590
- throw new Error("[acp-adapter] plan() interactive mode is not yet supported via ACP");
591
- }
592
-
593
591
  const modelDef = options.modelDef ?? { provider: "anthropic", model: "default" };
594
592
  // Timeout: from options, or config, or fallback to 600s
595
593
  const timeoutSeconds =
@@ -56,6 +56,16 @@ export const _decomposeDeps = {
56
56
  // Re-export deps for testing
57
57
  export { _runOnceDeps, _completeDeps };
58
58
 
59
+ /**
60
+ * Injectable dependencies for ClaudeCodeAdapter retry loop.
61
+ * Exported so tests can replace sleep with a no-op spy.
62
+ *
63
+ * @internal
64
+ */
65
+ export const _claudeAdapterDeps = {
66
+ sleep: (ms: number): Promise<void> => Bun.sleep(ms),
67
+ };
68
+
59
69
  /**
60
70
  * Claude Code agent adapter implementation.
61
71
  *
@@ -120,7 +130,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
120
130
  const backoffMs = 2 ** attempt * 1000;
121
131
  const logger = getLogger();
122
132
  logger.warn("agent", "Rate limited, retrying", { backoffSeconds: backoffMs / 1000, attempt, maxRetries });
123
- await Bun.sleep(backoffMs);
133
+ await _claudeAdapterDeps.sleep(backoffMs);
124
134
  continue;
125
135
  }
126
136
 
@@ -138,7 +148,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
138
148
  attempt,
139
149
  maxRetries,
140
150
  });
141
- await Bun.sleep(backoffMs);
151
+ await _claudeAdapterDeps.sleep(backoffMs);
142
152
  continue;
143
153
  }
144
154
 
@@ -4,7 +4,7 @@
4
4
  * Scans the project directory to generate a summary for LLM classification.
5
5
  */
6
6
 
7
- import { existsSync } from "node:fs";
7
+ import { existsSync, readdirSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import type { CodebaseScan } from "./types";
10
10
 
@@ -75,38 +75,34 @@ async function generateFileTree(dir: string, maxDepth: number, currentDepth = 0,
75
75
  const entries: string[] = [];
76
76
 
77
77
  try {
78
- const dirEntries = Array.from(
79
- new Bun.Glob("*").scanSync({
80
- cwd: dir,
81
- onlyFiles: false,
82
- }),
83
- );
78
+ // readdirSync with withFileTypes avoids a separate stat() call per entry —
79
+ // directory info comes from the single readdir syscall (much faster on large trees).
80
+ const dirEntries = readdirSync(dir, { withFileTypes: true });
84
81
 
85
82
  // Sort: directories first, then files
86
83
  dirEntries.sort((a, b) => {
87
- const aIsDir = !a.includes(".");
88
- const bIsDir = !b.includes(".");
89
- if (aIsDir && !bIsDir) return -1;
90
- if (!aIsDir && bIsDir) return 1;
91
- return a.localeCompare(b);
84
+ if (a.isDirectory() && !b.isDirectory()) return -1;
85
+ if (!a.isDirectory() && b.isDirectory()) return 1;
86
+ return a.name.localeCompare(b.name);
92
87
  });
93
88
 
94
89
  for (let i = 0; i < dirEntries.length; i++) {
95
- const entry = dirEntries[i];
96
- const fullPath = join(dir, entry);
90
+ const dirent = dirEntries[i];
97
91
  const isLast = i === dirEntries.length - 1;
98
92
  const connector = isLast ? "└── " : "├── ";
99
93
  const childPrefix = isLast ? " " : "│ ";
94
+ const isDir = dirent.isDirectory();
100
95
 
101
- // Check if directory
102
- const stat = await Bun.file(fullPath).stat();
103
- const isDir = stat.isDirectory();
104
-
105
- entries.push(`${prefix}${connector}${entry}${isDir ? "/" : ""}`);
96
+ entries.push(`${prefix}${connector}${dirent.name}${isDir ? "/" : ""}`);
106
97
 
107
98
  // Recurse into directories
108
99
  if (isDir) {
109
- const subtree = await generateFileTree(fullPath, maxDepth, currentDepth + 1, prefix + childPrefix);
100
+ const subtree = await generateFileTree(
101
+ join(dir, dirent.name),
102
+ maxDepth,
103
+ currentDepth + 1,
104
+ prefix + childPrefix,
105
+ );
110
106
  if (subtree) {
111
107
  entries.push(subtree);
112
108
  }
package/src/cli/plan.ts CHANGED
@@ -1,178 +1,204 @@
1
1
  /**
2
- * Plan Command — Interactive planning via agent plan mode
2
+ * Plan Command — Generate prd.json from a spec file via LLM one-shot call
3
3
  *
4
- * Spawns a coding agent in plan mode to gather requirements,
5
- * ask clarifying questions, and generate a structured specification.
4
+ * Reads a spec file (--from), builds a planning prompt with codebase context,
5
+ * calls adapter.complete(), validates the JSON response, and writes prd.json.
6
+ *
7
+ * Interactive mode is not yet implemented (PLN-002).
6
8
  */
7
9
 
8
10
  import { existsSync } from "node:fs";
9
11
  import { join } from "node:path";
10
- import { createInterface } from "node:readline";
11
- import { ClaudeCodeAdapter } from "../agents/claude";
12
- import type { PlanOptions } from "../agents/types";
12
+ import { getAgent } from "../agents/registry";
13
+ import type { AgentAdapter } from "../agents/types";
13
14
  import { scanCodebase } from "../analyze/scanner";
15
+ import type { CodebaseScan } from "../analyze/types";
14
16
  import type { NaxConfig } from "../config";
15
- import { resolveModel } from "../config/schema";
16
17
  import { getLogger } from "../logger";
18
+ import { validatePlanOutput } from "../prd/schema";
17
19
 
18
20
  // ─────────────────────────────────────────────────────────────────────────────
19
- // Question detection helpers for ACP interaction bridge
21
+ // Dependency injection (_deps) override in tests
20
22
  // ─────────────────────────────────────────────────────────────────────────────
21
23
 
22
- const QUESTION_PATTERNS = [/\?[\s]*$/, /\bwhich\b/i, /\bshould i\b/i, /\bdo you want\b/i, /\bwould you like\b/i];
24
+ export const _deps = {
25
+ readFile: (path: string): Promise<string> => Bun.file(path).text(),
26
+ writeFile: (path: string, content: string): Promise<void> => Bun.write(path, content).then(() => {}),
27
+ scanCodebase: (workdir: string): Promise<CodebaseScan> => scanCodebase(workdir),
28
+ getAgent: (name: string): AgentAdapter | undefined => getAgent(name),
29
+ readPackageJson: (workdir: string): Promise<Record<string, unknown> | null> =>
30
+ Bun.file(join(workdir, "package.json"))
31
+ .json()
32
+ .catch(() => null),
33
+ spawnSync: (cmd: string[], opts?: { cwd?: string }): { stdout: Buffer; exitCode: number | null } => {
34
+ const result = Bun.spawnSync(cmd, opts ? { cwd: opts.cwd } : {});
35
+ return { stdout: result.stdout as Buffer, exitCode: result.exitCode };
36
+ },
37
+ mkdirp: (path: string): Promise<void> => Bun.spawn(["mkdir", "-p", path]).exited.then(() => {}),
38
+ };
23
39
 
24
- async function detectQuestion(text: string): Promise<boolean> {
25
- return QUESTION_PATTERNS.some((p) => p.test(text.trim()));
26
- }
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Plan options
42
+ // ─────────────────────────────────────────────────────────────────────────────
27
43
 
28
- async function askHuman(question: string): Promise<string> {
29
- const rl = createInterface({ input: process.stdin, output: process.stdout });
30
- return new Promise((resolve) => {
31
- rl.question(`\n[Agent asks]: ${question}\nYour answer: `, (answer) => {
32
- rl.close();
33
- resolve(answer.trim());
34
- });
35
- });
44
+ export interface PlanCommandOptions {
45
+ /** Path to spec file (--from) required */
46
+ from: string;
47
+ /** Feature name (-f) required */
48
+ feature: string;
49
+ /** Run in auto (one-shot LLM) mode */
50
+ auto?: boolean;
51
+ /** Override default branch name (-b) */
52
+ branch?: string;
36
53
  }
37
54
 
38
- /**
39
- * Template for structured specification output.
40
- *
41
- * This template guides the agent to produce a consistent spec format
42
- * that can be parsed by the analyze command.
43
- */
44
- const SPEC_TEMPLATE = `# Feature: [title]
45
-
46
- ## Problem
47
- Why this is needed.
48
-
49
- ## Requirements
50
- - REQ-1: ...
51
- - REQ-2: ...
52
-
53
- ## Acceptance Criteria
54
- - AC-1: ...
55
-
56
- ## Technical Notes
57
- Architecture hints, constraints, dependencies.
58
-
59
- ## Out of Scope
60
- What this does NOT include.
61
- `;
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ // Public API
57
+ // ─────────────────────────────────────────────────────────────────────────────
62
58
 
63
59
  /**
64
- * Run the plan command to generate a feature specification.
60
+ * Run the plan command: read spec, call LLM, write prd.json.
65
61
  *
66
- * @param prompt - The feature description or task
67
62
  * @param workdir - Project root directory
68
- * @param config - Ngent configuration
69
- * @param options - Command options (interactive, from)
70
- * @returns Path to the generated spec file
63
+ * @param config - Nax configuration
64
+ * @param options - Command options
65
+ * @returns Path to generated prd.json
71
66
  */
72
- export async function planCommand(
73
- prompt: string,
74
- workdir: string,
75
- config: NaxConfig,
76
- options: {
77
- interactive?: boolean;
78
- from?: string;
79
- } = {},
80
- ): Promise<string> {
81
- const interactive = options.interactive !== false; // Default to true
82
- const ngentDir = join(workdir, "nax");
83
- const outputPath = join(ngentDir, config.plan.outputPath);
84
-
85
- // Ensure nax directory exists
86
- if (!existsSync(ngentDir)) {
67
+ export async function planCommand(workdir: string, config: NaxConfig, options: PlanCommandOptions): Promise<string> {
68
+ const naxDir = join(workdir, "nax");
69
+
70
+ if (!existsSync(naxDir)) {
87
71
  throw new Error(`nax directory not found. Run 'nax init' first in ${workdir}`);
88
72
  }
89
73
 
90
- // Scan codebase for context
91
74
  const logger = getLogger();
92
- logger.info("cli", "Scanning codebase...");
93
- const scan = await scanCodebase(workdir);
94
75
 
95
- // Build codebase context markdown
76
+ // Read spec from --from path
77
+ logger?.info("plan", "Reading spec", { from: options.from });
78
+ const specContent = await _deps.readFile(options.from);
79
+
80
+ // Scan codebase for context
81
+ logger?.info("plan", "Scanning codebase...");
82
+ const scan = await _deps.scanCodebase(workdir);
96
83
  const codebaseContext = buildCodebaseContext(scan);
97
84
 
98
- // Resolve model for planning
99
- const modelTier = config.plan.model;
100
- const modelEntry = config.models[modelTier];
101
- const modelDef = resolveModel(modelEntry);
102
-
103
- // Build full prompt with template
104
- const fullPrompt = buildPlanPrompt(prompt, SPEC_TEMPLATE);
105
-
106
- // Prepare plan options
107
- const planOptions: PlanOptions = {
108
- prompt: fullPrompt,
109
- workdir,
110
- interactive,
111
- codebaseContext,
112
- inputFile: options.from,
113
- modelTier,
114
- modelDef,
115
- config,
116
- // Wire ACP interaction bridge for mid-session Q&A (only in interactive mode)
117
- interactionBridge: interactive ? { detectQuestion, onQuestionDetected: askHuman } : undefined,
118
- };
85
+ // Auto-detect project name
86
+ const pkg = await _deps.readPackageJson(workdir);
87
+ const projectName = detectProjectName(workdir, pkg);
119
88
 
120
- // Run agent in plan mode
121
- const adapter = new ClaudeCodeAdapter();
122
-
123
- logger.info("cli", interactive ? "Starting interactive planning session..." : `Reading from ${options.from}...`, {
124
- interactive,
125
- from: options.from,
126
- });
127
-
128
- const result = await adapter.plan(planOptions);
129
-
130
- // Write spec to output file
131
- if (interactive) {
132
- // In interactive mode, the agent may have written directly
133
- // But we also capture and write to ensure consistency
134
- if (result.specContent) {
135
- await Bun.write(outputPath, result.specContent);
136
- } else {
137
- // If agent wrote directly, verify it exists
138
- if (!existsSync(outputPath)) {
139
- throw new Error(`Interactive planning completed but spec not found at ${outputPath}`);
140
- }
141
- }
89
+ // Build prompt
90
+ const branchName = options.branch ?? `feat/${options.feature}`;
91
+ const prompt = buildPlanningPrompt(specContent, codebaseContext);
92
+
93
+ // Get agent adapter
94
+ const agentName = config?.autoMode?.defaultAgent ?? "claude";
95
+ const adapter = _deps.getAgent(agentName);
96
+ if (!adapter) {
97
+ throw new Error(`[plan] No agent adapter found for '${agentName}'`);
98
+ }
99
+
100
+ // Timeout: from config, or default to 600 seconds (10 min)
101
+ const timeoutSeconds = config?.execution?.sessionTimeoutSeconds ?? 600;
102
+
103
+ // Route to auto (one-shot) or interactive (multi-turn) mode
104
+ let rawResponse: string;
105
+ if (options.auto) {
106
+ rawResponse = await adapter.complete(prompt, { jsonMode: true });
142
107
  } else {
143
- // In non-interactive mode, we have the spec in result
144
- if (!result.specContent) {
145
- throw new Error("Agent did not produce specification content");
108
+ const interactionBridge = createCliInteractionBridge();
109
+ logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
110
+ try {
111
+ const result = await adapter.plan({
112
+ prompt,
113
+ workdir,
114
+ interactive: true,
115
+ timeoutSeconds,
116
+ interactionBridge,
117
+ });
118
+ rawResponse = result.specContent;
119
+ } finally {
120
+ logger?.info("plan", "Interactive session ended");
146
121
  }
147
- await Bun.write(outputPath, result.specContent);
148
122
  }
149
123
 
150
- logger.info("cli", "✓ Specification written to output", { outputPath });
124
+ // Validate and normalize: handles markdown extraction, trailing commas, LLM quirks,
125
+ // complexity normalization, dependency cross-ref, and forces status → pending.
126
+ const finalPrd = validatePlanOutput(rawResponse, options.feature, branchName);
127
+
128
+ // Override project with auto-detected name (validatePlanOutput fills feature/branchName already)
129
+ finalPrd.project = projectName;
130
+
131
+ // Write output
132
+ const outputDir = join(naxDir, "features", options.feature);
133
+ const outputPath = join(outputDir, "prd.json");
134
+ await _deps.mkdirp(outputDir);
135
+ await _deps.writeFile(outputPath, JSON.stringify(finalPrd, null, 2));
136
+
137
+ logger?.info("plan", "[OK] PRD written", { outputPath });
151
138
 
152
139
  return outputPath;
153
140
  }
154
141
 
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // Interaction and extraction helpers
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Create a CLI interaction bridge for stdin-based human interaction.
148
+ * This bridge accepts questions from the agent and prompts the user via stdin.
149
+ */
150
+ function createCliInteractionBridge(): {
151
+ detectQuestion: (text: string) => Promise<boolean>;
152
+ onQuestionDetected: (text: string) => Promise<string>;
153
+ } {
154
+ return {
155
+ async detectQuestion(text: string): Promise<boolean> {
156
+ // Simple heuristic: detect if text contains a question mark
157
+ return text.includes("?");
158
+ },
159
+
160
+ async onQuestionDetected(text: string): Promise<string> {
161
+ // For now, return the question text as-is to be used as follow-up prompt
162
+ // In a real CLI, this would read from stdin
163
+ // TODO: Implement stdin reading for actual CLI interaction
164
+ return text;
165
+ },
166
+ };
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+ // Private helpers
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Detect project name from package.json or git remote.
175
+ */
176
+ function detectProjectName(workdir: string, pkg: Record<string, unknown> | null): string {
177
+ if (pkg?.name && typeof pkg.name === "string") {
178
+ return pkg.name;
179
+ }
180
+
181
+ const result = _deps.spawnSync(["git", "remote", "get-url", "origin"], { cwd: workdir });
182
+ if (result.exitCode === 0) {
183
+ const url = result.stdout.toString().trim();
184
+ const match = url.match(/\/([^/]+?)(?:\.git)?$/);
185
+ if (match?.[1]) return match[1];
186
+ }
187
+
188
+ return "unknown";
189
+ }
190
+
155
191
  /**
156
192
  * Build codebase context markdown from scan results.
157
- *
158
- * @param scan - Codebase scan result
159
- * @returns Formatted context string
160
193
  */
161
- function buildCodebaseContext(scan: {
162
- fileTree: string;
163
- dependencies: Record<string, string>;
164
- devDependencies: Record<string, string>;
165
- testPatterns: string[];
166
- }): string {
194
+ function buildCodebaseContext(scan: CodebaseScan): string {
167
195
  const sections: string[] = [];
168
196
 
169
- // File tree
170
197
  sections.push("## Codebase Structure\n");
171
198
  sections.push("```");
172
199
  sections.push(scan.fileTree);
173
200
  sections.push("```\n");
174
201
 
175
- // Dependencies
176
202
  const allDeps = { ...scan.dependencies, ...scan.devDependencies };
177
203
  const depList = Object.entries(allDeps)
178
204
  .map(([name, version]) => `- ${name}@${version}`)
@@ -184,7 +210,6 @@ function buildCodebaseContext(scan: {
184
210
  sections.push("");
185
211
  }
186
212
 
187
- // Test patterns
188
213
  if (scan.testPatterns.length > 0) {
189
214
  sections.push("## Test Setup\n");
190
215
  sections.push(scan.testPatterns.map((p) => `- ${p}`).join("\n"));
@@ -195,28 +220,69 @@ function buildCodebaseContext(scan: {
195
220
  }
196
221
 
197
222
  /**
198
- * Build the full planning prompt with template.
223
+ * Build the full planning prompt sent to the LLM.
199
224
  *
200
- * @param userPrompt - User's task description
201
- * @param template - Spec template
202
- * @returns Full prompt with instructions
225
+ * Includes:
226
+ * - Spec content
227
+ * - Codebase context
228
+ * - Output schema (exact prd.json JSON structure)
229
+ * - Complexity classification guide
230
+ * - Test strategy guide
203
231
  */
204
- function buildPlanPrompt(userPrompt: string, template: string): string {
205
- return `You are helping plan a new feature for this codebase.
232
+ function buildPlanningPrompt(specContent: string, codebaseContext: string): string {
233
+ return `You are a senior software architect generating a product requirements document (PRD) as JSON.
234
+
235
+ ## Spec
236
+
237
+ ${specContent}
238
+
239
+ ## Codebase Context
240
+
241
+ ${codebaseContext}
242
+
243
+ ## Output Schema
244
+
245
+ Generate a JSON object with this exact structure (no markdown, no explanation — JSON only):
246
+
247
+ {
248
+ "project": "string — project name",
249
+ "feature": "string — feature name",
250
+ "branchName": "string — git branch (e.g. feat/my-feature)",
251
+ "createdAt": "ISO 8601 timestamp",
252
+ "updatedAt": "ISO 8601 timestamp",
253
+ "userStories": [
254
+ {
255
+ "id": "string — e.g. US-001",
256
+ "title": "string — concise story title",
257
+ "description": "string — detailed description of the story",
258
+ "acceptanceCriteria": ["string — each AC line"],
259
+ "tags": ["string — routing tags, e.g. feature, security, api"],
260
+ "dependencies": ["string — story IDs this story depends on"],
261
+ "status": "pending",
262
+ "passes": false,
263
+ "routing": {
264
+ "complexity": "simple | medium | complex | expert",
265
+ "testStrategy": "test-after | tdd-lite | three-session-tdd",
266
+ "reasoning": "string — brief classification rationale"
267
+ },
268
+ "escalations": [],
269
+ "attempts": 0
270
+ }
271
+ ]
272
+ }
206
273
 
207
- Task: ${userPrompt}
274
+ ## Complexity Classification Guide
208
275
 
209
- Please gather requirements and produce a structured specification following this template:
276
+ - simple: ≤50 LOC, single-file change, purely additive, no new dependencies → test-after
277
+ - medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-lite
278
+ - complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
279
+ - expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd
210
280
 
211
- ${template}
281
+ ## Test Strategy Guide
212
282
 
213
- Ask clarifying questions as needed to ensure the spec is complete and unambiguous.
214
- Focus on understanding:
215
- - The problem being solved
216
- - Specific requirements and constraints
217
- - Acceptance criteria for success
218
- - Technical approach and architecture
219
- - What is explicitly out of scope
283
+ - test-after: Simple changes with well-understood behavior. Write tests after implementation.
284
+ - tdd-lite: Medium complexity. Write key tests first, implement, then fill coverage.
285
+ - three-session-tdd: Complex/expert. Full TDD cycle with separate sessions for tests and implementation.
220
286
 
221
- When done, output the complete specification in markdown format.`;
287
+ Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
222
288
  }
@@ -66,7 +66,7 @@ export async function precheckCommand(options: PrecheckOptions): Promise<void> {
66
66
  // Validate prd.json exists
67
67
  if (!existsSync(prdPath)) {
68
68
  console.error(chalk.red(`Missing prd.json for feature: ${featureName}`));
69
- console.error(chalk.dim(`Run: nax analyze -f ${featureName}`));
69
+ console.error(chalk.dim(`Run: nax plan -f ${featureName} --from spec.md --auto`));
70
70
  process.exit(EXIT_CODES.INVALID_PRD);
71
71
  }
72
72
 
@@ -10,6 +10,15 @@ import type { Server } from "node:http";
10
10
  import { z } from "zod";
11
11
  import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
12
12
 
13
+ /**
14
+ * Injectable sleep for WebhookInteractionPlugin.receive() polling loop.
15
+ * Replace in tests to avoid real backoff delays.
16
+ * @internal
17
+ */
18
+ export const _webhookPluginDeps = {
19
+ sleep: (ms: number): Promise<void> => Bun.sleep(ms),
20
+ };
21
+
13
22
  /** Webhook plugin configuration */
14
23
  interface WebhookConfig {
15
24
  /** Webhook URL to POST requests to */
@@ -119,7 +128,7 @@ export class WebhookInteractionPlugin implements InteractionPlugin {
119
128
  this.pendingResponses.delete(requestId);
120
129
  return response;
121
130
  }
122
- await Bun.sleep(backoffMs);
131
+ await _webhookPluginDeps.sleep(backoffMs);
123
132
  // Exponential backoff: double interval up to max
124
133
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
125
134
  }