@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/README.md +1 -0
- package/bin/nax.ts +130 -11
- package/dist/nax.js +478 -186
- package/package.json +7 -6
- package/src/agents/acp/adapter.ts +3 -5
- package/src/agents/claude.ts +12 -2
- package/src/analyze/scanner.ts +16 -20
- package/src/cli/plan.ts +211 -145
- package/src/commands/precheck.ts +1 -1
- package/src/interaction/plugins/webhook.ts +10 -1
- package/src/prd/schema.ts +249 -0
- package/src/tdd/session-runner.ts +11 -2
- package/src/utils/git.ts +30 -0
- package/src/verification/runners.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "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 (
|
|
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 =
|
package/src/agents/claude.ts
CHANGED
|
@@ -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
|
|
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
|
|
151
|
+
await _claudeAdapterDeps.sleep(backoffMs);
|
|
142
152
|
continue;
|
|
143
153
|
}
|
|
144
154
|
|
package/src/analyze/scanner.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 —
|
|
2
|
+
* Plan Command — Generate prd.json from a spec file via LLM one-shot call
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
//
|
|
21
|
+
// Dependency injection (_deps) — override in tests
|
|
20
22
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
23
|
|
|
22
|
-
const
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Plan options
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
43
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
69
|
-
* @param options - Command options
|
|
70
|
-
* @returns Path to
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
99
|
-
const
|
|
100
|
-
const
|
|
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
|
-
//
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
223
|
+
* Build the full planning prompt sent to the LLM.
|
|
199
224
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
205
|
-
return `You are
|
|
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
|
-
|
|
274
|
+
## Complexity Classification Guide
|
|
208
275
|
|
|
209
|
-
|
|
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
|
-
|
|
281
|
+
## Test Strategy Guide
|
|
212
282
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
-
|
|
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
|
-
|
|
287
|
+
Output ONLY the JSON object. Do not wrap in markdown code blocks.`;
|
|
222
288
|
}
|
package/src/commands/precheck.ts
CHANGED
|
@@ -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
|
|
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
|
|
131
|
+
await _webhookPluginDeps.sleep(backoffMs);
|
|
123
132
|
// Exponential backoff: double interval up to max
|
|
124
133
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
|
125
134
|
}
|