@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/src/config/types.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export type Complexity = "simple" | "medium" | "complex" | "expert";
|
|
9
9
|
export type TestStrategy = "test-after" | "tdd-simple" | "three-session-tdd" | "three-session-tdd-lite";
|
|
10
|
-
export type TddStrategy = "auto" | "strict" | "lite" | "off";
|
|
10
|
+
export type TddStrategy = "auto" | "strict" | "lite" | "simple" | "off";
|
|
11
11
|
|
|
12
12
|
export interface EscalationEntry {
|
|
13
13
|
from: string;
|
|
@@ -125,6 +125,8 @@ export interface ExecutionConfig {
|
|
|
125
125
|
/** Enable smart test runner to scope test runs to changed files (default: true).
|
|
126
126
|
* Accepts boolean for backward compat or a SmartTestRunnerConfig object. */
|
|
127
127
|
smartTestRunner?: boolean | SmartTestRunnerConfig;
|
|
128
|
+
/** Configured agent binary: claude, codex, opencode, gemini, aider (default: claude) */
|
|
129
|
+
agent?: string;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
/** Quality gate config */
|
package/src/interaction/init.ts
CHANGED
|
@@ -41,18 +41,20 @@ function createInteractionPlugin(pluginName: string): InteractionPlugin {
|
|
|
41
41
|
export async function initInteractionChain(config: NaxConfig, headless: boolean): Promise<InteractionChain | null> {
|
|
42
42
|
const logger = getSafeLogger();
|
|
43
43
|
|
|
44
|
-
// If headless mode, skip interaction system
|
|
45
|
-
if (headless) {
|
|
46
|
-
logger?.debug("interaction", "Headless mode - skipping interaction system");
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
44
|
// If no interaction config, skip
|
|
51
45
|
if (!config.interaction) {
|
|
52
46
|
logger?.debug("interaction", "No interaction config - skipping interaction system");
|
|
53
47
|
return null;
|
|
54
48
|
}
|
|
55
49
|
|
|
50
|
+
// In headless mode, skip CLI plugin only — it requires stdin (TTY).
|
|
51
|
+
// Telegram and Webhook plugins work via HTTP and don't need a TTY.
|
|
52
|
+
const pluginName = config.interaction.plugin;
|
|
53
|
+
if (headless && pluginName === "cli") {
|
|
54
|
+
logger?.debug("interaction", "Headless mode with CLI plugin - skipping interaction system (stdin unavailable)");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
56
58
|
// Create chain
|
|
57
59
|
const chain = new InteractionChain({
|
|
58
60
|
defaultTimeout: config.interaction.defaults.timeout,
|
|
@@ -60,7 +62,6 @@ export async function initInteractionChain(config: NaxConfig, headless: boolean)
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
// Create and register plugin
|
|
63
|
-
const pluginName = config.interaction.plugin;
|
|
64
65
|
try {
|
|
65
66
|
const plugin = createInteractionPlugin(pluginName);
|
|
66
67
|
chain.register(plugin, 100);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import type { AgentAdapter } from "../../agents/types";
|
|
9
10
|
import type { NaxConfig } from "../../config";
|
|
10
11
|
import { resolveModel } from "../../config";
|
|
11
12
|
import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../types";
|
|
@@ -40,9 +41,12 @@ interface DecisionResponse {
|
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Module-level deps for testability (_deps pattern).
|
|
43
|
-
* Override
|
|
44
|
+
* Override adapter in tests to mock adapter.complete() without spawning the claude CLI.
|
|
45
|
+
*
|
|
46
|
+
* For backward compatibility, also supports _deps.callLlm (deprecated).
|
|
44
47
|
*/
|
|
45
48
|
export const _deps = {
|
|
49
|
+
adapter: null as AgentAdapter | null,
|
|
46
50
|
callLlm: null as ((request: InteractionRequest) => Promise<DecisionResponse>) | null,
|
|
47
51
|
};
|
|
48
52
|
|
|
@@ -71,7 +75,7 @@ export class AutoInteractionPlugin implements InteractionPlugin {
|
|
|
71
75
|
// No-op — in-process plugin
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
async receive(
|
|
78
|
+
async receive(_requestId: string, _timeout = 60000): Promise<InteractionResponse> {
|
|
75
79
|
// For auto plugin, we need to fetch the request from somewhere
|
|
76
80
|
// In practice, the chain should pass the request to us
|
|
77
81
|
// For now, throw an error since we need the full request
|
|
@@ -88,8 +92,23 @@ export class AutoInteractionPlugin implements InteractionPlugin {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
// Use deprecated callLlm if provided (backward compatibility)
|
|
96
|
+
if (_deps.callLlm) {
|
|
97
|
+
const decision = await _deps.callLlm(request);
|
|
98
|
+
if (decision.confidence < (this.config.confidenceThreshold ?? 0.7)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
requestId: request.id,
|
|
103
|
+
action: decision.action,
|
|
104
|
+
value: decision.value,
|
|
105
|
+
respondedBy: "auto-ai",
|
|
106
|
+
respondedAt: Date.now(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Use new adapter-based path
|
|
111
|
+
const decision = await this.callLlm(request);
|
|
93
112
|
|
|
94
113
|
// Check confidence threshold
|
|
95
114
|
if (decision.confidence < (this.config.confidenceThreshold ?? 0.7)) {
|
|
@@ -114,34 +133,31 @@ export class AutoInteractionPlugin implements InteractionPlugin {
|
|
|
114
133
|
*/
|
|
115
134
|
private async callLlm(request: InteractionRequest): Promise<DecisionResponse> {
|
|
116
135
|
const prompt = this.buildPrompt(request);
|
|
117
|
-
const modelTier = this.config.model ?? "fast";
|
|
118
136
|
|
|
119
|
-
|
|
120
|
-
|
|
137
|
+
// Get adapter from dependency injection or throw
|
|
138
|
+
const adapter = _deps.adapter;
|
|
139
|
+
if (!adapter) {
|
|
140
|
+
throw new Error("Auto plugin requires adapter to be injected via _deps.adapter");
|
|
121
141
|
}
|
|
122
142
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
143
|
+
// Resolve model option if naxConfig is available
|
|
144
|
+
let modelArg: string | undefined;
|
|
145
|
+
if (this.config.naxConfig) {
|
|
146
|
+
const modelTier = this.config.model ?? "fast";
|
|
147
|
+
const modelEntry = this.config.naxConfig.models[modelTier];
|
|
148
|
+
if (!modelEntry) {
|
|
149
|
+
throw new Error(`Model tier "${modelTier}" not found in config.models`);
|
|
150
|
+
}
|
|
151
|
+
const modelDef = resolveModel(modelEntry);
|
|
152
|
+
modelArg = modelDef.model;
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const proc = Bun.spawn(["claude", "-p", prompt, "--model", modelArg], {
|
|
133
|
-
stdout: "pipe",
|
|
134
|
-
stderr: "pipe",
|
|
155
|
+
// Use adapter.complete() for one-shot LLM call
|
|
156
|
+
const output = await adapter.complete(prompt, {
|
|
157
|
+
...(modelArg && { model: modelArg }),
|
|
158
|
+
jsonMode: true,
|
|
135
159
|
});
|
|
136
160
|
|
|
137
|
-
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
138
|
-
|
|
139
|
-
const exitCode = await proc.exited;
|
|
140
|
-
if (exitCode !== 0) {
|
|
141
|
-
throw new Error(`claude CLI failed with exit code ${exitCode}: ${stderr}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const output = stdout.trim();
|
|
145
161
|
return this.parseResponse(output);
|
|
146
162
|
}
|
|
147
163
|
|
|
@@ -90,7 +90,10 @@ export const routingStage: PipelineStage = {
|
|
|
90
90
|
routing = await _routingDeps.routeStory(ctx.story, { config: ctx.config }, ctx.workdir, ctx.plugins);
|
|
91
91
|
// Override with cached values only when they are actually set
|
|
92
92
|
if (ctx.story.routing?.complexity) routing.complexity = ctx.story.routing.complexity;
|
|
93
|
-
|
|
93
|
+
// BUG-062: Only honor stored testStrategy for legacy/manual routing (no contentHash).
|
|
94
|
+
// When contentHash exists, the LLM strategy layer already recomputes testStrategy
|
|
95
|
+
// fresh via determineTestStrategy() — don't clobber it with the stale PRD value.
|
|
96
|
+
if (!hasContentHash && ctx.story.routing?.testStrategy) routing.testStrategy = ctx.story.routing.testStrategy;
|
|
94
97
|
// BUG-032: Use escalated modelTier if explicitly set (by handleTierEscalation),
|
|
95
98
|
// otherwise derive from complexity + current config
|
|
96
99
|
if (ctx.story.routing?.modelTier) {
|
package/src/plugins/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
PluginType,
|
|
10
10
|
PluginExtensions,
|
|
11
11
|
PluginConfigEntry,
|
|
12
|
+
PluginLogger,
|
|
12
13
|
IReviewPlugin,
|
|
13
14
|
ReviewCheckResult,
|
|
14
15
|
IContextProvider,
|
|
@@ -29,3 +30,4 @@ export type {
|
|
|
29
30
|
export { validatePlugin } from "./validator";
|
|
30
31
|
export { loadPlugins } from "./loader";
|
|
31
32
|
export { PluginRegistry } from "./registry";
|
|
33
|
+
export { createPluginLogger } from "./plugin-logger";
|
package/src/plugins/loader.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as fs from "node:fs/promises";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { getSafeLogger as _getSafeLoggerFromModule } from "../logger";
|
|
13
13
|
import { validateModulePath } from "../utils/path-security";
|
|
14
|
+
import { createPluginLogger } from "./plugin-logger";
|
|
14
15
|
import { PluginRegistry } from "./registry";
|
|
15
16
|
import type { NaxPlugin, PluginConfigEntry } from "./types";
|
|
16
17
|
import { validatePlugin } from "./validator";
|
|
@@ -272,10 +273,11 @@ async function loadAndValidatePlugin(
|
|
|
272
273
|
return null;
|
|
273
274
|
}
|
|
274
275
|
|
|
275
|
-
// Call setup() if defined
|
|
276
|
+
// Call setup() if defined — pass plugin-scoped logger
|
|
276
277
|
if (validated.setup) {
|
|
277
278
|
try {
|
|
278
|
-
|
|
279
|
+
const pluginLogger = createPluginLogger(validated.name);
|
|
280
|
+
await validated.setup(config, pluginLogger);
|
|
279
281
|
} catch (error) {
|
|
280
282
|
const logger = getSafeLogger();
|
|
281
283
|
logger?.error("plugins", `Plugin '${validated.name}' setup failed`, { error });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Logger Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates write-only, stage-prefixed loggers for plugins.
|
|
5
|
+
* Each logger auto-tags entries with `plugin:<name>` so plugin
|
|
6
|
+
* output is filterable and cannot impersonate core stages.
|
|
7
|
+
*
|
|
8
|
+
* @module plugins/plugin-logger
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getSafeLogger } from "../logger";
|
|
12
|
+
import type { PluginLogger } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a PluginLogger scoped to a plugin name.
|
|
16
|
+
*
|
|
17
|
+
* The returned logger delegates to the global nax Logger with
|
|
18
|
+
* `plugin:<pluginName>` as the stage. If the global logger is
|
|
19
|
+
* not initialized (e.g., during tests), calls are silently dropped.
|
|
20
|
+
*
|
|
21
|
+
* @param pluginName - Plugin name used as stage prefix
|
|
22
|
+
* @returns PluginLogger instance
|
|
23
|
+
*/
|
|
24
|
+
export function createPluginLogger(pluginName: string): PluginLogger {
|
|
25
|
+
const stage = `plugin:${pluginName}`;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
error(message: string, data?: Record<string, unknown>): void {
|
|
29
|
+
getSafeLogger()?.error(stage, message, data);
|
|
30
|
+
},
|
|
31
|
+
warn(message: string, data?: Record<string, unknown>): void {
|
|
32
|
+
getSafeLogger()?.warn(stage, message, data);
|
|
33
|
+
},
|
|
34
|
+
info(message: string, data?: Record<string, unknown>): void {
|
|
35
|
+
getSafeLogger()?.info(stage, message, data);
|
|
36
|
+
},
|
|
37
|
+
debug(message: string, data?: Record<string, unknown>): void {
|
|
38
|
+
getSafeLogger()?.debug(stage, message, data);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/plugins/types.ts
CHANGED
|
@@ -61,8 +61,9 @@ export interface NaxPlugin {
|
|
|
61
61
|
* validating config, establishing connections, etc.
|
|
62
62
|
*
|
|
63
63
|
* @param config - Plugin-specific config from nax config.json
|
|
64
|
+
* @param logger - Write-only logger scoped to this plugin (stage auto-prefixed as `plugin:<name>`)
|
|
64
65
|
*/
|
|
65
|
-
setup?(config: Record<string, unknown
|
|
66
|
+
setup?(config: Record<string, unknown>, logger: PluginLogger): Promise<void>;
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
69
|
* Called when the nax run ends (success or failure).
|
|
@@ -333,6 +334,54 @@ export interface IReporter {
|
|
|
333
334
|
onRunEnd?(event: RunEndEvent): Promise<void>;
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// Plugin Logger
|
|
339
|
+
// ============================================================================
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Write-only, level-gated logger provided to plugins via setup().
|
|
343
|
+
*
|
|
344
|
+
* All log entries are auto-prefixed with `plugin:<name>` as the stage,
|
|
345
|
+
* so plugins cannot impersonate core nax stages. The interface is
|
|
346
|
+
* intentionally minimal — plugins only need to emit messages, not
|
|
347
|
+
* configure log levels or access log files.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* let log: PluginLogger;
|
|
352
|
+
*
|
|
353
|
+
* const myPlugin: NaxPlugin = {
|
|
354
|
+
* name: "my-plugin",
|
|
355
|
+
* version: "1.0.0",
|
|
356
|
+
* provides: ["reviewer"],
|
|
357
|
+
* async setup(config, logger) {
|
|
358
|
+
* log = logger;
|
|
359
|
+
* log.info("Initialized with config", { keys: Object.keys(config) });
|
|
360
|
+
* },
|
|
361
|
+
* extensions: {
|
|
362
|
+
* reviewer: {
|
|
363
|
+
* name: "my-check",
|
|
364
|
+
* description: "Custom check",
|
|
365
|
+
* async check(workdir, changedFiles) {
|
|
366
|
+
* log.debug("Scanning files", { count: changedFiles.length });
|
|
367
|
+
* // ...
|
|
368
|
+
* }
|
|
369
|
+
* }
|
|
370
|
+
* }
|
|
371
|
+
* };
|
|
372
|
+
* ```
|
|
373
|
+
*/
|
|
374
|
+
export interface PluginLogger {
|
|
375
|
+
/** Log an error message */
|
|
376
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
377
|
+
/** Log a warning message */
|
|
378
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
379
|
+
/** Log an informational message */
|
|
380
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
381
|
+
/** Log a debug message */
|
|
382
|
+
debug(message: string, data?: Record<string, unknown>): void;
|
|
383
|
+
}
|
|
384
|
+
|
|
336
385
|
// ============================================================================
|
|
337
386
|
// Plugin Config
|
|
338
387
|
// ============================================================================
|
|
@@ -162,10 +162,15 @@ export async function checkPRDValid(prd: PRD): Promise<Check> {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** Dependency injection for testability */
|
|
166
|
+
export const _deps = {
|
|
167
|
+
spawn: Bun.spawn,
|
|
168
|
+
};
|
|
169
|
+
|
|
165
170
|
/** Check if Claude CLI is available. Uses: claude --version */
|
|
166
171
|
export async function checkClaudeCLI(): Promise<Check> {
|
|
167
172
|
try {
|
|
168
|
-
const proc =
|
|
173
|
+
const proc = _deps.spawn(["claude", "--version"], {
|
|
169
174
|
stdout: "pipe",
|
|
170
175
|
stderr: "pipe",
|
|
171
176
|
});
|
|
@@ -192,6 +197,37 @@ export async function checkClaudeCLI(): Promise<Check> {
|
|
|
192
197
|
}
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
/** Check if configured agent binary is available. Reads agent from config, defaults to 'claude'.
|
|
201
|
+
* Supports: claude, codex, opencode, gemini, aider */
|
|
202
|
+
export async function checkAgentCLI(config: NaxConfig): Promise<Check> {
|
|
203
|
+
const agent = config.execution?.agent || "claude";
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const proc = _deps.spawn([agent, "--version"], {
|
|
207
|
+
stdout: "pipe",
|
|
208
|
+
stderr: "pipe",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const exitCode = await proc.exited;
|
|
212
|
+
const passed = exitCode === 0;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
name: "agent-cli-available",
|
|
216
|
+
tier: "blocker",
|
|
217
|
+
passed,
|
|
218
|
+
message: passed ? `${agent} CLI is available` : `${agent} CLI not found. Install the ${agent} binary.`,
|
|
219
|
+
};
|
|
220
|
+
} catch {
|
|
221
|
+
// Bun.spawn throws ENOENT when the binary is not found in PATH.
|
|
222
|
+
return {
|
|
223
|
+
name: "agent-cli-available",
|
|
224
|
+
tier: "blocker",
|
|
225
|
+
passed: false,
|
|
226
|
+
message: `${agent} CLI not found in PATH. Install the ${agent} binary.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
195
231
|
/** Check if dependencies are installed (language-aware). Detects: node_modules, target, venv, vendor */
|
|
196
232
|
export async function checkDependenciesInstalled(workdir: string): Promise<Check> {
|
|
197
233
|
const depPaths = [
|
package/src/precheck/checks.ts
CHANGED
package/src/precheck/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { NaxConfig } from "../config";
|
|
10
10
|
import type { PRD } from "../prd/types";
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
checkAgentCLI,
|
|
13
13
|
checkClaudeMdExists,
|
|
14
14
|
checkDependenciesInstalled,
|
|
15
15
|
checkDiskSpace,
|
|
@@ -105,7 +105,7 @@ export async function runPrecheck(
|
|
|
105
105
|
() => checkWorkingTreeClean(workdir),
|
|
106
106
|
() => checkStaleLock(workdir),
|
|
107
107
|
() => checkPRDValid(prd),
|
|
108
|
-
() =>
|
|
108
|
+
() => checkAgentCLI(config),
|
|
109
109
|
() => checkDependenciesInstalled(workdir),
|
|
110
110
|
() => checkTestCommand(config),
|
|
111
111
|
() => checkLintCommand(config),
|
package/src/routing/router.ts
CHANGED
|
@@ -181,6 +181,7 @@ export function determineTestStrategy(
|
|
|
181
181
|
// Explicit overrides — ignore all heuristics
|
|
182
182
|
if (tddStrategy === "strict") return "three-session-tdd";
|
|
183
183
|
if (tddStrategy === "lite") return "three-session-tdd-lite";
|
|
184
|
+
if (tddStrategy === "simple") return "tdd-simple";
|
|
184
185
|
if (tddStrategy === "off") return "test-after";
|
|
185
186
|
|
|
186
187
|
// auto mode: apply heuristics
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Falls back to keyword strategy on failure. Supports batch mode for efficiency.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { AgentAdapter } from "../../agents/types";
|
|
8
9
|
import type { NaxConfig } from "../../config";
|
|
9
10
|
import { resolveModel } from "../../config";
|
|
10
11
|
import { getLogger } from "../../logger";
|
|
11
12
|
import type { UserStory } from "../../prd/types";
|
|
13
|
+
import { determineTestStrategy } from "../router";
|
|
12
14
|
import type { RoutingContext, RoutingDecision, RoutingStrategy } from "../strategy";
|
|
13
15
|
import { keywordStrategy } from "./keyword";
|
|
14
16
|
import { buildBatchPrompt, buildRoutingPrompt, parseBatchResponse, parseRoutingResponse } from "./llm-prompts";
|
|
@@ -41,6 +43,11 @@ export function clearCacheForStory(storyId: string): void {
|
|
|
41
43
|
cachedDecisions.delete(storyId);
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** Inject a cache entry directly (test helper only) */
|
|
47
|
+
export function injectCacheEntry(storyId: string, decision: RoutingDecision): void {
|
|
48
|
+
cachedDecisions.set(storyId, decision);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
/** Evict oldest entry when cache is full (LRU) */
|
|
45
52
|
function evictOldest(): void {
|
|
46
53
|
const firstKey = cachedDecisions.keys().next().value;
|
|
@@ -59,22 +66,31 @@ export interface PipedProc {
|
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
68
|
* Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
69
|
+
* Includes spawn for backward compatibility with BUG-039 tests, and adapter for new AA-003.
|
|
62
70
|
*/
|
|
63
71
|
export const _deps = {
|
|
64
72
|
spawn: (cmd: string[], opts: { stdout: "pipe"; stderr: "pipe" }): PipedProc =>
|
|
65
73
|
Bun.spawn(cmd, opts) as unknown as PipedProc,
|
|
74
|
+
adapter: undefined as AgentAdapter | undefined,
|
|
66
75
|
};
|
|
67
76
|
|
|
68
77
|
/**
|
|
69
|
-
* Call LLM via
|
|
78
|
+
* Call LLM via adapter.complete() with timeout.
|
|
70
79
|
*
|
|
80
|
+
* @param adapter - Agent adapter to use for completion
|
|
71
81
|
* @param modelTier - Model tier to use for routing call
|
|
72
82
|
* @param prompt - Prompt to send to LLM
|
|
73
83
|
* @param config - nax configuration
|
|
74
84
|
* @returns LLM response text
|
|
75
|
-
* @throws Error on timeout or
|
|
85
|
+
* @throws Error on timeout or completion failure
|
|
76
86
|
*/
|
|
77
|
-
async function callLlmOnce(
|
|
87
|
+
async function callLlmOnce(
|
|
88
|
+
adapter: AgentAdapter,
|
|
89
|
+
modelTier: string,
|
|
90
|
+
prompt: string,
|
|
91
|
+
config: NaxConfig,
|
|
92
|
+
timeoutMs: number,
|
|
93
|
+
): Promise<string> {
|
|
78
94
|
// Resolve model tier to actual model identifier
|
|
79
95
|
const modelEntry = config.models[modelTier];
|
|
80
96
|
if (!modelEntry) {
|
|
@@ -84,12 +100,6 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
|
|
|
84
100
|
const modelDef = resolveModel(modelEntry);
|
|
85
101
|
const modelArg = modelDef.model;
|
|
86
102
|
|
|
87
|
-
// Spawn claude CLI with timeout
|
|
88
|
-
const proc = _deps.spawn(["claude", "-p", prompt, "--model", modelArg], {
|
|
89
|
-
stdout: "pipe",
|
|
90
|
-
stderr: "pipe",
|
|
91
|
-
});
|
|
92
|
-
|
|
93
103
|
// Race between completion and timeout, ensuring cleanup on either path
|
|
94
104
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
95
105
|
|
|
@@ -101,16 +111,7 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
|
|
|
101
111
|
// Prevent unhandled rejection if timer fires between race resolution and clearTimeout
|
|
102
112
|
timeoutPromise.catch(() => {});
|
|
103
113
|
|
|
104
|
-
const outputPromise = (
|
|
105
|
-
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
106
|
-
|
|
107
|
-
const exitCode = await proc.exited;
|
|
108
|
-
if (exitCode !== 0) {
|
|
109
|
-
throw new Error(`claude CLI failed with exit code ${exitCode}: ${stderr}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return stdout.trim();
|
|
113
|
-
})();
|
|
114
|
+
const outputPromise = adapter.complete(prompt, { model: modelArg });
|
|
114
115
|
|
|
115
116
|
try {
|
|
116
117
|
const result = await Promise.race([outputPromise, timeoutPromise]);
|
|
@@ -118,30 +119,23 @@ async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig,
|
|
|
118
119
|
return result;
|
|
119
120
|
} catch (err) {
|
|
120
121
|
clearTimeout(timeoutId);
|
|
121
|
-
// Silence the floating outputPromise
|
|
122
|
-
// proc.kill() causes piped streams to error → Response.text() rejects →
|
|
123
|
-
// outputPromise rejects. The .catch() must be attached first to prevent
|
|
124
|
-
// an unhandled rejection that crashes nax via crash-recovery.
|
|
122
|
+
// Silence the floating outputPromise to prevent unhandled rejection
|
|
125
123
|
outputPromise.catch(() => {});
|
|
126
|
-
proc.kill();
|
|
127
|
-
// DO NOT call proc.stdout.cancel() / proc.stderr.cancel() here.
|
|
128
|
-
// The streams are locked by Response.text() readers. Per Web Streams spec,
|
|
129
|
-
// cancel() on a locked stream returns a rejected Promise (not a sync throw),
|
|
130
|
-
// which becomes an unhandled rejection. Let proc.kill() handle cleanup.
|
|
131
124
|
throw err;
|
|
132
125
|
}
|
|
133
126
|
}
|
|
134
127
|
|
|
135
128
|
/**
|
|
136
|
-
* Call LLM via
|
|
129
|
+
* Call LLM via adapter.complete() with timeout and retry (BUG-033).
|
|
137
130
|
*
|
|
131
|
+
* @param adapter - Agent adapter to use for completion
|
|
138
132
|
* @param modelTier - Model tier to use for routing call
|
|
139
133
|
* @param prompt - Prompt to send to LLM
|
|
140
134
|
* @param config - nax configuration
|
|
141
135
|
* @returns LLM response text
|
|
142
136
|
* @throws Error after all retries exhausted
|
|
143
137
|
*/
|
|
144
|
-
async function callLlm(modelTier: string, prompt: string, config: NaxConfig): Promise<string> {
|
|
138
|
+
async function callLlm(adapter: AgentAdapter, modelTier: string, prompt: string, config: NaxConfig): Promise<string> {
|
|
145
139
|
const llmConfig = config.routing.llm;
|
|
146
140
|
const timeoutMs = llmConfig?.timeoutMs ?? 30000;
|
|
147
141
|
const maxRetries = llmConfig?.retries ?? 1;
|
|
@@ -151,7 +145,7 @@ async function callLlm(modelTier: string, prompt: string, config: NaxConfig): Pr
|
|
|
151
145
|
|
|
152
146
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
153
147
|
try {
|
|
154
|
-
return await callLlmOnce(modelTier, prompt, config, timeoutMs);
|
|
148
|
+
return await callLlmOnce(adapter, modelTier, prompt, config, timeoutMs);
|
|
155
149
|
} catch (err) {
|
|
156
150
|
lastError = err as Error;
|
|
157
151
|
if (attempt < maxRetries) {
|
|
@@ -189,11 +183,17 @@ export async function routeBatch(stories: UserStory[], context: RoutingContext):
|
|
|
189
183
|
throw new Error("LLM routing config not found");
|
|
190
184
|
}
|
|
191
185
|
|
|
186
|
+
// Resolve adapter from context or _deps
|
|
187
|
+
const adapter = context.adapter ?? _deps.adapter;
|
|
188
|
+
if (!adapter) {
|
|
189
|
+
throw new Error("No agent adapter available for batch routing (AA-003)");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
192
|
const modelTier = llmConfig.model ?? "fast";
|
|
193
193
|
const prompt = buildBatchPrompt(stories, config);
|
|
194
194
|
|
|
195
195
|
try {
|
|
196
|
-
const output = await callLlm(modelTier, prompt, config);
|
|
196
|
+
const output = await callLlm(adapter, modelTier, prompt, config);
|
|
197
197
|
const decisions = parseBatchResponse(output, stories, config);
|
|
198
198
|
|
|
199
199
|
// Populate cache (PERF-1 fix: evict oldest if full)
|
|
@@ -217,7 +217,7 @@ export async function routeBatch(stories: UserStory[], context: RoutingContext):
|
|
|
217
217
|
*
|
|
218
218
|
* This strategy:
|
|
219
219
|
* - Checks cache first (if enabled)
|
|
220
|
-
* - Calls LLM with story context to classify complexity
|
|
220
|
+
* - Calls LLM with story context to classify complexity (via adapter.complete())
|
|
221
221
|
* - Parses structured JSON response
|
|
222
222
|
* - Maps complexity to model tier and test strategy
|
|
223
223
|
* - Falls back to null (keyword fallback) on any failure
|
|
@@ -241,14 +241,25 @@ export const llmStrategy: RoutingStrategy = {
|
|
|
241
241
|
if (!cached) {
|
|
242
242
|
throw new Error(`Cached decision not found for story: ${story.id}`);
|
|
243
243
|
}
|
|
244
|
+
// Recompute testStrategy from complexity — cache is authoritative on complexity/modelTier
|
|
245
|
+
// only. testStrategy must always reflect the current determineTestStrategy() rules
|
|
246
|
+
// (e.g. TS-001: simple → tdd-simple) even if the cache was populated under older rules.
|
|
247
|
+
const tddStrategy = config.tdd?.strategy ?? "auto";
|
|
248
|
+
const freshTestStrategy = determineTestStrategy(
|
|
249
|
+
cached.complexity,
|
|
250
|
+
story.title,
|
|
251
|
+
story.description,
|
|
252
|
+
story.tags,
|
|
253
|
+
tddStrategy,
|
|
254
|
+
);
|
|
244
255
|
const logger = getLogger();
|
|
245
256
|
logger.debug("routing", "LLM cache hit", {
|
|
246
257
|
storyId: story.id,
|
|
247
258
|
complexity: cached.complexity,
|
|
248
259
|
modelTier: cached.modelTier,
|
|
249
|
-
testStrategy:
|
|
260
|
+
testStrategy: freshTestStrategy,
|
|
250
261
|
});
|
|
251
|
-
return cached;
|
|
262
|
+
return { ...cached, testStrategy: freshTestStrategy };
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
// One-shot mode: cache miss -> keyword fallback without new LLM call
|
|
@@ -261,9 +272,15 @@ export const llmStrategy: RoutingStrategy = {
|
|
|
261
272
|
}
|
|
262
273
|
|
|
263
274
|
try {
|
|
275
|
+
// Resolve adapter from context or _deps (AA-003)
|
|
276
|
+
const adapter = context.adapter ?? _deps.adapter;
|
|
277
|
+
if (!adapter) {
|
|
278
|
+
throw new Error("No agent adapter available for LLM routing (AA-003)");
|
|
279
|
+
}
|
|
280
|
+
|
|
264
281
|
const modelTier = llmConfig.model ?? "fast";
|
|
265
282
|
const prompt = buildRoutingPrompt(story, config);
|
|
266
|
-
const output = await callLlm(modelTier, prompt, config);
|
|
283
|
+
const output = await callLlm(adapter, modelTier, prompt, config);
|
|
267
284
|
const decision = parseRoutingResponse(output, story, config);
|
|
268
285
|
|
|
269
286
|
// Cache decision (PERF-1 fix: evict oldest if full)
|
package/src/routing/strategy.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Strategies can return null to delegate to the next strategy in the chain.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { AgentAdapter } from "../agents/types";
|
|
8
9
|
import type { Complexity, ModelTier, NaxConfig, TestStrategy } from "../config";
|
|
9
10
|
import type { UserStory } from "../prd/types";
|
|
10
11
|
|
|
@@ -45,6 +46,8 @@ export interface RoutingContext {
|
|
|
45
46
|
codebaseContext?: string;
|
|
46
47
|
/** Optional historical metrics (v0.5 Phase 1) */
|
|
47
48
|
metrics?: AggregateMetrics;
|
|
49
|
+
/** Optional agent adapter for LLM-based routing (AA-003) */
|
|
50
|
+
adapter?: AgentAdapter;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/** Routing decision returned by strategies */
|