karajan-code 1.3.0 → 1.5.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/README.md CHANGED
@@ -36,7 +36,10 @@ Instead of running one AI agent and manually reviewing its output, `kj` chains a
36
36
  - **Review profiles** — standard, strict, relaxed, paranoid
37
37
  - **Budget tracking** — per-session token and cost monitoring with `--trace`
38
38
  - **Git automation** — auto-commit, auto-push, auto-PR after approval
39
- - **Session management** — pause/resume with fail-fast detection
39
+ - **Session management** — pause/resume with fail-fast detection and automatic cleanup of expired sessions
40
+ - **Plugin system** — extend with custom agents via `.karajan/plugins/`
41
+ - **Smart model selection** — auto-selects optimal model per role based on triage complexity (lighter models for trivial tasks, powerful models for complex ones)
42
+ - **Retry with backoff** — automatic recovery from transient API errors (429, 5xx) with exponential backoff and jitter
40
43
  - **Planning Game integration** — optionally pair with [Planning Game](https://github.com/AgenteIA-Geniova/planning-game) for agile project management (tasks, sprints, estimation) — like Jira, but open-source and XP-native
41
44
 
42
45
  > **Best with MCP** — Karajan Code is designed to be used as an MCP server inside your AI agent (Claude, Codex, etc.). The agent sends tasks to `kj_run`, gets real-time progress notifications, and receives structured results — no copy-pasting needed.
@@ -193,6 +196,8 @@ kj run "Fix the login bug" [options]
193
196
  | `--auto-pr` | Create PR after push |
194
197
  | `--no-auto-rebase` | Disable auto-rebase before push |
195
198
  | `--branch-prefix <prefix>` | Branch naming prefix (default: `feat/`) |
199
+ | `--smart-models` | Enable smart model selection based on triage complexity |
200
+ | `--no-smart-models` | Disable smart model selection |
196
201
  | `--no-sonar` | Skip SonarQube analysis |
197
202
  | `--pg-task <cardId>` | Planning Game card ID for task context |
198
203
  | `--pg-project <projectId>` | Planning Game project ID |
@@ -352,6 +357,16 @@ budget:
352
357
  currency: usd # usd | eur
353
358
  exchange_rate_eur: 0.92
354
359
 
360
+ # Smart model selection (requires --enable-triage)
361
+ model_selection:
362
+ enabled: true # Auto-select models based on triage complexity
363
+ tiers: # Override default tier map per provider
364
+ claude:
365
+ simple: claude/sonnet # Use sonnet even for simple tasks
366
+ role_overrides: # Override level mapping per role
367
+ reviewer:
368
+ trivial: medium # Reviewer always at least medium tier
369
+
355
370
  # Output
356
371
  output:
357
372
  report_dir: ./.reviews
@@ -428,7 +443,7 @@ Use `kj roles show <role>` to inspect any template. Create a project override to
428
443
  git clone https://github.com/manufosela/karajan-code.git
429
444
  cd karajan-code
430
445
  npm install
431
- npm test # Run 761+ tests with Vitest
446
+ npm test # Run 964+ tests with Vitest
432
447
  npm run test:watch # Watch mode
433
448
  npm run validate # Lint + test
434
449
  ```
@@ -439,6 +454,7 @@ npm run validate # Lint + test
439
454
 
440
455
  ## Links
441
456
 
457
+ - [Website](https://karajancode.com) (also [kj-code.com](https://kj-code.com))
442
458
  - [Changelog](CHANGELOG.md)
443
459
  - [Security Policy](SECURITY.md)
444
460
  - [License (AGPL-3.0)](LICENSE)
package/docs/README.es.md CHANGED
@@ -36,7 +36,9 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
36
36
  - **Perfiles de revision** — standard, strict, relaxed, paranoid
37
37
  - **Tracking de presupuesto** — monitorizacion de tokens y costes por sesion con `--trace`
38
38
  - **Automatizacion Git** — auto-commit, auto-push, auto-PR tras aprobacion
39
- - **Gestion de sesiones** — pausa/reanudacion con deteccion fail-fast
39
+ - **Gestion de sesiones** — pausa/reanudacion con deteccion fail-fast y limpieza automatica de sesiones expiradas
40
+ - **Sistema de plugins** — extiende con agentes custom via `.karajan/plugins/`
41
+ - **Retry con backoff** — recuperacion automatica ante errores transitorios de API (429, 5xx) con backoff exponencial y jitter
40
42
  - **Integracion con Planning Game** — combina opcionalmente con [Planning Game](https://github.com/AgenteIA-Geniova/planning-game) para gestion agil de proyectos (tareas, sprints, estimacion) — como Jira, pero open-source y nativo XP
41
43
 
42
44
  > **Mejor con MCP** — Karajan Code esta disenado para usarse como servidor MCP dentro de tu agente de IA (Claude, Codex, etc.). El agente envia tareas a `kj_run`, recibe notificaciones de progreso en tiempo real, y obtiene resultados estructurados — sin copiar y pegar.
@@ -227,7 +229,7 @@ Usa `kj roles show <rol>` para inspeccionar cualquier template. Crea un override
227
229
  git clone https://github.com/manufosela/karajan-code.git
228
230
  cd karajan-code
229
231
  npm install
230
- npm test # Ejecutar 761+ tests con Vitest
232
+ npm test # Ejecutar 899+ tests con Vitest
231
233
  npm run test:watch # Modo watch
232
234
  npm run validate # Lint + test
233
235
  ```
@@ -238,6 +240,7 @@ npm run validate # Lint + test
238
240
 
239
241
  ## Enlaces
240
242
 
243
+ - [Web](https://karajancode.com) (tambien [kj-code.com](https://kj-code.com))
241
244
  - [Changelog](../CHANGELOG.md)
242
245
  - [Politica de seguridad](../SECURITY.md)
243
246
  - [Licencia (AGPL-3.0)](../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
package/src/cli.js CHANGED
@@ -24,7 +24,7 @@ async function withConfig(commandName, flags, fn) {
24
24
  }
25
25
 
26
26
  const program = new Command();
27
- program.name("kj").description("Karajan Code CLI").version("1.2.1");
27
+ program.name("kj").description("Karajan Code CLI").version("1.5.0");
28
28
 
29
29
  program
30
30
  .command("init")
@@ -71,6 +71,7 @@ program
71
71
  .option("--max-total-minutes <n>")
72
72
  .option("--base-branch <name>")
73
73
  .option("--base-ref <ref>")
74
+ .option("--coder-fallback <name>")
74
75
  .option("--reviewer-fallback <name>")
75
76
  .option("--reviewer-retries <n>")
76
77
  .option("--auto-commit")
@@ -82,6 +83,8 @@ program
82
83
  .option("--no-sonar")
83
84
  .option("--pg-task <cardId>", "Planning Game card ID (e.g., KJC-TSK-0042)")
84
85
  .option("--pg-project <projectId>", "Planning Game project ID")
86
+ .option("--smart-models", "Enable smart model selection based on triage complexity")
87
+ .option("--no-smart-models", "Disable smart model selection")
85
88
  .option("--dry-run", "Show what would be executed without running anything")
86
89
  .option("--json", "Output JSON only (no styled display)")
87
90
  .action(async (task, flags) => {
package/src/config.js CHANGED
@@ -33,7 +33,7 @@ const DEFAULTS = {
33
33
  review_rules: "./review-rules.md",
34
34
  coder_rules: "./coder-rules.md",
35
35
  base_branch: "main",
36
- coder_options: { model: null, auto_approve: true },
36
+ coder_options: { model: null, auto_approve: true, fallback_coder: null },
37
37
  reviewer_options: {
38
38
  output_format: "json",
39
39
  require_schema: true,
@@ -106,6 +106,11 @@ const DEFAULTS = {
106
106
  currency: "usd",
107
107
  exchange_rate_eur: 0.92
108
108
  },
109
+ model_selection: {
110
+ enabled: true,
111
+ tiers: {},
112
+ role_overrides: {}
113
+ },
109
114
  session: {
110
115
  max_iteration_minutes: 15,
111
116
  max_total_minutes: 120,
@@ -240,6 +245,7 @@ export function applyRunOverrides(config, flags) {
240
245
  if (flags.maxIterationMinutes) out.session.max_iteration_minutes = Number(flags.maxIterationMinutes);
241
246
  if (flags.maxTotalMinutes) out.session.max_total_minutes = Number(flags.maxTotalMinutes);
242
247
  if (flags.baseBranch) out.base_branch = flags.baseBranch;
248
+ if (flags.coderFallback) out.coder_options.fallback_coder = flags.coderFallback;
243
249
  if (flags.reviewerFallback) out.reviewer_options.fallback_reviewer = flags.reviewerFallback;
244
250
  if (flags.reviewerRetries !== undefined) out.reviewer_options.retries = Number(flags.reviewerRetries);
245
251
  if (flags.autoCommit !== undefined) out.git.auto_commit = Boolean(flags.autoCommit);
@@ -259,6 +265,9 @@ export function applyRunOverrides(config, flags) {
259
265
  out.planning_game = out.planning_game || {};
260
266
  if (flags.pgTask) out.planning_game.enabled = true;
261
267
  if (flags.pgProject) out.planning_game.project_id = flags.pgProject;
268
+ out.model_selection = out.model_selection || { enabled: true, tiers: {}, role_overrides: {} };
269
+ if (flags.smartModels === true) out.model_selection.enabled = true;
270
+ if (flags.smartModels === false || flags.noSmartModels === true) out.model_selection.enabled = false;
262
271
  return out;
263
272
  }
264
273
 
package/src/mcp/run-kj.js CHANGED
@@ -49,6 +49,8 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
49
49
  normalizeBoolFlag(options.autoPr, "--auto-pr", args);
50
50
  if (options.autoRebase === false) args.push("--no-auto-rebase");
51
51
  normalizeBoolFlag(options.noSonar, "--no-sonar", args);
52
+ if (options.smartModels === true) args.push("--smart-models");
53
+ if (options.smartModels === false) args.push("--no-smart-models");
52
54
  addOptionalValue(args, "--pg-task", options.pgTask);
53
55
  addOptionalValue(args, "--pg-project", options.pgProject);
54
56
 
package/src/mcp/tools.js CHANGED
@@ -85,6 +85,7 @@ export const tools = [
85
85
  autoPr: { type: "boolean" },
86
86
  autoRebase: { type: "boolean" },
87
87
  branchPrefix: { type: "string" },
88
+ smartModels: { type: "boolean", description: "Enable/disable smart model selection based on triage complexity" },
88
89
  noSonar: { type: "boolean" },
89
90
  kjHome: { type: "string" },
90
91
  sonarToken: { type: "string" },
@@ -0,0 +1,83 @@
1
+ import { createAgent } from "../agents/index.js";
2
+ import { addCheckpoint } from "../session-store.js";
3
+ import { detectRateLimit } from "../utils/rate-limit-detector.js";
4
+
5
+ /**
6
+ * Run a coder-like role with fallback on rate limit.
7
+ * Tries the primary agent first. If it fails with a rate limit,
8
+ * switches to the fallback agent (if configured).
9
+ * Non-rate-limit failures stop immediately (no fallback).
10
+ *
11
+ * Returns { execResult, attempts, allRateLimited }
12
+ */
13
+ export async function runCoderWithFallback({
14
+ coderName,
15
+ fallbackCoder,
16
+ config,
17
+ logger,
18
+ emitter,
19
+ RoleClass,
20
+ roleInput,
21
+ session,
22
+ iteration,
23
+ onAttemptResult
24
+ }) {
25
+ const candidates = [coderName];
26
+ if (fallbackCoder && fallbackCoder !== coderName) {
27
+ candidates.push(fallbackCoder);
28
+ }
29
+
30
+ const attempts = [];
31
+ let allRateLimited = true;
32
+
33
+ for (const name of candidates) {
34
+ const agentConfig = {
35
+ ...config,
36
+ roles: { ...config.roles, coder: { ...config.roles?.coder, provider: name } }
37
+ };
38
+
39
+ const role = new RoleClass({ config: agentConfig, logger, emitter, createAgentFn: createAgent });
40
+ await role.init();
41
+
42
+ const execResult = await role.execute(roleInput);
43
+
44
+ if (onAttemptResult) {
45
+ await onAttemptResult({ coder: name, result: execResult.result });
46
+ }
47
+
48
+ const rateLimited = !execResult.ok && detectRateLimit({
49
+ stderr: execResult.result?.error || "",
50
+ stdout: execResult.result?.output || ""
51
+ }).isRateLimit;
52
+
53
+ attempts.push({
54
+ coder: name,
55
+ ok: execResult.ok,
56
+ rateLimited,
57
+ result: execResult.result,
58
+ execResult
59
+ });
60
+
61
+ await addCheckpoint(session, {
62
+ stage: "coder-attempt",
63
+ iteration,
64
+ coder: name,
65
+ ok: execResult.ok,
66
+ rateLimited
67
+ });
68
+
69
+ if (execResult.ok) {
70
+ return { execResult, attempts, allRateLimited: false };
71
+ }
72
+
73
+ // Only fallback on rate limit errors
74
+ if (!rateLimited) {
75
+ allRateLimited = false;
76
+ return { execResult: null, attempts, allRateLimited: false };
77
+ }
78
+
79
+ logger.warn(`Agent ${name} hit rate limit, trying fallback...`);
80
+ }
81
+
82
+ return { execResult: null, attempts, allRateLimited };
83
+ }
@@ -1,4 +1,5 @@
1
1
  import { createAgent } from "../agents/index.js";
2
+ import { CoderRole } from "../roles/coder-role.js";
2
3
  import { RefactorerRole } from "../roles/refactorer-role.js";
3
4
  import { SonarRole } from "../roles/sonar-role.js";
4
5
  import { addCheckpoint, markSessionStatus, saveSession, pauseSession } from "../session-store.js";
@@ -7,7 +8,9 @@ import { evaluateTddPolicy } from "../review/tdd-policy.js";
7
8
  import { validateReviewResult } from "../review/schema.js";
8
9
  import { emitProgress, makeEvent } from "../utils/events.js";
9
10
  import { runReviewerWithFallback } from "./reviewer-fallback.js";
11
+ import { runCoderWithFallback } from "./agent-fallback.js";
10
12
  import { invokeSolomon } from "./solomon-escalation.js";
13
+ import { detectRateLimit } from "../utils/rate-limit-detector.js";
11
14
 
12
15
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
13
16
  logger.setContext({ iteration, stage: "coder" });
@@ -35,8 +38,70 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
35
38
  trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
36
39
 
37
40
  if (!coderExecResult.ok) {
38
- await markSessionStatus(session, "failed");
39
41
  const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
42
+ const rateLimitCheck = detectRateLimit({
43
+ stderr: coderExecResult.result?.error || "",
44
+ stdout: coderExecResult.result?.output || ""
45
+ });
46
+
47
+ if (rateLimitCheck.isRateLimit) {
48
+ // Try fallback agent if configured
49
+ const fallbackCoder = config.coder_options?.fallback_coder;
50
+ if (fallbackCoder && fallbackCoder !== coderRole.provider) {
51
+ logger.warn(`Coder ${coderRole.provider} hit rate limit, falling back to ${fallbackCoder}`);
52
+ emitProgress(
53
+ emitter,
54
+ makeEvent("coder:fallback", { ...eventBase, stage: "coder" }, {
55
+ message: `Coder ${coderRole.provider} rate-limited, switching to ${fallbackCoder}`,
56
+ detail: { primary: coderRole.provider, fallback: fallbackCoder }
57
+ })
58
+ );
59
+
60
+ const fallbackResult = await runCoderWithFallback({
61
+ coderName: fallbackCoder,
62
+ fallbackCoder: null,
63
+ config,
64
+ logger,
65
+ emitter,
66
+ RoleClass: CoderRole,
67
+ roleInput: { task: plannedTask, reviewerFeedback: session.last_reviewer_feedback, sonarSummary: session.last_sonar_summary, onOutput: coderOnOutput },
68
+ session,
69
+ iteration,
70
+ onAttemptResult: ({ coder, result }) => {
71
+ trackBudget({ role: "coder", provider: coder, model: coderRole.model, result, duration_ms: Date.now() - coderStart });
72
+ }
73
+ });
74
+
75
+ if (fallbackResult.execResult?.ok) {
76
+ await addCheckpoint(session, { stage: "coder", iteration, note: `Coder completed via fallback (${fallbackCoder})` });
77
+ emitProgress(
78
+ emitter,
79
+ makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
80
+ message: `Coder completed (fallback: ${fallbackCoder})`
81
+ })
82
+ );
83
+ return;
84
+ }
85
+ }
86
+
87
+ // No fallback or fallback also failed — pause
88
+ const question = `Agent ${coderRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
89
+ await pauseSession(session, {
90
+ question,
91
+ context: { iteration, stage: "coder", reason: "rate_limit", agent: coderRole.provider, detail: rateLimitCheck.message }
92
+ });
93
+ emitProgress(
94
+ emitter,
95
+ makeEvent("coder:rate_limit", { ...eventBase, stage: "coder" }, {
96
+ status: "paused",
97
+ message: question,
98
+ detail: { agent: coderRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
99
+ })
100
+ );
101
+ return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
102
+ }
103
+
104
+ await markSessionStatus(session, "failed");
40
105
  emitProgress(
41
106
  emitter,
42
107
  makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
@@ -71,8 +136,30 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
71
136
  const refResult = await refRole.execute(plannedTask);
72
137
  trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
73
138
  if (!refResult.ok) {
74
- await markSessionStatus(session, "failed");
75
139
  const details = refResult.result?.error || refResult.summary || "unknown error";
140
+ const rateLimitCheck = detectRateLimit({
141
+ stderr: refResult.result?.error || "",
142
+ stdout: refResult.result?.output || ""
143
+ });
144
+
145
+ if (rateLimitCheck.isRateLimit) {
146
+ const question = `Agent ${refactorerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
147
+ await pauseSession(session, {
148
+ question,
149
+ context: { iteration, stage: "refactorer", reason: "rate_limit", agent: refactorerRole.provider, detail: rateLimitCheck.message }
150
+ });
151
+ emitProgress(
152
+ emitter,
153
+ makeEvent("refactorer:rate_limit", { ...eventBase, stage: "refactorer" }, {
154
+ status: "paused",
155
+ message: question,
156
+ detail: { agent: refactorerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
157
+ })
158
+ );
159
+ return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
160
+ }
161
+
162
+ await markSessionStatus(session, "failed");
76
163
  emitProgress(
77
164
  emitter,
78
165
  makeEvent("refactorer:end", { ...eventBase, stage: "refactorer" }, {
@@ -318,12 +405,35 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
318
405
  });
319
406
 
320
407
  if (!reviewerExec.execResult || !reviewerExec.execResult.ok) {
321
- await markSessionStatus(session, "failed");
322
408
  const lastAttempt = reviewerExec.attempts.at(-1);
323
409
  const details =
324
410
  lastAttempt?.result?.error ||
325
411
  lastAttempt?.execResult?.summary ||
326
412
  `reviewer=${lastAttempt?.reviewer || "unknown"}`;
413
+
414
+ const rateLimitCheck = detectRateLimit({
415
+ stderr: lastAttempt?.result?.error || "",
416
+ stdout: lastAttempt?.result?.output || ""
417
+ });
418
+
419
+ if (rateLimitCheck.isRateLimit) {
420
+ const question = `Reviewer ${reviewerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
421
+ await pauseSession(session, {
422
+ question,
423
+ context: { iteration, stage: "reviewer", reason: "rate_limit", agent: reviewerRole.provider, detail: rateLimitCheck.message }
424
+ });
425
+ emitProgress(
426
+ emitter,
427
+ makeEvent("reviewer:rate_limit", { ...eventBase, stage: "reviewer" }, {
428
+ status: "paused",
429
+ message: question,
430
+ detail: { agent: reviewerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
431
+ })
432
+ );
433
+ return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
434
+ }
435
+
436
+ await markSessionStatus(session, "failed");
327
437
  emitProgress(
328
438
  emitter,
329
439
  makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
@@ -5,6 +5,7 @@ import { createAgent } from "../agents/index.js";
5
5
  import { addCheckpoint, markSessionStatus } from "../session-store.js";
6
6
  import { emitProgress, makeEvent } from "../utils/events.js";
7
7
  import { parsePlannerOutput } from "../prompts/planner.js";
8
+ import { selectModelsForRoles } from "../utils/model-selector.js";
8
9
 
9
10
  export async function runTriageStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
10
11
  logger.setContext({ iteration: 0, stage: "triage" });
@@ -47,6 +48,31 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
47
48
  reasoning: triageOutput.result?.reasoning || null
48
49
  };
49
50
 
51
+ let modelSelection = null;
52
+ if (triageOutput.ok && config?.model_selection?.enabled) {
53
+ const level = triageOutput.result?.level;
54
+ if (level) {
55
+ const { modelOverrides, reasoning } = selectModelsForRoles({ level, config });
56
+ for (const [role, model] of Object.entries(modelOverrides)) {
57
+ if (config.roles?.[role] && !config.roles[role].model) {
58
+ config.roles[role].model = model;
59
+ }
60
+ }
61
+ modelSelection = { modelOverrides, reasoning };
62
+ emitProgress(
63
+ emitter,
64
+ makeEvent("model-selection:applied", { ...eventBase, stage: "triage" }, {
65
+ message: "Smart model selection applied",
66
+ detail: modelSelection
67
+ })
68
+ );
69
+ }
70
+ }
71
+
72
+ if (modelSelection) {
73
+ stageResult.modelSelection = modelSelection;
74
+ }
75
+
50
76
  emitProgress(
51
77
  emitter,
52
78
  makeEvent("triage:end", { ...eventBase, stage: "triage" }, {
@@ -255,11 +255,17 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
255
255
  logger.info(`Iteration ${i}/${config.max_iterations}`);
256
256
 
257
257
  // --- Coder ---
258
- await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
258
+ const coderResult = await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
259
+ if (coderResult?.action === "pause") {
260
+ return coderResult.result;
261
+ }
259
262
 
260
263
  // --- Refactorer ---
261
264
  if (refactorerEnabled) {
262
- await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
265
+ const refResult = await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
266
+ if (refResult?.action === "pause") {
267
+ return refResult.result;
268
+ }
263
269
  }
264
270
 
265
271
  // --- TDD Policy ---
@@ -302,6 +308,9 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
302
308
  reviewerRole, config, logger, emitter, eventBase, session, trackBudget,
303
309
  iteration: i, reviewRules, task, repeatDetector, budgetSummary
304
310
  });
311
+ if (reviewerResult.action === "pause") {
312
+ return reviewerResult.result;
313
+ }
305
314
  review = reviewerResult.review;
306
315
  if (reviewerResult.stalled) {
307
316
  return reviewerResult.stalledResult;
@@ -0,0 +1,107 @@
1
+ const DEFAULT_MODEL_TIERS = {
2
+ claude: { trivial: "claude/haiku", simple: "claude/haiku", medium: "claude/sonnet", complex: "claude/opus" },
3
+ codex: { trivial: "codex/o4-mini", simple: "codex/o4-mini", medium: "codex/o4-mini", complex: "codex/o3" },
4
+ gemini: { trivial: "gemini/flash", simple: "gemini/flash", medium: "gemini/pro", complex: "gemini/pro" },
5
+ aider: { trivial: null, simple: null, medium: null, complex: null }
6
+ };
7
+
8
+ const DEFAULT_ROLE_OVERRIDES = {
9
+ reviewer: { trivial: "medium", simple: "medium" },
10
+ triage: { medium: "simple", complex: "simple" }
11
+ };
12
+
13
+ const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
14
+
15
+ export function getDefaultModelTiers() {
16
+ return JSON.parse(JSON.stringify(DEFAULT_MODEL_TIERS));
17
+ }
18
+
19
+ export function getDefaultRoleOverrides() {
20
+ return JSON.parse(JSON.stringify(DEFAULT_ROLE_OVERRIDES));
21
+ }
22
+
23
+ export function resolveModelForRole({ role, provider, level, tierMap, roleOverrides }) {
24
+ if (!provider || !level || !VALID_LEVELS.has(level)) return null;
25
+
26
+ const tiers = tierMap || DEFAULT_MODEL_TIERS;
27
+ const providerTiers = tiers[provider];
28
+ if (!providerTiers) return null;
29
+
30
+ const overrides = roleOverrides || DEFAULT_ROLE_OVERRIDES;
31
+ const roleOvr = overrides[role];
32
+
33
+ let effectiveLevel = level;
34
+ if (roleOvr && roleOvr[level]) {
35
+ const mappedLevel = roleOvr[level];
36
+ if (VALID_LEVELS.has(mappedLevel)) {
37
+ effectiveLevel = mappedLevel;
38
+ }
39
+ }
40
+
41
+ return providerTiers[effectiveLevel] || null;
42
+ }
43
+
44
+ export function selectModelsForRoles({ level, config, roles }) {
45
+ if (!level || !VALID_LEVELS.has(level)) {
46
+ return { modelOverrides: {}, reasoning: "No valid triage level provided" };
47
+ }
48
+
49
+ const modelSelection = config?.model_selection || {};
50
+ const userTiers = modelSelection.tiers || {};
51
+ const userRoleOverrides = modelSelection.role_overrides || {};
52
+
53
+ const mergedTiers = { ...getDefaultModelTiers() };
54
+ for (const [provider, levels] of Object.entries(userTiers)) {
55
+ mergedTiers[provider] = { ...(mergedTiers[provider] || {}), ...levels };
56
+ }
57
+
58
+ const mergedRoleOverrides = { ...getDefaultRoleOverrides() };
59
+ for (const [role, levels] of Object.entries(userRoleOverrides)) {
60
+ mergedRoleOverrides[role] = { ...(mergedRoleOverrides[role] || {}), ...levels };
61
+ }
62
+
63
+ const allRoles = roles || Object.keys(config?.roles || {});
64
+ const modelOverrides = {};
65
+ const details = [];
66
+
67
+ for (const role of allRoles) {
68
+ const roleConfig = config?.roles?.[role];
69
+ if (!roleConfig) continue;
70
+
71
+ if (roleConfig.model) {
72
+ details.push(`${role}: skipped (explicit model "${roleConfig.model}")`);
73
+ continue;
74
+ }
75
+
76
+ if (roleConfig.disabled) {
77
+ details.push(`${role}: skipped (disabled)`);
78
+ continue;
79
+ }
80
+
81
+ const provider = roleConfig.provider;
82
+ if (!provider) {
83
+ details.push(`${role}: skipped (no provider)`);
84
+ continue;
85
+ }
86
+
87
+ const model = resolveModelForRole({
88
+ role,
89
+ provider,
90
+ level,
91
+ tierMap: mergedTiers,
92
+ roleOverrides: mergedRoleOverrides
93
+ });
94
+
95
+ if (model) {
96
+ modelOverrides[role] = model;
97
+ details.push(`${role}: ${model} (level=${level}, provider=${provider})`);
98
+ } else {
99
+ details.push(`${role}: no model for provider "${provider}"`);
100
+ }
101
+ }
102
+
103
+ return {
104
+ modelOverrides,
105
+ reasoning: `Smart model selection (level=${level}): ${details.join("; ")}`
106
+ };
107
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Detects rate limit / usage cap messages from CLI agent output.
3
+ * Returns { isRateLimit, agent, message } where agent is the best guess
4
+ * of which CLI triggered it (or "unknown").
5
+ */
6
+
7
+ const RATE_LIMIT_PATTERNS = [
8
+ // Claude CLI
9
+ { pattern: /usage limit/i, agent: "claude" },
10
+ { pattern: /plan's usage limit/i, agent: "claude" },
11
+ { pattern: /Claude Pro usage limit/i, agent: "claude" },
12
+
13
+ // OpenAI / Codex CLI
14
+ { pattern: /exceeded your current quota/i, agent: "codex" },
15
+
16
+ // Gemini CLI
17
+ { pattern: /resource exhausted/i, agent: "gemini" },
18
+ { pattern: /quota exceeded/i, agent: "gemini" },
19
+
20
+ // Generic (match any agent)
21
+ { pattern: /rate limit/i, agent: "unknown" },
22
+ { pattern: /token limit reached/i, agent: "unknown" },
23
+ { pattern: /\b429\b/, agent: "unknown" },
24
+ { pattern: /too many requests/i, agent: "unknown" },
25
+ { pattern: /throttl/i, agent: "unknown" },
26
+ ];
27
+
28
+ export function detectRateLimit({ stderr = "", stdout = "" }) {
29
+ const combined = `${stderr}\n${stdout}`;
30
+
31
+ for (const { pattern, agent } of RATE_LIMIT_PATTERNS) {
32
+ if (pattern.test(combined)) {
33
+ const matchedLine = combined.split("\n").find((l) => pattern.test(l)) || combined.trim();
34
+ return {
35
+ isRateLimit: true,
36
+ agent,
37
+ message: matchedLine.trim()
38
+ };
39
+ }
40
+ }
41
+
42
+ return { isRateLimit: false, agent: "", message: "" };
43
+ }