@nathapp/nax 0.34.0 → 0.36.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.
Files changed (48) hide show
  1. package/bin/nax.ts +18 -9
  2. package/dist/nax.js +1934 -1138
  3. package/package.json +1 -2
  4. package/src/agents/adapters/aider.ts +135 -0
  5. package/src/agents/adapters/codex.ts +153 -0
  6. package/src/agents/adapters/gemini.ts +177 -0
  7. package/src/agents/adapters/opencode.ts +106 -0
  8. package/src/agents/claude-plan.ts +22 -5
  9. package/src/agents/claude.ts +102 -11
  10. package/src/agents/index.ts +4 -1
  11. package/src/agents/model-resolution.ts +43 -0
  12. package/src/agents/registry.ts +8 -3
  13. package/src/agents/types-extended.ts +5 -1
  14. package/src/agents/types.ts +31 -0
  15. package/src/agents/version-detection.ts +109 -0
  16. package/src/analyze/classifier.ts +30 -50
  17. package/src/cli/agents.ts +87 -0
  18. package/src/cli/analyze-parser.ts +8 -1
  19. package/src/cli/analyze.ts +1 -1
  20. package/src/cli/config.ts +28 -14
  21. package/src/cli/generate.ts +1 -1
  22. package/src/cli/index.ts +1 -0
  23. package/src/cli/plan.ts +1 -0
  24. package/src/config/types.ts +3 -1
  25. package/src/context/generator.ts +4 -0
  26. package/src/context/generators/codex.ts +28 -0
  27. package/src/context/generators/gemini.ts +28 -0
  28. package/src/context/types.ts +1 -1
  29. package/src/interaction/init.ts +8 -7
  30. package/src/interaction/plugins/auto.ts +41 -25
  31. package/src/pipeline/stages/execution.ts +2 -39
  32. package/src/pipeline/stages/routing.ts +12 -3
  33. package/src/plugins/index.ts +2 -0
  34. package/src/plugins/loader.ts +4 -2
  35. package/src/plugins/plugin-logger.ts +41 -0
  36. package/src/plugins/types.ts +50 -1
  37. package/src/precheck/checks-agents.ts +63 -0
  38. package/src/precheck/checks-blockers.ts +37 -1
  39. package/src/precheck/checks.ts +4 -0
  40. package/src/precheck/index.ts +4 -2
  41. package/src/routing/router.ts +1 -0
  42. package/src/routing/strategies/llm.ts +53 -36
  43. package/src/routing/strategy.ts +3 -0
  44. package/src/tdd/rectification-gate.ts +25 -1
  45. package/src/tdd/session-runner.ts +18 -49
  46. package/src/tdd/verdict.ts +135 -7
  47. package/src/utils/git.ts +49 -0
  48. package/src/verification/rectification-loop.ts +14 -1
