@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.
@@ -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 */
@@ -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 callLlm in tests to avoid spawning the claude CLI.
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(requestId: string, timeout = 60000): Promise<InteractionResponse> {
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
- const callFn = _deps.callLlm ?? this.callLlm.bind(this);
92
- const decision = await callFn(request);
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
- if (!this.config.naxConfig) {
120
- throw new Error("Auto plugin requires naxConfig in init()");
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
- const modelEntry = this.config.naxConfig.models[modelTier];
124
- if (!modelEntry) {
125
- throw new Error(`Model tier "${modelTier}" not found in config.models`);
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
- const modelDef = resolveModel(modelEntry);
129
- const modelArg = modelDef.model;
130
-
131
- // Spawn claude CLI
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
- if (ctx.story.routing?.testStrategy) routing.testStrategy = ctx.story.routing.testStrategy;
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) {
@@ -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";
@@ -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
- await validated.setup(config);
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
+ }
@@ -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>): Promise<void>;
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 = Bun.spawn(["claude", "--version"], {
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 = [
@@ -13,6 +13,7 @@ export {
13
13
  checkStaleLock,
14
14
  checkPRDValid,
15
15
  checkClaudeCLI,
16
+ checkAgentCLI,
16
17
  checkDependenciesInstalled,
17
18
  checkTestCommand,
18
19
  checkLintCommand,
@@ -9,7 +9,7 @@
9
9
  import type { NaxConfig } from "../config";
10
10
  import type { PRD } from "../prd/types";
11
11
  import {
12
- checkClaudeCLI,
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
- () => checkClaudeCLI(),
108
+ () => checkAgentCLI(config),
109
109
  () => checkDependenciesInstalled(workdir),
110
110
  () => checkTestCommand(config),
111
111
  () => checkLintCommand(config),
@@ -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 claude CLI with timeout.
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 spawn failure
85
+ * @throws Error on timeout or completion failure
76
86
  */
77
- async function callLlmOnce(modelTier: string, prompt: string, config: NaxConfig, timeoutMs: number): Promise<string> {
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 = (async () => {
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 BEFORE killing the process.
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 claude CLI with timeout and retry (BUG-033).
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: cached.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)
@@ -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 */