@nathapp/nax 0.34.0 → 0.35.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/dist/nax.js +4711 -4419
- package/package.json +1 -2
- package/src/agents/adapters/codex.ts +153 -0
- package/src/agents/claude-plan.ts +22 -5
- package/src/agents/claude.ts +102 -11
- package/src/agents/index.ts +2 -1
- package/src/agents/model-resolution.ts +43 -0
- package/src/agents/registry.ts +2 -1
- package/src/agents/types-extended.ts +5 -1
- package/src/agents/types.ts +31 -0
- package/src/analyze/classifier.ts +30 -50
- package/src/cli/analyze-parser.ts +8 -1
- package/src/cli/analyze.ts +1 -1
- package/src/cli/plan.ts +1 -0
- package/src/config/types.ts +3 -1
- package/src/interaction/init.ts +8 -7
- package/src/interaction/plugins/auto.ts +41 -25
- package/src/pipeline/stages/routing.ts +4 -1
- package/src/plugins/index.ts +2 -0
- package/src/plugins/loader.ts +4 -2
- package/src/plugins/plugin-logger.ts +41 -0
- package/src/plugins/types.ts +50 -1
- package/src/precheck/checks-blockers.ts +37 -1
- package/src/precheck/checks.ts +1 -0
- package/src/precheck/index.ts +2 -2
- package/src/routing/router.ts +1 -0
- package/src/routing/strategies/llm.ts +53 -36
- package/src/routing/strategy.ts +3 -0
- package/src/tdd/rectification-gate.ts +68 -0
- package/src/tdd/session-runner.ts +16 -0
- package/src/tdd/verdict.ts +1 -0
- package/src/verification/rectification-loop.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"prepublishOnly": "bun run build"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@anthropic-ai/sdk": "^0.74.0",
|
|
24
23
|
"@types/react": "^19.2.14",
|
|
25
24
|
"chalk": "^5.6.2",
|
|
26
25
|
"commander": "^13.1.0",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex Agent Adapter — implements AgentAdapter interface
|
|
3
|
+
*
|
|
4
|
+
* Provides uniform interface for spawning Codex agent processes,
|
|
5
|
+
* supporting one-shot completions and headless execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AgentAdapter,
|
|
10
|
+
AgentCapabilities,
|
|
11
|
+
AgentResult,
|
|
12
|
+
AgentRunOptions,
|
|
13
|
+
CompleteOptions,
|
|
14
|
+
DecomposeOptions,
|
|
15
|
+
DecomposeResult,
|
|
16
|
+
PlanOptions,
|
|
17
|
+
PlanResult,
|
|
18
|
+
} from "../types";
|
|
19
|
+
import { CompleteError } from "../types";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Injectable dependencies — matches the _deps pattern used in claude.ts
|
|
23
|
+
// These are replaced in unit tests to intercept Bun.spawn calls.
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export const _codexRunDeps = {
|
|
27
|
+
which(name: string): string | null {
|
|
28
|
+
return Bun.which(name);
|
|
29
|
+
},
|
|
30
|
+
spawn(
|
|
31
|
+
cmd: string[],
|
|
32
|
+
opts: { cwd?: string; stdout: "pipe"; stderr: "pipe" | "inherit"; env?: Record<string, string | undefined> },
|
|
33
|
+
): {
|
|
34
|
+
stdout: ReadableStream<Uint8Array>;
|
|
35
|
+
stderr: ReadableStream<Uint8Array>;
|
|
36
|
+
exited: Promise<number>;
|
|
37
|
+
pid: number;
|
|
38
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
39
|
+
} {
|
|
40
|
+
return Bun.spawn(cmd, opts) as unknown as {
|
|
41
|
+
stdout: ReadableStream<Uint8Array>;
|
|
42
|
+
stderr: ReadableStream<Uint8Array>;
|
|
43
|
+
exited: Promise<number>;
|
|
44
|
+
pid: number;
|
|
45
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const _codexCompleteDeps = {
|
|
51
|
+
spawn(
|
|
52
|
+
cmd: string[],
|
|
53
|
+
opts: { stdout: "pipe"; stderr: "pipe" | "inherit" },
|
|
54
|
+
): {
|
|
55
|
+
stdout: ReadableStream<Uint8Array>;
|
|
56
|
+
stderr: ReadableStream<Uint8Array>;
|
|
57
|
+
exited: Promise<number>;
|
|
58
|
+
pid: number;
|
|
59
|
+
} {
|
|
60
|
+
return Bun.spawn(cmd, opts) as unknown as {
|
|
61
|
+
stdout: ReadableStream<Uint8Array>;
|
|
62
|
+
stderr: ReadableStream<Uint8Array>;
|
|
63
|
+
exited: Promise<number>;
|
|
64
|
+
pid: number;
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// CodexAdapter implementation
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Maximum characters to capture from agent stdout.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_AGENT_OUTPUT_CHARS = 5000;
|
|
77
|
+
|
|
78
|
+
export class CodexAdapter implements AgentAdapter {
|
|
79
|
+
readonly name = "codex";
|
|
80
|
+
readonly displayName = "Codex";
|
|
81
|
+
readonly binary = "codex";
|
|
82
|
+
|
|
83
|
+
readonly capabilities: AgentCapabilities = {
|
|
84
|
+
supportedTiers: ["fast", "balanced"],
|
|
85
|
+
maxContextTokens: 8_000,
|
|
86
|
+
features: new Set<"tdd" | "review" | "refactor" | "batch">(["tdd", "refactor"]),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async isInstalled(): Promise<boolean> {
|
|
90
|
+
const path = _codexRunDeps.which("codex");
|
|
91
|
+
return path !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
buildCommand(options: AgentRunOptions): string[] {
|
|
95
|
+
return ["codex", "-q", "--prompt", options.prompt];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async run(options: AgentRunOptions): Promise<AgentResult> {
|
|
99
|
+
const cmd = this.buildCommand(options);
|
|
100
|
+
const startTime = Date.now();
|
|
101
|
+
|
|
102
|
+
const proc = _codexRunDeps.spawn(cmd, {
|
|
103
|
+
cwd: options.workdir,
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "inherit",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const exitCode = await proc.exited;
|
|
109
|
+
const stdout = await new Response(proc.stdout).text();
|
|
110
|
+
const durationMs = Date.now() - startTime;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
success: exitCode === 0,
|
|
114
|
+
exitCode,
|
|
115
|
+
output: stdout.slice(-MAX_AGENT_OUTPUT_CHARS),
|
|
116
|
+
rateLimited: false,
|
|
117
|
+
durationMs,
|
|
118
|
+
estimatedCost: 0,
|
|
119
|
+
pid: proc.pid,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async complete(prompt: string, _options?: CompleteOptions): Promise<string> {
|
|
124
|
+
const cmd = ["codex", "-q", "--prompt", prompt];
|
|
125
|
+
|
|
126
|
+
const proc = _codexCompleteDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
127
|
+
const exitCode = await proc.exited;
|
|
128
|
+
|
|
129
|
+
const stdout = await new Response(proc.stdout).text();
|
|
130
|
+
const stderr = await new Response(proc.stderr).text();
|
|
131
|
+
const trimmed = stdout.trim();
|
|
132
|
+
|
|
133
|
+
if (exitCode !== 0) {
|
|
134
|
+
const errorDetails = stderr.trim() || trimmed;
|
|
135
|
+
const errorMessage = errorDetails || `complete() failed with exit code ${exitCode}`;
|
|
136
|
+
throw new CompleteError(errorMessage, exitCode);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!trimmed) {
|
|
140
|
+
throw new CompleteError("complete() returned empty output");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return trimmed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async plan(_options: PlanOptions): Promise<PlanResult> {
|
|
147
|
+
throw new Error("CodexAdapter.plan() not implemented");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async decompose(_options: DecomposeOptions): Promise<DecomposeResult> {
|
|
151
|
+
throw new Error("CodexAdapter.decompose() not implemented");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -9,6 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import type { PidRegistry } from "../execution/pid-registry";
|
|
11
11
|
import { getLogger } from "../logger";
|
|
12
|
+
import { resolveBalancedModelDef } from "./model-resolution";
|
|
12
13
|
import type { AgentRunOptions } from "./types";
|
|
13
14
|
import type { PlanOptions, PlanResult } from "./types-extended";
|
|
14
15
|
|
|
@@ -18,9 +19,14 @@ import type { PlanOptions, PlanResult } from "./types-extended";
|
|
|
18
19
|
export function buildPlanCommand(binary: string, options: PlanOptions): string[] {
|
|
19
20
|
const cmd = [binary, "--permission-mode", "plan"];
|
|
20
21
|
|
|
21
|
-
// Add model if specified
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Add model if specified (explicit or resolved from config)
|
|
23
|
+
let modelDef = options.modelDef;
|
|
24
|
+
if (!modelDef && options.config) {
|
|
25
|
+
modelDef = resolveBalancedModelDef(options.config);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (modelDef) {
|
|
29
|
+
cmd.push("--model", modelDef.model);
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
// Add dangerously-skip-permissions for automation
|
|
@@ -64,13 +70,24 @@ export async function runPlan(
|
|
|
64
70
|
pidRegistry: PidRegistry,
|
|
65
71
|
buildAllowedEnv: (options: AgentRunOptions) => Record<string, string | undefined>,
|
|
66
72
|
): Promise<PlanResult> {
|
|
73
|
+
const { resolveBalancedModelDef } = await import("./model-resolution");
|
|
74
|
+
|
|
67
75
|
const cmd = buildPlanCommand(binary, options);
|
|
68
76
|
|
|
77
|
+
// Resolve model: explicit modelDef > config.models.balanced > throw
|
|
78
|
+
let modelDef = options.modelDef;
|
|
79
|
+
if (!modelDef) {
|
|
80
|
+
if (!options.config) {
|
|
81
|
+
throw new Error("runPlan() requires either modelDef or config with models.balanced configured");
|
|
82
|
+
}
|
|
83
|
+
modelDef = resolveBalancedModelDef(options.config);
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
const envOptions: AgentRunOptions = {
|
|
70
87
|
workdir: options.workdir,
|
|
71
|
-
modelDef
|
|
88
|
+
modelDef,
|
|
72
89
|
prompt: "",
|
|
73
|
-
modelTier: "balanced",
|
|
90
|
+
modelTier: options.modelTier || "balanced",
|
|
74
91
|
timeoutSeconds: 600,
|
|
75
92
|
};
|
|
76
93
|
|
package/src/agents/claude.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
AgentCapabilities,
|
|
13
13
|
AgentResult,
|
|
14
14
|
AgentRunOptions,
|
|
15
|
+
CompleteOptions,
|
|
15
16
|
DecomposeOptions,
|
|
16
17
|
DecomposeResult,
|
|
17
18
|
InteractiveRunOptions,
|
|
@@ -19,6 +20,7 @@ import type {
|
|
|
19
20
|
PlanResult,
|
|
20
21
|
PtyHandle,
|
|
21
22
|
} from "./types";
|
|
23
|
+
import { CompleteError } from "./types";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Maximum characters to capture from agent stdout.
|
|
@@ -42,6 +44,53 @@ const MAX_AGENT_STDERR_CHARS = 1000;
|
|
|
42
44
|
*/
|
|
43
45
|
const SIGKILL_GRACE_PERIOD_MS = 5000;
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Injectable dependencies for complete() — allows tests to intercept
|
|
49
|
+
* Bun.spawn calls and verify correct CLI args without the claude binary.
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export const _completeDeps = {
|
|
54
|
+
spawn(
|
|
55
|
+
cmd: string[],
|
|
56
|
+
opts: { stdout: "pipe"; stderr: "pipe" | "inherit" },
|
|
57
|
+
): { stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; exited: Promise<number>; pid: number } {
|
|
58
|
+
return Bun.spawn(cmd, opts) as unknown as {
|
|
59
|
+
stdout: ReadableStream<Uint8Array>;
|
|
60
|
+
stderr: ReadableStream<Uint8Array>;
|
|
61
|
+
exited: Promise<number>;
|
|
62
|
+
pid: number;
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Injectable dependencies for decompose() — allows tests to intercept
|
|
69
|
+
* Bun.spawn calls and verify correct CLI args without the claude binary.
|
|
70
|
+
*
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export const _decomposeDeps = {
|
|
74
|
+
spawn(
|
|
75
|
+
cmd: string[],
|
|
76
|
+
opts: { cwd?: string; stdout: "pipe"; stderr: "inherit" | "pipe"; env?: Record<string, string | undefined> },
|
|
77
|
+
): {
|
|
78
|
+
stdout: ReadableStream<Uint8Array>;
|
|
79
|
+
stderr?: ReadableStream<Uint8Array>;
|
|
80
|
+
exited: Promise<number>;
|
|
81
|
+
pid: number;
|
|
82
|
+
kill(signal?: NodeJS.Signals | number): void;
|
|
83
|
+
} {
|
|
84
|
+
return Bun.spawn(cmd, opts) as unknown as {
|
|
85
|
+
stdout: ReadableStream<Uint8Array>;
|
|
86
|
+
stderr?: ReadableStream<Uint8Array>;
|
|
87
|
+
exited: Promise<number>;
|
|
88
|
+
pid: number;
|
|
89
|
+
kill(signal?: NodeJS.Signals | number): void;
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
45
94
|
/**
|
|
46
95
|
* Injectable dependencies for runOnce() — allows tests to verify
|
|
47
96
|
* that PID cleanup (unregister) always runs even if kill() throws.
|
|
@@ -295,34 +344,76 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
295
344
|
};
|
|
296
345
|
}
|
|
297
346
|
|
|
347
|
+
async complete(prompt: string, options?: CompleteOptions): Promise<string> {
|
|
348
|
+
// Build command: claude -p <prompt> [--model <model>] [--max-tokens <tokens>] [--output-format json]
|
|
349
|
+
const cmd = ["claude", "-p", prompt];
|
|
350
|
+
|
|
351
|
+
if (options?.model) {
|
|
352
|
+
cmd.push("--model", options.model);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (options?.maxTokens !== undefined) {
|
|
356
|
+
cmd.push("--max-tokens", String(options.maxTokens));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (options?.jsonMode) {
|
|
360
|
+
cmd.push("--output-format", "json");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const proc = _completeDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
364
|
+
const exitCode = await proc.exited;
|
|
365
|
+
|
|
366
|
+
// Read stdout and stderr for error messages
|
|
367
|
+
const stdout = await new Response(proc.stdout).text();
|
|
368
|
+
const stderr = await new Response(proc.stderr).text();
|
|
369
|
+
const trimmed = stdout.trim();
|
|
370
|
+
|
|
371
|
+
// Validate exit code and output
|
|
372
|
+
if (exitCode !== 0) {
|
|
373
|
+
const errorDetails = stderr.trim() || trimmed;
|
|
374
|
+
const errorMessage = errorDetails || `complete() failed with exit code ${exitCode}`;
|
|
375
|
+
throw new CompleteError(errorMessage, exitCode);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!trimmed) {
|
|
379
|
+
throw new CompleteError("complete() returned empty output");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return trimmed;
|
|
383
|
+
}
|
|
384
|
+
|
|
298
385
|
async plan(options: PlanOptions): Promise<PlanResult> {
|
|
299
386
|
const pidRegistry = this.getPidRegistry(options.workdir);
|
|
300
387
|
return runPlan(this.binary, options, pidRegistry, this.buildAllowedEnv.bind(this));
|
|
301
388
|
}
|
|
302
389
|
|
|
303
390
|
async decompose(options: DecomposeOptions): Promise<DecomposeResult> {
|
|
391
|
+
const { resolveBalancedModelDef } = await import("./model-resolution");
|
|
392
|
+
|
|
304
393
|
const prompt = buildDecomposePrompt(options);
|
|
305
394
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
options.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
395
|
+
// Resolve model: explicit modelDef > config.models.balanced > throw
|
|
396
|
+
let modelDef = options.modelDef;
|
|
397
|
+
if (!modelDef) {
|
|
398
|
+
if (!options.config) {
|
|
399
|
+
throw new Error("decompose() requires either modelDef or config with models.balanced configured");
|
|
400
|
+
}
|
|
401
|
+
modelDef = resolveBalancedModelDef(options.config);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const cmd = [this.binary, "--model", modelDef.model, "--dangerously-skip-permissions", "-p", prompt];
|
|
314
405
|
|
|
315
406
|
const pidRegistry = this.getPidRegistry(options.workdir);
|
|
316
407
|
|
|
317
|
-
const proc =
|
|
408
|
+
const proc = _decomposeDeps.spawn(cmd, {
|
|
318
409
|
cwd: options.workdir,
|
|
319
410
|
stdout: "pipe",
|
|
320
411
|
stderr: "inherit", // MEM-3: Inherit stderr to avoid blocking on unread pipe
|
|
321
412
|
env: this.buildAllowedEnv({
|
|
322
413
|
workdir: options.workdir,
|
|
323
|
-
modelDef
|
|
414
|
+
modelDef,
|
|
324
415
|
prompt: "",
|
|
325
|
-
modelTier: "balanced",
|
|
416
|
+
modelTier: options.modelTier || "balanced",
|
|
326
417
|
timeoutSeconds: 600,
|
|
327
418
|
}),
|
|
328
419
|
});
|
package/src/agents/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type { AgentAdapter, AgentCapabilities, AgentResult, AgentRunOptions } from "./types";
|
|
1
|
+
export type { AgentAdapter, AgentCapabilities, AgentResult, AgentRunOptions, CompleteOptions } from "./types";
|
|
2
|
+
export { CompleteError } from "./types";
|
|
2
3
|
export { ClaudeCodeAdapter } from "./claude";
|
|
3
4
|
export { getAllAgentNames, getAgent, getInstalledAgents, checkAgentHealth } from "./registry";
|
|
4
5
|
export type { ModelCostRates, TokenUsage, CostEstimate, TokenUsageWithConfidence } from "./cost";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution utility — AA-006
|
|
3
|
+
*
|
|
4
|
+
* Resolves a ModelDef from config.models.balanced with fallback chain:
|
|
5
|
+
* config value -> adapter default -> throw if none configured
|
|
6
|
+
*
|
|
7
|
+
* Implementation placeholder — logic to be filled in by the implementer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveModel } from "../config/schema";
|
|
11
|
+
import type { ModelDef, NaxConfig } from "../config/schema";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the balanced model definition from config, with optional adapter default fallback.
|
|
15
|
+
*
|
|
16
|
+
* Fallback chain:
|
|
17
|
+
* 1. config.models.balanced (object or string shorthand)
|
|
18
|
+
* 2. adapterDefault (if provided)
|
|
19
|
+
* 3. Throws if neither is configured
|
|
20
|
+
*
|
|
21
|
+
* @param config - Partial NaxConfig (models.balanced is read if present)
|
|
22
|
+
* @param adapterDefault - Optional adapter-level fallback ModelDef
|
|
23
|
+
* @returns Resolved ModelDef
|
|
24
|
+
* @throws Error if no balanced model is configured and no adapter default provided
|
|
25
|
+
*/
|
|
26
|
+
export function resolveBalancedModelDef(
|
|
27
|
+
config: Pick<NaxConfig, "models"> | Partial<NaxConfig>,
|
|
28
|
+
adapterDefault?: ModelDef,
|
|
29
|
+
): ModelDef {
|
|
30
|
+
const configWithModels = config as Pick<NaxConfig, "models">;
|
|
31
|
+
const models = configWithModels.models as Record<string, unknown> | undefined;
|
|
32
|
+
const balancedEntry = models?.balanced;
|
|
33
|
+
|
|
34
|
+
if (balancedEntry) {
|
|
35
|
+
return resolveModel(balancedEntry as string | ModelDef);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (adapterDefault) {
|
|
39
|
+
return adapterDefault;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error("No balanced model configured in config.models.balanced and no adapter default provided");
|
|
43
|
+
}
|
package/src/agents/registry.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Discovers and manages available coding agents.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { CodexAdapter } from "./adapters/codex";
|
|
7
8
|
import { ClaudeCodeAdapter } from "./claude";
|
|
8
9
|
import type { AgentAdapter } from "./types";
|
|
9
10
|
|
|
10
11
|
/** All known agent adapters */
|
|
11
12
|
export const ALL_AGENTS: AgentAdapter[] = [
|
|
12
13
|
new ClaudeCodeAdapter(),
|
|
13
|
-
|
|
14
|
+
new CodexAdapter(),
|
|
14
15
|
// Future: new OpenCodeAdapter(),
|
|
15
16
|
// Future: new GeminiAdapter(),
|
|
16
17
|
];
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Separated from core types to keep each file under 400 lines.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ModelDef, ModelTier } from "../config/schema";
|
|
8
|
+
import type { ModelDef, ModelTier, NaxConfig } from "../config/schema";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Configuration options for running an agent in plan mode.
|
|
@@ -28,6 +28,8 @@ export interface PlanOptions {
|
|
|
28
28
|
modelTier?: ModelTier;
|
|
29
29
|
/** Resolved model definition */
|
|
30
30
|
modelDef?: ModelDef;
|
|
31
|
+
/** Global config — used to resolve models.balanced when modelDef is absent */
|
|
32
|
+
config?: Partial<NaxConfig>;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -59,6 +61,8 @@ export interface DecomposeOptions {
|
|
|
59
61
|
modelTier?: ModelTier;
|
|
60
62
|
/** Resolved model definition */
|
|
61
63
|
modelDef?: ModelDef;
|
|
64
|
+
/** Global config — used to resolve models.balanced when modelDef is absent */
|
|
65
|
+
config?: Partial<NaxConfig>;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/** A single classified user story from decompose result. */
|
package/src/agents/types.ts
CHANGED
|
@@ -73,6 +73,31 @@ export interface AgentCapabilities {
|
|
|
73
73
|
readonly features: ReadonlySet<"tdd" | "review" | "refactor" | "batch">;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Options for one-shot LLM completion calls.
|
|
78
|
+
*/
|
|
79
|
+
export interface CompleteOptions {
|
|
80
|
+
/** Maximum tokens for the response */
|
|
81
|
+
maxTokens?: number;
|
|
82
|
+
/** Request JSON-formatted output (adds --output-format json) */
|
|
83
|
+
jsonMode?: boolean;
|
|
84
|
+
/** Override the model (adds --model flag) */
|
|
85
|
+
model?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Typed error thrown when complete() fails due to non-zero exit or empty output.
|
|
90
|
+
*/
|
|
91
|
+
export class CompleteError extends Error {
|
|
92
|
+
constructor(
|
|
93
|
+
message: string,
|
|
94
|
+
public readonly exitCode?: number,
|
|
95
|
+
) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "CompleteError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
/**
|
|
77
102
|
* Agent adapter interface — one implementation per supported coding agent.
|
|
78
103
|
*
|
|
@@ -104,6 +129,12 @@ export interface AgentAdapter {
|
|
|
104
129
|
/** Run the agent in decompose mode to break spec into classified stories. */
|
|
105
130
|
decompose(options: import("./types-extended").DecomposeOptions): Promise<import("./types-extended").DecomposeResult>;
|
|
106
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Run a one-shot LLM call and return the plain text response.
|
|
134
|
+
* Uses claude -p CLI for non-interactive completions.
|
|
135
|
+
*/
|
|
136
|
+
complete(prompt: string, options?: CompleteOptions): Promise<string>;
|
|
137
|
+
|
|
107
138
|
/**
|
|
108
139
|
* Run the agent in interactive PTY mode for TUI embedding.
|
|
109
140
|
* This method is optional — only implemented by agents that support
|
|
@@ -5,13 +5,25 @@
|
|
|
5
5
|
* Falls back to keyword matching if LLM call fails.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import type { AgentAdapter } from "../agents";
|
|
9
|
+
import { ClaudeCodeAdapter } from "../agents/claude";
|
|
9
10
|
import type { NaxConfig } from "../config";
|
|
11
|
+
import { resolveModel } from "../config/schema";
|
|
10
12
|
import { getLogger } from "../logger";
|
|
11
13
|
import type { UserStory } from "../prd";
|
|
12
14
|
import { classifyComplexity } from "../routing";
|
|
13
15
|
import type { ClassificationResult, CodebaseScan, StoryClassification } from "./types";
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Injectable dependencies for classifier — allows tests to mock adapter.complete()
|
|
19
|
+
* without needing the claude binary.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export const _classifyDeps = {
|
|
24
|
+
adapter: new ClaudeCodeAdapter() as AgentAdapter,
|
|
25
|
+
};
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
28
|
* Raw LLM classification item (before validation)
|
|
17
29
|
*/
|
|
@@ -92,37 +104,29 @@ async function classifyWithLLM(
|
|
|
92
104
|
scan: CodebaseScan,
|
|
93
105
|
config: NaxConfig,
|
|
94
106
|
): Promise<StoryClassification[]> {
|
|
95
|
-
// Check
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error("ANTHROPIC_API_KEY not set");
|
|
107
|
+
// Check for required environment variables
|
|
108
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
109
|
+
throw new Error("ANTHROPIC_API_KEY environment variable not configured — cannot use LLM classification");
|
|
99
110
|
}
|
|
100
111
|
|
|
101
|
-
const client = new Anthropic({ apiKey });
|
|
102
|
-
|
|
103
112
|
// Build prompt
|
|
104
|
-
const prompt = buildClassificationPrompt(stories, scan
|
|
113
|
+
const prompt = buildClassificationPrompt(stories, scan);
|
|
105
114
|
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
messages: [
|
|
111
|
-
{
|
|
112
|
-
role: "user",
|
|
113
|
-
content: prompt,
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Extract text from response
|
|
119
|
-
const textContent = response.content.find((c) => c.type === "text");
|
|
120
|
-
if (!textContent || textContent.type !== "text") {
|
|
121
|
-
throw new Error("No text response from LLM");
|
|
115
|
+
// Resolve model from config.models.fast
|
|
116
|
+
const fastModelEntry = config.models.fast;
|
|
117
|
+
if (!fastModelEntry) {
|
|
118
|
+
throw new Error("config.models.fast not configured");
|
|
122
119
|
}
|
|
120
|
+
const modelDef = resolveModel(fastModelEntry);
|
|
121
|
+
|
|
122
|
+
// Make API call via adapter (use haiku for cheap classification)
|
|
123
|
+
const jsonText = await _classifyDeps.adapter.complete(prompt, {
|
|
124
|
+
jsonMode: true,
|
|
125
|
+
maxTokens: 4096,
|
|
126
|
+
model: modelDef.model,
|
|
127
|
+
});
|
|
123
128
|
|
|
124
129
|
// Parse JSON response
|
|
125
|
-
const jsonText = extractJSON(textContent.text);
|
|
126
130
|
const parsed: unknown = JSON.parse(jsonText);
|
|
127
131
|
|
|
128
132
|
// Validate structure
|
|
@@ -159,10 +163,9 @@ async function classifyWithLLM(
|
|
|
159
163
|
*
|
|
160
164
|
* @param stories - User stories to classify
|
|
161
165
|
* @param scan - Codebase scan result
|
|
162
|
-
* @param config - Ngent configuration
|
|
163
166
|
* @returns Formatted prompt string
|
|
164
167
|
*/
|
|
165
|
-
function buildClassificationPrompt(stories: UserStory[], scan: CodebaseScan
|
|
168
|
+
function buildClassificationPrompt(stories: UserStory[], scan: CodebaseScan): string {
|
|
166
169
|
// Format codebase summary
|
|
167
170
|
const codebaseSummary = `
|
|
168
171
|
FILE TREE:
|
|
@@ -229,29 +232,6 @@ Consider:
|
|
|
229
232
|
Respond with ONLY the JSON array.`;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
/**
|
|
233
|
-
* Extract JSON from LLM response (handles markdown code fences).
|
|
234
|
-
*
|
|
235
|
-
* @param text - LLM response text
|
|
236
|
-
* @returns JSON string
|
|
237
|
-
*/
|
|
238
|
-
function extractJSON(text: string): string {
|
|
239
|
-
// Remove markdown code fences if present
|
|
240
|
-
const jsonMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
|
|
241
|
-
if (jsonMatch) {
|
|
242
|
-
return jsonMatch[1];
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Try to find JSON array directly
|
|
246
|
-
const arrayMatch = text.match(/\[[\s\S]*\]/);
|
|
247
|
-
if (arrayMatch) {
|
|
248
|
-
return arrayMatch[0];
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Return as-is if no special formatting detected
|
|
252
|
-
return text.trim();
|
|
253
|
-
}
|
|
254
|
-
|
|
255
235
|
/**
|
|
256
236
|
* Validate complexity value from LLM response.
|
|
257
237
|
*
|
|
@@ -233,7 +233,14 @@ async function reclassifyWithLLM(
|
|
|
233
233
|
const modelTier = config.analyze.model;
|
|
234
234
|
const modelDef = resolveModel(config.models[modelTier]);
|
|
235
235
|
|
|
236
|
-
const result = await adapter.decompose({
|
|
236
|
+
const result = await adapter.decompose({
|
|
237
|
+
specContent: storySpec,
|
|
238
|
+
workdir,
|
|
239
|
+
codebaseContext,
|
|
240
|
+
modelTier,
|
|
241
|
+
modelDef,
|
|
242
|
+
config,
|
|
243
|
+
});
|
|
237
244
|
|
|
238
245
|
if (result.stories.length === 0) return story;
|
|
239
246
|
const ds = result.stories[0];
|
package/src/cli/analyze.ts
CHANGED
|
@@ -115,7 +115,7 @@ async function decomposeLLM(
|
|
|
115
115
|
|
|
116
116
|
const modelTier = config.analyze.model;
|
|
117
117
|
const modelDef = resolveModel(config.models[modelTier]);
|
|
118
|
-
const result = await adapter.decompose({ specContent, workdir, codebaseContext, modelTier, modelDef });
|
|
118
|
+
const result = await adapter.decompose({ specContent, workdir, codebaseContext, modelTier, modelDef, config });
|
|
119
119
|
|
|
120
120
|
logger.info("cli", "[OK] Agent decompose complete", { storiesCount: result.stories.length });
|
|
121
121
|
|