package/src/cli/config.ts CHANGED
@@ -23,19 +23,27 @@ const FIELD_DESCRIPTIONS: Record<string, string> = {
23
23
  "models.powerful": "Powerful model for complex tasks (e.g., opus)",
24
24
 
25
25
  // Auto mode
26
- autoMode: "Auto mode configuration for agent orchestration",
26
+ autoMode:
27
+ "Auto mode configuration for agent orchestration. Enables multi-agent routing with model tier selection per task complexity and escalation on failures.",
27
28
  "autoMode.enabled": "Enable automatic agent selection and escalation",
28
- "autoMode.defaultAgent": "Default agent to use (e.g., claude, codex)",
29
- "autoMode.fallbackOrder": "Fallback order when agent is rate-limited",
30
- "autoMode.complexityRouting": "Model tier per complexity level",
31
- "autoMode.complexityRouting.simple": "Model tier for simple tasks",
32
- "autoMode.complexityRouting.medium": "Model tier for medium tasks",
33
- "autoMode.complexityRouting.complex": "Model tier for complex tasks",
34
- "autoMode.complexityRouting.expert": "Model tier for expert tasks",
35
- "autoMode.escalation": "Escalation settings for failed stories",
29
+ "autoMode.defaultAgent":
30
+ "Default agent to use when no specific agent is requested. Examples: 'claude' (Claude Code), 'codex' (GitHub Copilot), 'opencode' (OpenCode). The agent handles the main coding tasks.",
31
+ "autoMode.fallbackOrder":
32
+ 'Fallback order for agent selection when the primary agent is rate-limited, unavailable, or fails. Tries each agent in sequence until one succeeds. Example: ["claude", "codex", "opencode"] means try Claude first, then Copilot, then OpenCode.',
33
+ "autoMode.complexityRouting":
34
+ "Model tier routing rules mapped to story complexity levels. Determines which model (fast/balanced/powerful) to use based on task complexity: simple fast, medium → balanced, complex → powerful, expert → powerful.",
35
+ "autoMode.complexityRouting.simple": "Model tier for simple tasks (low complexity, straightforward changes)",
36
+ "autoMode.complexityRouting.medium": "Model tier for medium tasks (moderate complexity, multi-file changes)",
37
+ "autoMode.complexityRouting.complex": "Model tier for complex tasks (high complexity, architectural decisions)",
38
+ "autoMode.complexityRouting.expert":
39
+ "Model tier for expert tasks (highest complexity, novel problems, design patterns)",
40
+ "autoMode.escalation":
41
+ "Escalation settings for failed stories. When a story fails after max attempts at current tier, escalate to the next tier in tierOrder. Enables progressive use of more powerful models.",
36
42
  "autoMode.escalation.enabled": "Enable tier escalation on failure",
37
- "autoMode.escalation.tierOrder": "Ordered tier escalation with per-tier attempt budgets",
38
- "autoMode.escalation.escalateEntireBatch": "Escalate all stories in batch when one fails",
43
+ "autoMode.escalation.tierOrder":
44
+ 'Ordered tier escalation chain with per-tier attempt budgets. Format: [{"tier": "fast", "attempts": 2}, {"tier": "balanced", "attempts": 2}, {"tier": "powerful", "attempts": 1}]. Allows each tier to attempt fixes before escalating to the next.',
45
+ "autoMode.escalation.escalateEntireBatch":
46
+ "When enabled, escalate all stories in a batch if one fails. When disabled, only the failing story escalates (allows parallel attempts at different tiers).",
39
47
 
40
48
  // Routing
41
49
  routing: "Model routing strategy configuration",
@@ -528,9 +536,15 @@ function displayConfigWithDescriptions(
528
536
 
529
537
  // Display description comment if available
530
538
  if (description) {
531
- // Include path for prompts section (where tests expect "prompts.overrides" to appear)
532
- const isPromptsSubSection = currentPathStr.startsWith("prompts.");
533
- const comment = isPromptsSubSection ? `${currentPathStr}: ${description}` : description;
539
+ // Include path for direct subsections of key configuration sections
540
+ // (to improve clarity of important configs like multi-agent setup)
541
+ const pathParts = currentPathStr.split(".");
542
+ // Only show path for 2-level paths (e.g., "autoMode.enabled", "models.fast")
543
+ // to keep deeply nested descriptions concise
544
+ const isDirectSubsection = pathParts.length === 2;
545
+ const isKeySection = ["prompts", "autoMode", "models", "routing"].includes(pathParts[0]);
546
+ const shouldIncludePath = isKeySection && isDirectSubsection;
547
+ const comment = shouldIncludePath ? `${currentPathStr}: ${description}` : description;
534
548
  console.log(`${indentStr}# ${comment}`);
535
549
  }
536
550
 
@@ -26,7 +26,7 @@ export interface GenerateCommandOptions {
26
26
  noAutoInject?: boolean;
27
27
  }
28
28
 
29
- const VALID_AGENTS: AgentType[] = ["claude", "opencode", "cursor", "windsurf", "aider"];
29
+ const VALID_AGENTS: AgentType[] = ["claude", "codex", "opencode", "cursor", "windsurf", "aider", "gemini"];
30
30
 
31
31
  /**
32
32
  * `nax generate` command handler.
package/src/cli/index.ts CHANGED
@@ -37,3 +37,4 @@ export {
37
37
  } from "./interact";
38
38
  export { generateCommand, type GenerateCommandOptions } from "./generate";
39
39
  export { configCommand, type ConfigCommandOptions } from "./config";
40
+ export { agentsListCommand } from "./agents";
package/src/cli/plan.ts CHANGED
@@ -91,6 +91,7 @@ export async function planCommand(
91
91
  inputFile: options.from,
92
92
  modelTier,
93
93
  modelDef,
94
+ config,
94
95
  };
95
96
 
96
97
  // Run agent in plan mode
@@ -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 */
@@ -11,7 +11,9 @@ import type { NaxConfig } from "../config";
11
11
  import { validateFilePath } from "../config/path-security";
12
12
  import { aiderGenerator } from "./generators/aider";
13
13
  import { claudeGenerator } from "./generators/claude";
14
+ import { codexGenerator } from "./generators/codex";
14
15
  import { cursorGenerator } from "./generators/cursor";
16
+ import { geminiGenerator } from "./generators/gemini";
15
17
  import { opencodeGenerator } from "./generators/opencode";
16
18
  import { windsurfGenerator } from "./generators/windsurf";
17
19
  import { buildProjectMetadata } from "./injector";
@@ -20,10 +22,12 @@ import type { AgentContextGenerator, AgentType, ContextContent, GeneratorMap } f
20
22
  /** Generator registry */
21
23
  const GENERATORS: GeneratorMap = {
22
24
  claude: claudeGenerator,
25
+ codex: codexGenerator,
23
26
  opencode: opencodeGenerator,
24
27
  cursor: cursorGenerator,
25
28
  windsurf: windsurfGenerator,
26
29
  aider: aiderGenerator,
30
+ gemini: geminiGenerator,
27
31
  };
28
32
 
29
33
  /** Generation result for a single agent */
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Codex Config Generator (v0.16.1)
3
+ *
4
+ * Generates codex.md from nax/context.md + auto-injected metadata.
5
+ */
6
+
7
+ import { formatMetadataSection } from "../injector";
8
+ import type { AgentContextGenerator, ContextContent } from "../types";
9
+
10
+ function generateCodexConfig(context: ContextContent): string {
11
+ const header = `# Codex Instructions
12
+
13
+ This file is auto-generated from \`nax/context.md\`.
14
+ DO NOT EDIT MANUALLY — run \`nax generate\` to regenerate.
15
+
16
+ ---
17
+
18
+ `;
19
+
20
+ const metaSection = context.metadata ? formatMetadataSection(context.metadata) : "";
21
+ return header + metaSection + context.markdown;
22
+ }
23
+
24
+ export const codexGenerator: AgentContextGenerator = {
25
+ name: "codex",
26
+ outputFile: "codex.md",
27
+ generate: generateCodexConfig,
28
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Gemini CLI Config Generator (v0.16.1)
3
+ *
4
+ * Generates GEMINI.md from nax/context.md + auto-injected metadata.
5
+ */
6
+
7
+ import { formatMetadataSection } from "../injector";
8
+ import type { AgentContextGenerator, ContextContent } from "../types";
9
+
10
+ function generateGeminiConfig(context: ContextContent): string {
11
+ const header = `# Gemini CLI Context
12
+
13
+ This file is auto-generated from \`nax/context.md\`.
14
+ DO NOT EDIT MANUALLY — run \`nax generate\` to regenerate.
15
+
16
+ ---
17
+
18
+ `;
19
+
20
+ const metaSection = context.metadata ? formatMetadataSection(context.metadata) : "";
21
+ return header + metaSection + context.markdown;
22
+ }
23
+
24
+ export const geminiGenerator: AgentContextGenerator = {
25
+ name: "gemini",
26
+ outputFile: "GEMINI.md",
27
+ generate: generateGeminiConfig,
28
+ };
@@ -40,7 +40,7 @@ export interface AgentContextGenerator {
40
40
  }
41
41
 
42
42
  /** All available generator types */
43
- export type AgentType = "claude" | "opencode" | "cursor" | "windsurf" | "aider";
43
+ export type AgentType = "claude" | "codex" | "opencode" | "cursor" | "windsurf" | "aider" | "gemini";
44
44
 
45
45
  /** Generator registry map */
46
46
  export type GeneratorMap = Record<AgentType, AgentContextGenerator>;
@@ -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
 
@@ -36,7 +36,7 @@ import { checkMergeConflict, checkStoryAmbiguity, isTriggerEnabled } from "../..
36
36
  import { getLogger } from "../../logger";
37
37
  import type { FailureCategory } from "../../tdd";
38
38
  import { runThreeSessionTdd } from "../../tdd";
39
- import { detectMergeConflict } from "../../utils/git";
39
+ import { autoCommitIfDirty, detectMergeConflict } from "../../utils/git";
40
40
  import type { PipelineContext, PipelineStage, StageResult } from "../types";
41
41
 
42
42
  /**
@@ -200,7 +200,7 @@ export const executionStage: PipelineStage = {
200
200
  ctx.agentResult = result;
201
201
 
202
202
  // BUG-058: Auto-commit if agent left uncommitted changes (single-session/test-after)
203
- await autoCommitIfDirty(ctx.workdir, "single-session", ctx.story.id);
203
+ await autoCommitIfDirty(ctx.workdir, "execution", "single-session", ctx.story.id);
204
204
 
205
205
  // merge-conflict trigger: detect CONFLICT markers in agent output
206
206
  const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
@@ -270,40 +270,3 @@ export const _executionDeps = {
270
270
  isAmbiguousOutput,
271
271
  checkStoryAmbiguity,
272
272
  };
273
-
274
- /**
275
- * BUG-058: Auto-commit safety net for single-session/test-after.
276
- * Mirrors the same function in tdd/session-runner.ts for three-session TDD.
277
- */
278
- async function autoCommitIfDirty(workdir: string, role: string, storyId: string): Promise<void> {
279
- try {
280
- const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
281
- cwd: workdir,
282
- stdout: "pipe",
283
- stderr: "pipe",
284
- });
285
- const statusOutput = await new Response(statusProc.stdout).text();
286
- await statusProc.exited;
287
-
288
- if (!statusOutput.trim()) return;
289
-
290
- const logger = getLogger();
291
- logger.warn("execution", `Agent did not commit after ${role} session — auto-committing`, {
292
- role,
293
- storyId,
294
- dirtyFiles: statusOutput.trim().split("\n").length,
295
- });
296
-
297
- const addProc = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
298
- await addProc.exited;
299
-
300
- const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
301
- cwd: workdir,
302
- stdout: "pipe",
303
- stderr: "pipe",
304
- });
305
- await commitProc.exited;
306
- } catch {
307
- // Silently ignore — auto-commit is best-effort
308
- }
309
- }
@@ -25,6 +25,7 @@
25
25
  * ```
26
26
  */
27
27
 
28
+ import { getAgent } from "../../agents/registry";
28
29
  import type { NaxConfig } from "../../config";
29
30
  import { isGreenfieldStory } from "../../context/greenfield";
30
31
  import { applyDecomposition } from "../../decompose/apply";
@@ -68,6 +69,10 @@ export const routingStage: PipelineStage = {
68
69
  async execute(ctx: PipelineContext): Promise<StageResult> {
69
70
  const logger = getLogger();
70
71
 
72
+ // Resolve agent adapter for LLM routing (shared with execution)
73
+ const agentName = ctx.config.execution?.agent ?? "claude";
74
+ const adapter = _routingDeps.getAgent(agentName);
75
+
71
76
  // Staleness detection (RRP-003):
72
77
  // - story.routing absent → cache miss (no prior routing)
73
78
  // - story.routing + no contentHash → legacy cache hit (manual / pre-RRP-003 routing, honor as-is)
@@ -87,10 +92,13 @@ export const routingStage: PipelineStage = {
87
92
 
88
93
  if (isCacheHit) {
89
94
  // Cache hit: legacy routing (no contentHash) or matching contentHash — use cached values
90
- routing = await _routingDeps.routeStory(ctx.story, { config: ctx.config }, ctx.workdir, ctx.plugins);
95
+ routing = await _routingDeps.routeStory(ctx.story, { config: ctx.config, adapter }, ctx.workdir, ctx.plugins);
91
96
  // Override with cached values only when they are actually set
92
97
  if (ctx.story.routing?.complexity) routing.complexity = ctx.story.routing.complexity;
93
- if (ctx.story.routing?.testStrategy) routing.testStrategy = ctx.story.routing.testStrategy;
98
+ // BUG-062: Only honor stored testStrategy for legacy/manual routing (no contentHash).
99
+ // When contentHash exists, the LLM strategy layer already recomputes testStrategy
100
+ // fresh via determineTestStrategy() — don't clobber it with the stale PRD value.
101
+ if (!hasContentHash && ctx.story.routing?.testStrategy) routing.testStrategy = ctx.story.routing.testStrategy;
94
102
  // BUG-032: Use escalated modelTier if explicitly set (by handleTierEscalation),
95
103
  // otherwise derive from complexity + current config
96
104
  if (ctx.story.routing?.modelTier) {
@@ -103,7 +111,7 @@ export const routingStage: PipelineStage = {
103
111
  }
104
112
  } else {
105
113
  // Cache miss: no routing, or contentHash present but mismatched — fresh classification
106
- routing = await _routingDeps.routeStory(ctx.story, { config: ctx.config }, ctx.workdir, ctx.plugins);
114
+ routing = await _routingDeps.routeStory(ctx.story, { config: ctx.config, adapter }, ctx.workdir, ctx.plugins);
107
115
  // currentHash already computed if a mismatch was detected; compute now if starting fresh
108
116
  currentHash = currentHash ?? _routingDeps.computeStoryContentHash(ctx.story);
109
117
  ctx.story.routing = {
@@ -220,4 +228,5 @@ export const _routingDeps = {
220
228
  applyDecomposition,
221
229
  runDecompose,
222
230
  checkStoryOversized,
231
+ getAgent,
223
232
  };
@@ -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
  // ============================================================================
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Precheck for multi-agent health
3
+ *
4
+ * Detects installed agents, reports version information,
5
+ * and checks health status for each configured agent.
6
+ */
7
+
8
+ import { getAgentVersions } from "../agents/version-detection";
9
+ import type { Check } from "./types";
10
+
11
+ /**
12
+ * Check multi-agent health: installed agents and their versions
13
+ *
14
+ * This is a Tier 2 warning check. Reports which agents are available
15
+ * and their versions, but doesn't fail if no agents are installed
16
+ * (since the main configured agent is checked in Tier 1).
17
+ */
18
+ export async function checkMultiAgentHealth(): Promise<Check> {
19
+ try {
20
+ const versions = await getAgentVersions();
21
+
22
+ // Separate installed from not installed
23
+ const installed = versions.filter((v) => v.installed);
24
+ const notInstalled = versions.filter((v) => !v.installed);
25
+
26
+ // Build message with agent status
27
+ const lines: string[] = [];
28
+
29
+ if (installed.length > 0) {
30
+ lines.push(`Installed agents (${installed.length}):`);
31
+ for (const agent of installed) {
32
+ const versionStr = agent.version ? ` v${agent.version}` : " (version unknown)";
33
+ lines.push(` • ${agent.displayName}${versionStr}`);
34
+ }
35
+ } else {
36
+ lines.push("No additional agents detected (using default configured agent)");
37
+ }
38
+
39
+ if (notInstalled.length > 0) {
40
+ lines.push(`\nAvailable but not installed (${notInstalled.length}):`);
41
+ for (const agent of notInstalled) {
42
+ lines.push(` • ${agent.displayName}`);
43
+ }
44
+ }
45
+
46
+ const message = lines.join("\n");
47
+
48
+ return {
49
+ name: "multi-agent-health",
50
+ tier: "warning",
51
+ passed: true, // Always pass - this is informational
52
+ message,
53
+ };
54
+ } catch (error) {
55
+ // If version detection fails, still pass but report error
56
+ return {
57
+ name: "multi-agent-health",
58
+ tier: "warning",
59
+ passed: true,
60
+ message: `Agent detection: ${error instanceof Error ? error.message : "Unknown error"}`,
61
+ };
62
+ }
63
+ }