@nathapp/nax 0.37.0 → 0.38.1

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 (72) hide show
  1. package/dist/nax.js +3258 -2894
  2. package/package.json +4 -1
  3. package/src/agents/claude-complete.ts +72 -0
  4. package/src/agents/claude-execution.ts +189 -0
  5. package/src/agents/claude-interactive.ts +77 -0
  6. package/src/agents/claude-plan.ts +23 -8
  7. package/src/agents/claude.ts +64 -349
  8. package/src/analyze/classifier.ts +2 -1
  9. package/src/cli/config-descriptions.ts +206 -0
  10. package/src/cli/config-diff.ts +103 -0
  11. package/src/cli/config-display.ts +285 -0
  12. package/src/cli/config-get.ts +55 -0
  13. package/src/cli/config.ts +7 -618
  14. package/src/cli/prompts-export.ts +58 -0
  15. package/src/cli/prompts-init.ts +200 -0
  16. package/src/cli/prompts-main.ts +237 -0
  17. package/src/cli/prompts-tdd.ts +78 -0
  18. package/src/cli/prompts.ts +10 -541
  19. package/src/commands/logs-formatter.ts +201 -0
  20. package/src/commands/logs-reader.ts +171 -0
  21. package/src/commands/logs.ts +11 -362
  22. package/src/config/loader.ts +4 -15
  23. package/src/config/runtime-types.ts +448 -0
  24. package/src/config/schema-types.ts +53 -0
  25. package/src/config/types.ts +49 -486
  26. package/src/context/auto-detect.ts +2 -1
  27. package/src/context/builder.ts +3 -2
  28. package/src/execution/crash-heartbeat.ts +77 -0
  29. package/src/execution/crash-recovery.ts +23 -365
  30. package/src/execution/crash-signals.ts +149 -0
  31. package/src/execution/crash-writer.ts +154 -0
  32. package/src/execution/parallel-coordinator.ts +278 -0
  33. package/src/execution/parallel-executor-rectification-pass.ts +117 -0
  34. package/src/execution/parallel-executor-rectify.ts +135 -0
  35. package/src/execution/parallel-executor.ts +19 -211
  36. package/src/execution/parallel-worker.ts +148 -0
  37. package/src/execution/parallel.ts +5 -404
  38. package/src/execution/pid-registry.ts +3 -8
  39. package/src/execution/runner-completion.ts +160 -0
  40. package/src/execution/runner-execution.ts +221 -0
  41. package/src/execution/runner-setup.ts +82 -0
  42. package/src/execution/runner.ts +53 -202
  43. package/src/execution/timeout-handler.ts +100 -0
  44. package/src/hooks/runner.ts +11 -21
  45. package/src/metrics/tracker.ts +7 -30
  46. package/src/pipeline/runner.ts +2 -1
  47. package/src/pipeline/stages/completion.ts +0 -1
  48. package/src/pipeline/stages/context.ts +2 -1
  49. package/src/plugins/extensions.ts +225 -0
  50. package/src/plugins/loader.ts +2 -1
  51. package/src/plugins/types.ts +16 -221
  52. package/src/prd/index.ts +2 -1
  53. package/src/prd/validate.ts +41 -0
  54. package/src/precheck/checks-blockers.ts +15 -419
  55. package/src/precheck/checks-cli.ts +68 -0
  56. package/src/precheck/checks-config.ts +102 -0
  57. package/src/precheck/checks-git.ts +87 -0
  58. package/src/precheck/checks-system.ts +163 -0
  59. package/src/review/orchestrator.ts +19 -6
  60. package/src/review/runner.ts +17 -5
  61. package/src/routing/chain.ts +2 -1
  62. package/src/routing/loader.ts +2 -5
  63. package/src/tdd/orchestrator.ts +2 -1
  64. package/src/tdd/verdict-reader.ts +266 -0
  65. package/src/tdd/verdict.ts +6 -271
  66. package/src/utils/errors.ts +12 -0
  67. package/src/utils/git.ts +12 -5
  68. package/src/utils/json-file.ts +72 -0
  69. package/src/verification/executor.ts +2 -1
  70. package/src/verification/smart-runner.ts +23 -3
  71. package/src/worktree/manager.ts +9 -3
  72. package/src/worktree/merge.ts +3 -2
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Claude Code Agent Adapter
3
+ *
4
+ * Main adapter class coordinating execution, completion, decomposition, and interactive modes.
3
5
  */
4
6
 
5
7
  import { PidRegistry } from "../execution/pid-registry";
8
+ import { withProcessTimeout } from "../execution/timeout-handler";
6
9
  import { getLogger } from "../logger";
10
+ import { _completeDeps, executeComplete } from "./claude-complete";
7
11
  import { buildDecomposePrompt, parseDecomposeOutput } from "./claude-decompose";
12
+ import { _runOnceDeps, buildAllowedEnv, buildCommand, executeOnce } from "./claude-execution";
13
+ import { runInteractiveMode } from "./claude-interactive";
8
14
  import { runPlan } from "./claude-plan";
9
- import { estimateCostByDuration, estimateCostFromOutput } from "./cost";
10
15
  import type {
11
16
  AgentAdapter,
12
17
  AgentCapabilities,
@@ -20,49 +25,6 @@ import type {
20
25
  PlanResult,
21
26
  PtyHandle,
22
27
  } from "./types";
23
- import { CompleteError } from "./types";
24
-
25
- /**
26
- * Maximum characters to capture from agent stdout.
27
- *
28
- * Last 5000 chars typically contain the most relevant info (final status, summary, errors).
29
- * This limit prevents memory bloat while preserving actionable output.
30
- */
31
- const MAX_AGENT_OUTPUT_CHARS = 5000;
32
-
33
- /**
34
- * Maximum characters to capture from agent stderr.
35
- *
36
- * Last 1000 chars typically contain the actual error message (e.g., 401, 500, crash).
37
- * Smaller than stdout since stderr is more focused on errors.
38
- */
39
- const MAX_AGENT_STDERR_CHARS = 1000;
40
-
41
- /**
42
- * Grace period in ms between SIGTERM and SIGKILL on timeout.
43
- * Mirrors the pattern in src/verification/executor.ts:executeWithTimeout().
44
- */
45
- const SIGKILL_GRACE_PERIOD_MS = 5000;
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
28
 
67
29
  /**
68
30
  * Injectable dependencies for decompose() — allows tests to intercept
@@ -91,17 +53,8 @@ export const _decomposeDeps = {
91
53
  },
92
54
  };
93
55
 
94
- /**
95
- * Injectable dependencies for runOnce() — allows tests to verify
96
- * that PID cleanup (unregister) always runs even if kill() throws.
97
- *
98
- * @internal
99
- */
100
- export const _runOnceDeps = {
101
- killProc(proc: { kill(signal?: number | NodeJS.Signals): void }, signal: NodeJS.Signals): void {
102
- proc.kill(signal);
103
- },
104
- };
56
+ // Re-export deps for testing
57
+ export { _runOnceDeps, _completeDeps };
105
58
 
106
59
  /**
107
60
  * Claude Code agent adapter implementation.
@@ -146,240 +99,62 @@ export class ClaudeCodeAdapter implements AgentAdapter {
146
99
  }
147
100
 
148
101
  buildCommand(options: AgentRunOptions): string[] {
149
- const model = options.modelDef.model;
150
- const skipPermissions = options.dangerouslySkipPermissions ?? true;
151
- const permArgs = skipPermissions ? ["--dangerously-skip-permissions"] : [];
152
- return [this.binary, "--model", model, ...permArgs, "-p", options.prompt];
102
+ return buildCommand(this.binary, options);
153
103
  }
154
104
 
155
- async run(options: AgentRunOptions): Promise<AgentResult> {
156
- const maxRetries = 3;
157
- let lastError: Error | null = null;
158
-
159
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
160
- try {
161
- const result = await this.runOnce(options, attempt);
162
-
163
- if (result.rateLimited && attempt < maxRetries) {
164
- const backoffMs = 2 ** attempt * 1000;
165
- const logger = getLogger();
166
- logger.warn("agent", "Rate limited, retrying", { backoffSeconds: backoffMs / 1000, attempt, maxRetries });
167
- await Bun.sleep(backoffMs);
168
- continue;
169
- }
170
-
171
- return result;
172
- } catch (error) {
173
- lastError = error as Error;
174
- const isSpawnError = lastError.message.includes("spawn") || lastError.message.includes("ENOENT");
175
-
176
- if (isSpawnError && attempt < maxRetries) {
177
- const backoffMs = 2 ** attempt * 1000;
178
- const logger = getLogger();
179
- logger.warn("agent", "Agent spawn error, retrying", {
180
- error: lastError.message,
181
- backoffSeconds: backoffMs / 1000,
182
- attempt,
183
- maxRetries,
184
- });
185
- await Bun.sleep(backoffMs);
186
- continue;
187
- }
188
-
189
- throw lastError;
190
- }
191
- }
192
-
193
- throw lastError || new Error("Agent execution failed after all retries");
194
- }
195
-
196
- /**
197
- * Build allowed environment variables for spawned agents.
198
- * SEC-4: Only pass essential env vars to prevent leaking sensitive data.
199
- */
200
105
  buildAllowedEnv(options: AgentRunOptions): Record<string, string | undefined> {
201
- const allowed: Record<string, string | undefined> = {};
202
-
203
- const essentialVars = ["PATH", "HOME", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
204
- for (const varName of essentialVars) {
205
- if (process.env[varName]) {
206
- allowed[varName] = process.env[varName];
207
- }
208
- }
209
-
210
- const apiKeyVars = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
211
- for (const varName of apiKeyVars) {
212
- if (process.env[varName]) {
213
- allowed[varName] = process.env[varName];
214
- }
215
- }
216
-
217
- const allowedPrefixes = ["CLAUDE_", "NAX_", "CLAW_", "TURBO_"];
218
- for (const [key, value] of Object.entries(process.env)) {
219
- if (allowedPrefixes.some((prefix) => key.startsWith(prefix))) {
220
- allowed[key] = value;
221
- }
222
- }
223
-
224
- if (options.modelDef.env) {
225
- Object.assign(allowed, options.modelDef.env);
226
- }
227
-
228
- if (options.env) {
229
- Object.assign(allowed, options.env);
230
- }
231
-
232
- return allowed;
106
+ return buildAllowedEnv(options);
233
107
  }
234
108
 
235
- private async runOnce(options: AgentRunOptions, _attempt: number): Promise<AgentResult> {
236
- const cmd = this.buildCommand(options);
237
- const startTime = Date.now();
238
-
239
- const proc = Bun.spawn(cmd, {
240
- cwd: options.workdir,
241
- stdout: "pipe",
242
- stderr: "inherit", // MEM-3: Inherit stderr to avoid blocking on unread pipe
243
- env: this.buildAllowedEnv(options),
244
- });
245
-
246
- const processPid = proc.pid;
247
- const pidRegistry = this.getPidRegistry(options.workdir);
248
- await pidRegistry.register(processPid);
249
-
250
- let timedOut = false;
251
- const timeoutId = setTimeout(() => {
252
- timedOut = true;
253
- try {
254
- _runOnceDeps.killProc(proc, "SIGTERM" as NodeJS.Signals);
255
- } catch {
256
- /* already exited */
257
- }
258
- setTimeout(() => {
259
- try {
260
- _runOnceDeps.killProc(proc, "SIGKILL" as NodeJS.Signals);
261
- } catch {
262
- /* already exited */
263
- }
264
- }, SIGKILL_GRACE_PERIOD_MS);
265
- }, options.timeoutSeconds * 1000);
109
+ async run(options: AgentRunOptions): Promise<AgentResult> {
110
+ const maxRetries = 3;
111
+ let lastError: Error | null = null;
266
112
 
267
- let exitCode: number;
268
113
  try {
269
- // Hard deadline: if proc.exited doesn't resolve after kill signals are sent
270
- // (Bun subprocess edge case on some environments), fall back to -1 so the
271
- // caller can move on. timedOut flag ensures the result is still marked 124.
272
- const hardDeadlineMs = options.timeoutSeconds * 1000 + SIGKILL_GRACE_PERIOD_MS + 3000;
273
- exitCode = await Promise.race([
274
- proc.exited,
275
- new Promise<number>((resolve) => setTimeout(() => resolve(-1), hardDeadlineMs)),
276
- ]);
277
-
278
- // If hard deadline fired, the subprocess may still be alive (Bun SIGKILL edge case).
279
- // Force-kill via OS-level signal so that the stdout pipe closes and we don't block.
280
- if (exitCode === -1) {
281
- try {
282
- process.kill(processPid, "SIGKILL");
283
- } catch {
284
- /* already gone */
285
- }
286
- // Note: process.kill(-pid) (process group) only works if the child called
287
- // setpgid/setsid. Bun does not do this, so this will silently throw ESRCH.
288
- // Left here as a best-effort safety net for environments that do set a pgid.
114
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
289
115
  try {
290
- process.kill(-processPid, "SIGKILL");
291
- } catch {
292
- /* no process group — expected in most environments */
116
+ const pidRegistry = this.getPidRegistry(options.workdir);
117
+ const result = await executeOnce(this.binary, options, pidRegistry);
118
+
119
+ if (result.rateLimited && attempt < maxRetries) {
120
+ const backoffMs = 2 ** attempt * 1000;
121
+ const logger = getLogger();
122
+ logger.warn("agent", "Rate limited, retrying", { backoffSeconds: backoffMs / 1000, attempt, maxRetries });
123
+ await Bun.sleep(backoffMs);
124
+ continue;
125
+ }
126
+
127
+ return result;
128
+ } catch (error) {
129
+ lastError = error as Error;
130
+ const isSpawnError = lastError.message.includes("spawn") || lastError.message.includes("ENOENT");
131
+
132
+ if (isSpawnError && attempt < maxRetries) {
133
+ const backoffMs = 2 ** attempt * 1000;
134
+ const logger = getLogger();
135
+ logger.warn("agent", "Agent spawn error, retrying", {
136
+ error: lastError.message,
137
+ backoffSeconds: backoffMs / 1000,
138
+ attempt,
139
+ maxRetries,
140
+ });
141
+ await Bun.sleep(backoffMs);
142
+ continue;
143
+ }
144
+
145
+ throw lastError;
293
146
  }
294
147
  }
295
- } finally {
296
- clearTimeout(timeoutId);
297
- await pidRegistry.unregister(processPid);
298
- }
299
148
 
300
- // Use a deadline on stdout read if the subprocess pipe is still open
301
- // (e.g. hard-deadline fired but process didn't fully die), don't block forever.
302
- const stdout = await Promise.race([
303
- new Response(proc.stdout).text(),
304
- new Promise<string>((resolve) => setTimeout(() => resolve(""), 5000)),
305
- ]);
306
- const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
307
- const durationMs = Date.now() - startTime;
308
-
309
- // Claude Code emits rate limit messages as part of its output.
310
- const fullOutput = stdout + stderr;
311
- const rateLimited =
312
- fullOutput.toLowerCase().includes("rate limit") ||
313
- fullOutput.includes("429") ||
314
- fullOutput.toLowerCase().includes("too many requests");
315
-
316
- let costEstimate = estimateCostFromOutput(options.modelTier, fullOutput);
317
- const logger = getLogger();
318
- if (!costEstimate) {
319
- const fallbackEstimate = estimateCostByDuration(options.modelTier, durationMs);
320
- costEstimate = {
321
- cost: fallbackEstimate.cost * 1.5,
322
- confidence: "fallback",
323
- };
324
- logger.warn("agent", "Cost estimation fallback (duration-based)", {
325
- modelTier: options.modelTier,
326
- cost: costEstimate.cost,
327
- });
328
- } else if (costEstimate.confidence === "estimated") {
329
- logger.warn("agent", "Cost estimation using regex parsing (estimated confidence)", { cost: costEstimate.cost });
149
+ throw lastError || new Error("Agent execution failed after all retries");
150
+ } finally {
151
+ // Clean up pidRegistry entry for this workdir to prevent unbounded Map growth
152
+ this.pidRegistries.delete(options.workdir);
330
153
  }
331
- const cost = costEstimate.cost;
332
-
333
- const actualExitCode = timedOut ? 124 : exitCode;
334
-
335
- return {
336
- success: exitCode === 0 && !timedOut,
337
- exitCode: actualExitCode,
338
- output: stdout.slice(-MAX_AGENT_OUTPUT_CHARS),
339
- stderr: stderr.slice(-MAX_AGENT_STDERR_CHARS),
340
- rateLimited,
341
- durationMs,
342
- estimatedCost: cost,
343
- pid: processPid,
344
- };
345
154
  }
346
155
 
347
156
  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;
157
+ return executeComplete(this.binary, prompt, options);
383
158
  }
384
159
 
385
160
  async plan(options: PlanOptions): Promise<PlanResult> {
@@ -392,7 +167,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
392
167
 
393
168
  const prompt = buildDecomposePrompt(options);
394
169
 
395
- // Resolve model: explicit modelDef > config.models.balanced > throw
396
170
  let modelDef = options.modelDef;
397
171
  if (!modelDef) {
398
172
  if (!options.config) {
@@ -408,7 +182,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
408
182
  const proc = _decomposeDeps.spawn(cmd, {
409
183
  cwd: options.workdir,
410
184
  stdout: "pipe",
411
- stderr: "inherit", // MEM-3: Inherit stderr to avoid blocking on unread pipe
185
+ stderr: "inherit",
412
186
  env: this.buildAllowedEnv({
413
187
  workdir: options.workdir,
414
188
  modelDef,
@@ -420,30 +194,19 @@ export class ClaudeCodeAdapter implements AgentAdapter {
420
194
 
421
195
  await pidRegistry.register(proc.pid);
422
196
 
423
- // BUG-039: Hard timeout for decompose — prevents infinite hang if claude hangs
424
- const DECOMPOSE_TIMEOUT_MS = 300_000; // 5 minutes
197
+ const DECOMPOSE_TIMEOUT_MS = 300_000;
425
198
  let timedOut = false;
426
- const decomposeTimerId = setTimeout(() => {
427
- timedOut = true;
428
- try {
429
- proc.kill("SIGTERM");
430
- } catch {
431
- /* already exited */
432
- }
433
- setTimeout(() => {
434
- try {
435
- proc.kill("SIGKILL");
436
- } catch {
437
- /* already exited */
438
- }
439
- }, 5000);
440
- }, DECOMPOSE_TIMEOUT_MS);
441
199
 
442
200
  let exitCode: number;
443
201
  try {
444
- exitCode = await proc.exited;
202
+ const timeoutResult = await withProcessTimeout(proc, DECOMPOSE_TIMEOUT_MS, {
203
+ graceMs: 5000,
204
+ onTimeout: () => {
205
+ timedOut = true;
206
+ },
207
+ });
208
+ exitCode = timeoutResult.exitCode;
445
209
  } finally {
446
- clearTimeout(decomposeTimerId);
447
210
  await pidRegistry.unregister(proc.pid);
448
211
  }
449
212
 
@@ -451,12 +214,14 @@ export class ClaudeCodeAdapter implements AgentAdapter {
451
214
  throw new Error(`Decompose timed out after ${DECOMPOSE_TIMEOUT_MS / 1000}s`);
452
215
  }
453
216
 
454
- // Use a deadline on stdout read — if the subprocess pipe is still open
455
- // (e.g. hard-deadline fired but process didn't fully die), don't block forever.
217
+ let stdoutTimeoutId: ReturnType<typeof setTimeout> | undefined;
456
218
  const stdout = await Promise.race([
457
219
  new Response(proc.stdout).text(),
458
- new Promise<string>((resolve) => setTimeout(() => resolve(""), 5000)),
220
+ new Promise<string>((resolve) => {
221
+ stdoutTimeoutId = setTimeout(() => resolve(""), 5000);
222
+ }),
459
223
  ]);
224
+ clearTimeout(stdoutTimeoutId);
460
225
  const stderr = await new Response(proc.stderr).text();
461
226
 
462
227
  if (exitCode !== 0) {
@@ -469,57 +234,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
469
234
  }
470
235
 
471
236
  runInteractive(options: InteractiveRunOptions): PtyHandle {
472
- const model = options.modelDef.model;
473
- const cmd = [this.binary, "--model", model, options.prompt];
474
-
475
- // BUN-001: Replaced node-pty with Bun.spawn (piped stdio).
476
- // runInteractive() is TUI-only and currently dormant in headless nax runs.
477
- // TERM + FORCE_COLOR preserve formatting output from Claude Code.
478
- const proc = Bun.spawn(cmd, {
479
- cwd: options.workdir,
480
- env: { ...this.buildAllowedEnv(options), TERM: "xterm-256color", FORCE_COLOR: "1" },
481
- stdin: "pipe",
482
- stdout: "pipe",
483
- stderr: "inherit", // MEM-3: Inherit stderr to avoid blocking on unread pipe
484
- });
485
-
486
237
  const pidRegistry = this.getPidRegistry(options.workdir);
487
- pidRegistry.register(proc.pid).catch(() => {});
488
-
489
- // Stream stdout to onOutput callback
490
- (async () => {
491
- try {
492
- for await (const chunk of proc.stdout) {
493
- options.onOutput(Buffer.from(chunk));
494
- }
495
- } catch (err) {
496
- // BUG-21: Handle stream errors to avoid unhandled rejections
497
- getLogger()?.error("agent", "runInteractive stdout error", { err });
498
- }
499
- })();
500
-
501
- // Fire onExit when process completes
502
- proc.exited
503
- .then((code) => {
504
- pidRegistry.unregister(proc.pid).catch(() => {});
505
- options.onExit(code ?? 1);
506
- })
507
- .catch((err) => {
508
- // BUG-22: Guard against onExit or unregister throws
509
- getLogger()?.error("agent", "runInteractive exit error", { err });
510
- });
511
-
512
- return {
513
- write: (data: string) => {
514
- proc.stdin.write(data);
515
- },
516
- resize: (_cols: number, _rows: number) => {
517
- /* no-op: Bun.spawn has no PTY resize */
518
- },
519
- kill: () => {
520
- proc.kill();
521
- },
522
- pid: proc.pid,
523
- };
238
+ return runInteractiveMode(this.binary, options, pidRegistry);
524
239
  }
525
240
  }
@@ -12,6 +12,7 @@ import { resolveModel } from "../config/schema";
12
12
  import { getLogger } from "../logger";
13
13
  import type { UserStory } from "../prd";
14
14
  import { classifyComplexity } from "../routing";
15
+ import { errorMessage } from "../utils/errors";
15
16
  import type { ClassificationResult, CodebaseScan, StoryClassification } from "./types";
16
17
 
17
18
  /**
@@ -79,7 +80,7 @@ export async function classifyStories(
79
80
  };
80
81
  } catch (error) {
81
82
  // Fall back to keyword matching
82
- const reason = error instanceof Error ? error.message : String(error);
83
+ const reason = errorMessage(error);
83
84
  const logger = getLogger();
84
85
  logger.warn("analyze", "LLM classification failed, falling back to keyword matching", { error: reason });
85
86