karajan-code 1.4.0 → 1.6.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
@@ -38,6 +38,9 @@ Instead of running one AI agent and manually reviewing its output, `kj` chains a
38
38
  - **Git automation** — auto-commit, auto-push, auto-PR after approval
39
39
  - **Session management** — pause/resume with fail-fast detection and automatic cleanup of expired sessions
40
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
+ - **Interactive checkpoints** — instead of killing long-running tasks, pauses every 5 minutes with a progress report and lets you decide: continue, stop, or adjust the time
43
+ - **Task decomposition** — triage detects when tasks should be split and recommends subtasks; with Planning Game integration, creates linked cards with sequential blocking
41
44
  - **Retry with backoff** — automatic recovery from transient API errors (429, 5xx) with exponential backoff and jitter
42
45
  - **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
43
46
 
@@ -195,7 +198,10 @@ kj run "Fix the login bug" [options]
195
198
  | `--auto-pr` | Create PR after push |
196
199
  | `--no-auto-rebase` | Disable auto-rebase before push |
197
200
  | `--branch-prefix <prefix>` | Branch naming prefix (default: `feat/`) |
201
+ | `--smart-models` | Enable smart model selection based on triage complexity |
202
+ | `--no-smart-models` | Disable smart model selection |
198
203
  | `--no-sonar` | Skip SonarQube analysis |
204
+ | `--checkpoint-interval <n>` | Minutes between interactive checkpoints (default: 5) |
199
205
  | `--pg-task <cardId>` | Planning Game card ID for task context |
200
206
  | `--pg-project <projectId>` | Planning Game project ID |
201
207
  | `--dry-run` | Show what would run without executing |
@@ -346,6 +352,7 @@ git:
346
352
  session:
347
353
  max_iteration_minutes: 15
348
354
  max_total_minutes: 120
355
+ checkpoint_interval_minutes: 5 # Interactive checkpoint every N minutes
349
356
  max_budget_usd: null # null = unlimited
350
357
  fail_fast_repeats: 2
351
358
 
@@ -354,6 +361,16 @@ budget:
354
361
  currency: usd # usd | eur
355
362
  exchange_rate_eur: 0.92
356
363
 
364
+ # Smart model selection (requires --enable-triage)
365
+ model_selection:
366
+ enabled: true # Auto-select models based on triage complexity
367
+ tiers: # Override default tier map per provider
368
+ claude:
369
+ simple: claude/sonnet # Use sonnet even for simple tasks
370
+ role_overrides: # Override level mapping per role
371
+ reviewer:
372
+ trivial: medium # Reviewer always at least medium tier
373
+
357
374
  # Output
358
375
  output:
359
376
  report_dir: ./.reviews
@@ -430,7 +447,7 @@ Use `kj roles show <role>` to inspect any template. Create a project override to
430
447
  git clone https://github.com/manufosela/karajan-code.git
431
448
  cd karajan-code
432
449
  npm install
433
- npm test # Run 899+ tests with Vitest
450
+ npm test # Run 1025+ tests with Vitest
434
451
  npm run test:watch # Watch mode
435
452
  npm run validate # Lint + test
436
453
  ```
package/docs/README.es.md CHANGED
@@ -38,6 +38,8 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
38
38
  - **Automatizacion Git** — auto-commit, auto-push, auto-PR tras aprobacion
39
39
  - **Gestion de sesiones** — pausa/reanudacion con deteccion fail-fast y limpieza automatica de sesiones expiradas
40
40
  - **Sistema de plugins** — extiende con agentes custom via `.karajan/plugins/`
41
+ - **Checkpoints interactivos** — en lugar de matar tareas largas, pausa cada 5 minutos con un informe de progreso y te deja decidir: continuar, parar o ajustar el tiempo
42
+ - **Descomposicion de tareas** — triage detecta cuando una tarea debe dividirse y recomienda subtareas; con integracion Planning Game, crea cards vinculadas con bloqueo secuencial
41
43
  - **Retry con backoff** — recuperacion automatica ante errores transitorios de API (429, 5xx) con backoff exponencial y jitter
42
44
  - **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
43
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.4.0",
3
+ "version": "1.6.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.6.0");
28
28
 
29
29
  program
30
30
  .command("init")
@@ -81,8 +81,11 @@ program
81
81
  .option("--methodology <name>")
82
82
  .option("--no-auto-rebase")
83
83
  .option("--no-sonar")
84
+ .option("--checkpoint-interval <n>", "Minutes between interactive checkpoints (default: 5)")
84
85
  .option("--pg-task <cardId>", "Planning Game card ID (e.g., KJC-TSK-0042)")
85
86
  .option("--pg-project <projectId>", "Planning Game project ID")
87
+ .option("--smart-models", "Enable smart model selection based on triage complexity")
88
+ .option("--no-smart-models", "Disable smart model selection")
86
89
  .option("--dry-run", "Show what would be executed without running anything")
87
90
  .option("--json", "Output JSON only (no styled display)")
88
91
  .action(async (task, flags) => {
@@ -138,6 +141,7 @@ program
138
141
  .option("--format <type>", "Output format: text|json", "text")
139
142
  .option("--trace", "Show chronological trace of all pipeline stages")
140
143
  .option("--currency <code>", "Display costs in currency: usd|eur", "usd")
144
+ .option("--pg-task <cardId>", "Filter reports by Planning Game card ID")
141
145
  .action(async (flags) => {
142
146
  await reportCommand(flags);
143
147
  });
@@ -135,7 +135,7 @@ async function buildReport(dir, sessionId) {
135
135
 
136
136
  const budgetTrace = Array.isArray(session.budget?.trace) ? session.budget.trace : [];
137
137
 
138
- return {
138
+ const report = {
139
139
  session_id: session.id,
140
140
  task_description: session.task || "",
141
141
  plan_executed: summarizePlan(checkpoints),
@@ -150,6 +150,9 @@ async function buildReport(dir, sessionId) {
150
150
  commits_generated: commits,
151
151
  status: session.status || "unknown"
152
152
  };
153
+ if (session.pg_task_id) report.pg_task_id = session.pg_task_id;
154
+ if (session.pg_project_id) report.pg_project_id = session.pg_project_id;
155
+ return report;
153
156
  }
154
157
 
155
158
  function printTextReport(report) {
@@ -178,6 +181,10 @@ function printTextReport(report) {
178
181
  : String(report.commits_generated.count);
179
182
 
180
183
  console.log(`Session: ${report.session_id}`);
184
+ if (report.pg_task_id) {
185
+ const projectLabel = report.pg_project_id ? ` (${report.pg_project_id})` : "";
186
+ console.log(`Planning Game Card: ${report.pg_task_id}${projectLabel}`);
187
+ }
181
188
  console.log(`Status: ${report.status}`);
182
189
  console.log("Task Description:");
183
190
  console.log(report.task_description || "N/A");
@@ -233,13 +240,14 @@ function printTraceTable(trace, { currency = "usd", exchangeRate = 0.92 } = {})
233
240
 
234
241
  const currencyLabel = currency.toUpperCase();
235
242
 
236
- const headers = ["#", "Stage", "Provider", "Duration", "Tokens In", "Tokens Out", `Cost ${currencyLabel}`];
243
+ const headers = ["#", "Stage", "Provider", "Model", "Duration", "Tokens In", "Tokens Out", `Cost ${currencyLabel}`];
237
244
  const rows = trace.map((entry) => {
238
245
  const cost = convertCost(entry.cost_usd, currency, exchangeRate);
239
246
  return [
240
247
  String(entry.index ?? "-"),
241
248
  entry.role,
242
249
  entry.provider || "-",
250
+ entry.model || "-",
243
251
  formatDuration(entry.duration_ms),
244
252
  String(entry.tokens_in),
245
253
  String(entry.tokens_out),
@@ -262,6 +270,7 @@ function printTraceTable(trace, { currency = "usd", exchangeRate = 0.92 } = {})
262
270
  "",
263
271
  "TOTAL",
264
272
  "",
273
+ "",
265
274
  formatDuration(totals.duration),
266
275
  String(totals.tokens_in),
267
276
  String(totals.tokens_out),
@@ -273,7 +282,7 @@ function printTraceTable(trace, { currency = "usd", exchangeRate = 0.92 } = {})
273
282
  Math.max(...allRows.map((row) => String(row[colIdx]).length))
274
283
  );
275
284
 
276
- const rightAligned = new Set([0, 3, 4, 5, 6]);
285
+ const rightAligned = new Set([0, 4, 5, 6, 7]);
277
286
  function formatRow(row) {
278
287
  return row
279
288
  .map((cell, idx) =>
@@ -293,7 +302,26 @@ function printTraceTable(trace, { currency = "usd", exchangeRate = 0.92 } = {})
293
302
  console.log(formatRow(totalRow));
294
303
  }
295
304
 
296
- export async function reportCommand({ list = false, sessionId = null, format = "text", trace = false, currency = "usd" }) {
305
+ async function findSessionsByPgTask(dir, pgTask) {
306
+ const entries = await fs.readdir(dir);
307
+ const matches = [];
308
+ for (const entry of entries) {
309
+ const sessionFile = path.join(dir, entry, "session.json");
310
+ if (!(await exists(sessionFile))) continue;
311
+ try {
312
+ const raw = await fs.readFile(sessionFile, "utf8");
313
+ const session = JSON.parse(raw);
314
+ if (session.pg_task_id === pgTask) {
315
+ matches.push(entry);
316
+ }
317
+ } catch {
318
+ // skip malformed sessions
319
+ }
320
+ }
321
+ return matches.sort();
322
+ }
323
+
324
+ export async function reportCommand({ list = false, sessionId = null, format = "text", trace = false, currency = "usd", pgTask = null }) {
297
325
  const dir = getSessionRoot();
298
326
  if (!(await exists(dir))) {
299
327
  console.log("No reports yet");
@@ -301,6 +329,45 @@ export async function reportCommand({ list = false, sessionId = null, format = "
301
329
  }
302
330
 
303
331
  const entries = await fs.readdir(dir);
332
+
333
+ if (pgTask) {
334
+ const matches = await findSessionsByPgTask(dir, pgTask);
335
+ if (matches.length === 0) {
336
+ console.log(`No sessions found for card: ${pgTask}`);
337
+ return;
338
+ }
339
+ if (list) {
340
+ for (const item of matches) console.log(item);
341
+ return;
342
+ }
343
+ // Show all reports for this card, or the latest if no list flag
344
+ const targetId = sessionId || matches.at(-1);
345
+ const report = await buildReport(dir, targetId);
346
+ if (format === "json") {
347
+ console.log(JSON.stringify({ card: pgTask, sessions: matches, report }, null, 2));
348
+ return;
349
+ }
350
+ console.log(`Card ${pgTask}: ${matches.length} session${matches.length === 1 ? "" : "s"}`);
351
+ for (const m of matches) {
352
+ const marker = m === targetId ? " <--" : "";
353
+ console.log(` ${m}${marker}`);
354
+ }
355
+ console.log("");
356
+ if (trace) {
357
+ const { config } = await loadConfig();
358
+ const cur = currency?.toLowerCase() || config?.budget?.currency || "usd";
359
+ const rate = config?.budget?.exchange_rate_eur ?? 0.92;
360
+ console.log(`Session: ${report.session_id}`);
361
+ console.log(`Status: ${report.status}`);
362
+ console.log(`Task: ${report.task_description || "N/A"}`);
363
+ console.log("");
364
+ printTraceTable(report.budget_trace, { currency: cur, exchangeRate: rate });
365
+ } else {
366
+ printTextReport(report);
367
+ }
368
+ return;
369
+ }
370
+
304
371
  if (list) {
305
372
  for (const item of entries) console.log(item);
306
373
  return;
@@ -327,6 +394,10 @@ export async function reportCommand({ list = false, sessionId = null, format = "
327
394
  const cur = currency?.toLowerCase() || config?.budget?.currency || "usd";
328
395
  const rate = config?.budget?.exchange_rate_eur ?? 0.92;
329
396
  console.log(`Session: ${report.session_id}`);
397
+ if (report.pg_task_id) {
398
+ const projectLabel = report.pg_project_id ? ` (${report.pg_project_id})` : "";
399
+ console.log(`Planning Game Card: ${report.pg_task_id}${projectLabel}`);
400
+ }
330
401
  console.log(`Status: ${report.status}`);
331
402
  console.log(`Task: ${report.task_description || "N/A"}`);
332
403
  console.log("");
@@ -337,4 +408,4 @@ export async function reportCommand({ list = false, sessionId = null, format = "
337
408
  printTextReport(report);
338
409
  }
339
410
 
340
- export { formatDuration, convertCost, formatCost, printTraceTable, buildReport };
411
+ export { formatDuration, convertCost, formatCost, printTraceTable, buildReport, findSessionsByPgTask };
@@ -66,7 +66,7 @@ export async function runCommandHandler({ task, config, logger, flags }) {
66
66
  }
67
67
 
68
68
  const startDate = new Date().toISOString();
69
- const result = await runFlow({ task: enrichedTask, config, logger, flags, emitter });
69
+ const result = await runFlow({ task: enrichedTask, config, logger, flags, emitter, pgTaskId: pgCardId || null, pgProject: pgProject || null });
70
70
 
71
71
  // --- Planning Game: update card on completion ---
72
72
  if (pgCard && pgProject && result?.approved) {
package/src/config.js CHANGED
@@ -106,9 +106,15 @@ 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
- max_iteration_minutes: 15,
115
+ max_iteration_minutes: 30,
111
116
  max_total_minutes: 120,
117
+ checkpoint_interval_minutes: 5,
112
118
  fail_fast_repeats: 2,
113
119
  repeat_detection_threshold: 2,
114
120
  max_sonar_retries: 3,
@@ -239,6 +245,7 @@ export function applyRunOverrides(config, flags) {
239
245
  if (flags.maxIterations) out.max_iterations = Number(flags.maxIterations);
240
246
  if (flags.maxIterationMinutes) out.session.max_iteration_minutes = Number(flags.maxIterationMinutes);
241
247
  if (flags.maxTotalMinutes) out.session.max_total_minutes = Number(flags.maxTotalMinutes);
248
+ if (flags.checkpointInterval) out.session.checkpoint_interval_minutes = Number(flags.checkpointInterval);
242
249
  if (flags.baseBranch) out.base_branch = flags.baseBranch;
243
250
  if (flags.coderFallback) out.coder_options.fallback_coder = flags.coderFallback;
244
251
  if (flags.reviewerFallback) out.reviewer_options.fallback_reviewer = flags.reviewerFallback;
@@ -260,6 +267,9 @@ export function applyRunOverrides(config, flags) {
260
267
  out.planning_game = out.planning_game || {};
261
268
  if (flags.pgTask) out.planning_game.enabled = true;
262
269
  if (flags.pgProject) out.planning_game.project_id = flags.pgProject;
270
+ out.model_selection = out.model_selection || { enabled: true, tiers: {}, role_overrides: {} };
271
+ if (flags.smartModels === true) out.model_selection.enabled = true;
272
+ if (flags.smartModels === false || flags.noSmartModels === true) out.model_selection.enabled = false;
263
273
  return out;
264
274
  }
265
275
 
@@ -52,7 +52,37 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
52
52
  return { enabled: true, branch, baseBranch, autoRebase };
53
53
  }
54
54
 
55
- export async function finalizeGitAutomation({ config, gitCtx, task, logger, session }) {
55
+ export function buildPrBody({ task, stageResults }) {
56
+ const sections = ["Created by Karajan Code."];
57
+
58
+ const approach = stageResults?.planner?.approach;
59
+ if (approach) {
60
+ sections.push("", "## Approach", approach);
61
+ }
62
+
63
+ const steps = stageResults?.planner?.steps;
64
+ if (steps?.length) {
65
+ sections.push("", "## Steps");
66
+ for (let i = 0; i < steps.length; i++) {
67
+ sections.push(`${i + 1}. ${steps[i]}`);
68
+ }
69
+ }
70
+
71
+ const triageSubtasks = stageResults?.triage?.subtasks;
72
+ const shouldDecompose = stageResults?.triage?.shouldDecompose;
73
+ const pendingSubtasks = shouldDecompose && triageSubtasks?.length > 1 ? triageSubtasks.slice(1) : [];
74
+ if (pendingSubtasks.length > 0) {
75
+ sections.push("", "## Pending subtasks");
76
+ sections.push("This PR addresses part of a larger task. The following subtasks were identified but not included:");
77
+ for (const subtask of pendingSubtasks) {
78
+ sections.push(`- [ ] ${subtask}`);
79
+ }
80
+ }
81
+
82
+ return sections.join("\n");
83
+ }
84
+
85
+ export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null }) {
56
86
  if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
57
87
 
58
88
  const commitMsg = config.git.commit_message || commitMessageFromTask(task);
@@ -86,11 +116,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
86
116
 
87
117
  let prUrl = null;
88
118
  if (config.git.auto_pr) {
119
+ const body = buildPrBody({ task, stageResults });
89
120
  prUrl = await createPullRequest({
90
121
  baseBranch: gitCtx.baseBranch,
91
122
  branch: gitCtx.branch,
92
123
  title: commitMessageFromTask(task),
93
- body: "Created by Karajan Code."
124
+ body
94
125
  });
95
126
  await addCheckpoint(session, { stage: "git-pr", branch: gitCtx.branch, pr: prUrl });
96
127
  logger.info("Pull request created");
package/src/mcp/run-kj.js CHANGED
@@ -49,6 +49,9 @@ 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");
54
+ addOptionalValue(args, "--checkpoint-interval", options.checkpointInterval);
52
55
  addOptionalValue(args, "--pg-task", options.pgTask);
53
56
  addOptionalValue(args, "--pg-project", options.pgProject);
54
57
 
@@ -65,10 +68,12 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
65
68
  runEnv.KJ_SONAR_TOKEN = options.sonarToken;
66
69
  }
67
70
 
71
+ const timeout = options.timeoutMs ? Number(options.timeoutMs) : undefined;
72
+
68
73
  const result = await execa("node", args, {
69
74
  env: runEnv,
70
75
  reject: false,
71
- timeout: options.timeoutMs ? Number(options.timeoutMs) : undefined
76
+ timeout
72
77
  });
73
78
 
74
79
  const ok = result.exitCode === 0;
@@ -79,7 +84,18 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
79
84
  stderr: result.stderr
80
85
  };
81
86
 
82
- if (!ok && result.stderr) {
87
+ if (result.timedOut) {
88
+ payload.ok = false;
89
+ payload.timedOut = true;
90
+ payload.stderr = `Process timed out after ${Math.round((timeout || 0) / 1000)}s (explicit timeout). Consider using kj_run for interactive checkpoint support instead of subprocess commands.`;
91
+ }
92
+
93
+ if (result.killed && !payload.timedOut) {
94
+ payload.ok = false;
95
+ payload.stderr = `Process was killed by signal ${result.signal || "unknown"}. ${result.stderr || ""}`.trim();
96
+ }
97
+
98
+ if (!ok && result.stderr && !payload.timedOut) {
83
99
  payload.errorSummary = result.stderr.split("\n").filter(Boolean).slice(-3).join(" | ");
84
100
  }
85
101
 
@@ -66,7 +66,7 @@ export function classifyError(error) {
66
66
  if (lower.includes("timed out") || lower.includes("timeout")) {
67
67
  return {
68
68
  category: "timeout",
69
- suggestion: "The operation timed out. Try increasing timeoutMs or maxIterationMinutes. If a scan timed out, check SonarQube health."
69
+ suggestion: "The agent did not complete in time. Try: (1) increase --max-iteration-minutes (default: 5), (2) split the task into smaller pieces, (3) use kj_code for single-agent tasks. If a SonarQube scan timed out, check Docker health."
70
70
  };
71
71
  }
72
72
 
@@ -145,7 +145,9 @@ export async function handleRunDirect(a, server, extra) {
145
145
  if (progressNotifier) emitter.on("progress", progressNotifier);
146
146
 
147
147
  const askQuestion = buildAskQuestion(server);
148
- const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion });
148
+ const pgTaskId = a.pgTask || null;
149
+ const pgProject = a.pgProject || config.planning_game?.project_id || null;
150
+ const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
149
151
  return { ok: !result.paused && (result.approved !== false), ...result };
150
152
  }
151
153
 
@@ -212,6 +214,7 @@ export async function handleToolCall(name, args, server, extra) {
212
214
  if (a.format) commandArgs.push("--format", String(a.format));
213
215
  if (a.trace) commandArgs.push("--trace");
214
216
  if (a.currency) commandArgs.push("--currency", String(a.currency));
217
+ if (a.pgTask) commandArgs.push("--pg-task", String(a.pgTask));
215
218
  return runKjCommand({
216
219
  command: "report",
217
220
  commandArgs,
package/src/mcp/tools.js CHANGED
@@ -85,6 +85,8 @@ 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" },
89
+ checkpointInterval: { type: "number", description: "Minutes between interactive checkpoints (default: 5). Set 0 to disable." },
88
90
  noSonar: { type: "boolean" },
89
91
  kjHome: { type: "string" },
90
92
  sonarToken: { type: "string" },
@@ -117,6 +119,7 @@ export const tools = [
117
119
  format: { type: "string", enum: ["text", "json"] },
118
120
  trace: { type: "boolean", description: "Show chronological trace of all pipeline stages" },
119
121
  currency: { type: "string", enum: ["usd", "eur"], description: "Display costs in this currency" },
122
+ pgTask: { type: "string", description: "Filter reports by Planning Game card ID (e.g., KJC-TSK-0042)" },
120
123
  kjHome: { type: "string" }
121
124
  }
122
125
  }
@@ -73,7 +73,7 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
73
73
  });
74
74
 
75
75
  if (fallbackResult.execResult?.ok) {
76
- await addCheckpoint(session, { stage: "coder", iteration, note: `Coder completed via fallback (${fallbackCoder})` });
76
+ await addCheckpoint(session, { stage: "coder", iteration, note: `Coder completed via fallback (${fallbackCoder})`, provider: fallbackCoder, model: null });
77
77
  emitProgress(
78
78
  emitter,
79
79
  makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
@@ -112,7 +112,7 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
112
112
  throw new Error(`Coder failed: ${details}`);
113
113
  }
114
114
 
115
- await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes" });
115
+ await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes", provider: coderRole.provider, model: coderRole.model || null });
116
116
  emitProgress(
117
117
  emitter,
118
118
  makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
@@ -169,7 +169,7 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
169
169
  );
170
170
  throw new Error(`Refactorer failed: ${details}`);
171
171
  }
172
- await addCheckpoint(session, { stage: "refactorer", iteration, note: "Refactorer applied cleanups" });
172
+ await addCheckpoint(session, { stage: "refactorer", iteration, note: "Refactorer applied cleanups", provider: refactorerRole.provider, model: refactorerRole.model || null });
173
173
  emitProgress(
174
174
  emitter,
175
175
  makeEvent("refactorer:end", { ...eventBase, stage: "refactorer" }, {
@@ -285,7 +285,9 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
285
285
  iteration,
286
286
  project_key: sonarResult.projectKey,
287
287
  quality_gate: sonarResult.gateStatus,
288
- open_issues: sonarResult.openIssuesTotal
288
+ open_issues: sonarResult.openIssuesTotal,
289
+ provider: "sonar",
290
+ model: null
289
291
  });
290
292
 
291
293
  emitProgress(
@@ -472,7 +474,9 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
472
474
  stage: "reviewer",
473
475
  iteration,
474
476
  approved: review.approved,
475
- blocking_issues: review.blocking_issues.length
477
+ blocking_issues: review.blocking_issues.length,
478
+ provider: reviewerRole.provider,
479
+ model: reviewerRole.model || null
476
480
  });
477
481
 
478
482
  emitProgress(
@@ -25,7 +25,13 @@ export async function runTesterStage({ config, logger, emitter, eventBase, sessi
25
25
  duration_ms: Date.now() - testerStart
26
26
  });
27
27
 
28
- await addCheckpoint(session, { stage: "tester", iteration, ok: testerOutput.ok });
28
+ await addCheckpoint(session, {
29
+ stage: "tester",
30
+ iteration,
31
+ ok: testerOutput.ok,
32
+ provider: config?.roles?.tester?.provider || coderRole.provider,
33
+ model: config?.roles?.tester?.model || coderRole.model || null
34
+ });
29
35
 
30
36
  emitProgress(
31
37
  emitter,
@@ -93,7 +99,13 @@ export async function runSecurityStage({ config, logger, emitter, eventBase, ses
93
99
  duration_ms: Date.now() - securityStart
94
100
  });
95
101
 
96
- await addCheckpoint(session, { stage: "security", iteration, ok: securityOutput.ok });
102
+ await addCheckpoint(session, {
103
+ stage: "security",
104
+ iteration,
105
+ ok: securityOutput.ok,
106
+ provider: config?.roles?.security?.provider || coderRole.provider,
107
+ model: config?.roles?.security?.model || coderRole.model || null
108
+ });
97
109
 
98
110
  emitProgress(
99
111
  emitter,
@@ -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" });
@@ -27,7 +28,13 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
27
28
  duration_ms: Date.now() - triageStart
28
29
  });
29
30
 
30
- await addCheckpoint(session, { stage: "triage", iteration: 0, ok: triageOutput.ok });
31
+ await addCheckpoint(session, {
32
+ stage: "triage",
33
+ iteration: 0,
34
+ ok: triageOutput.ok,
35
+ provider: config?.roles?.triage?.provider || coderRole.provider,
36
+ model: config?.roles?.triage?.model || coderRole.model || null
37
+ });
31
38
 
32
39
  const recommendedRoles = new Set(triageOutput.result?.roles || []);
33
40
  const roleOverrides = {};
@@ -40,13 +47,53 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
40
47
  roleOverrides.securityEnabled = recommendedRoles.has("security");
41
48
  }
42
49
 
50
+ const shouldDecompose = triageOutput.result?.shouldDecompose || false;
51
+ const subtasks = triageOutput.result?.subtasks || [];
52
+
43
53
  const stageResult = {
44
54
  ok: triageOutput.ok,
45
55
  level: triageOutput.result?.level || null,
46
56
  roles: Array.from(recommendedRoles),
47
- reasoning: triageOutput.result?.reasoning || null
57
+ reasoning: triageOutput.result?.reasoning || null,
58
+ shouldDecompose,
59
+ subtasks
48
60
  };
49
61
 
62
+ let modelSelection = null;
63
+ if (triageOutput.ok && config?.model_selection?.enabled) {
64
+ const level = triageOutput.result?.level;
65
+ if (level) {
66
+ const { modelOverrides, reasoning } = selectModelsForRoles({ level, config });
67
+ for (const [role, model] of Object.entries(modelOverrides)) {
68
+ if (config.roles?.[role] && !config.roles[role].model) {
69
+ config.roles[role].model = model;
70
+ }
71
+ }
72
+ modelSelection = { modelOverrides, reasoning };
73
+ emitProgress(
74
+ emitter,
75
+ makeEvent("model-selection:applied", { ...eventBase, stage: "triage" }, {
76
+ message: "Smart model selection applied",
77
+ detail: modelSelection
78
+ })
79
+ );
80
+ }
81
+ }
82
+
83
+ if (modelSelection) {
84
+ stageResult.modelSelection = modelSelection;
85
+ }
86
+
87
+ if (shouldDecompose && subtasks.length > 0) {
88
+ emitProgress(
89
+ emitter,
90
+ makeEvent("triage:decompose", { ...eventBase, stage: "triage" }, {
91
+ message: `Task decomposition recommended: ${subtasks.length} subtask${subtasks.length === 1 ? "" : "s"}`,
92
+ detail: { shouldDecompose, subtasks }
93
+ })
94
+ );
95
+ }
96
+
50
97
  emitProgress(
51
98
  emitter,
52
99
  makeEvent("triage:end", { ...eventBase, stage: "triage" }, {
@@ -80,7 +127,13 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
80
127
  duration_ms: Date.now() - researchStart
81
128
  });
82
129
 
83
- await addCheckpoint(session, { stage: "researcher", iteration: 0, ok: researchOutput.ok });
130
+ await addCheckpoint(session, {
131
+ stage: "researcher",
132
+ iteration: 0,
133
+ ok: researchOutput.ok,
134
+ provider: config?.roles?.researcher?.provider || coderRole.provider,
135
+ model: config?.roles?.researcher?.model || coderRole.model || null
136
+ });
84
137
 
85
138
  emitProgress(
86
139
  emitter,
@@ -96,7 +149,7 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
96
149
  return { researchContext, stageResult };
97
150
  }
98
151
 
99
- export async function runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, trackBudget }) {
152
+ export async function runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, triageDecomposition = null, trackBudget }) {
100
153
  const task = session.task;
101
154
  logger.setContext({ iteration: 0, stage: "planner" });
102
155
  emitProgress(
@@ -108,11 +161,18 @@ export async function runPlannerStage({ config, logger, emitter, eventBase, sess
108
161
  );
109
162
 
110
163
  const planRole = new PlannerRole({ config, logger, emitter, createAgentFn: createAgent });
111
- planRole.context = { task, research: researchContext };
164
+ planRole.context = { task, research: researchContext, triageDecomposition };
112
165
  await planRole.init();
113
166
  const plannerStart = Date.now();
114
167
  const planResult = await planRole.execute(task);
115
168
  trackBudget({ role: "planner", provider: plannerRole.provider, model: plannerRole.model, result: planResult.result, duration_ms: Date.now() - plannerStart });
169
+ await addCheckpoint(session, {
170
+ stage: "planner",
171
+ iteration: 0,
172
+ ok: planResult.ok,
173
+ provider: plannerRole.provider,
174
+ model: plannerRole.model || null
175
+ });
116
176
 
117
177
  if (!planResult.ok) {
118
178
  await markSessionStatus(session, "failed");
@@ -4,7 +4,8 @@ import {
4
4
  loadSession,
5
5
  markSessionStatus,
6
6
  resumeSessionWithAnswer,
7
- saveSession
7
+ saveSession,
8
+ addCheckpoint
8
9
  } from "./session-store.js";
9
10
  import { computeBaseRef, generateDiff } from "./review/diff-generator.js";
10
11
  import { buildCoderPrompt } from "./prompts/coder.js";
@@ -27,7 +28,7 @@ import { runTesterStage, runSecurityStage } from "./orchestrator/post-loop-stage
27
28
 
28
29
 
29
30
 
30
- export async function runFlow({ task, config, logger, flags = {}, emitter = null, askQuestion = null }) {
31
+ export async function runFlow({ task, config, logger, flags = {}, emitter = null, askQuestion = null, pgTaskId = null, pgProject = null }) {
31
32
  const plannerRole = resolveRole(config, "planner");
32
33
  const coderRole = resolveRole(config, "coder");
33
34
  const reviewerRole = resolveRole(config, "reviewer");
@@ -136,7 +137,7 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
136
137
  }
137
138
 
138
139
  const baseRef = await computeBaseRef({ baseBranch: config.base_branch, baseRef: flags.baseRef || null });
139
- const session = await createSession({
140
+ const sessionInit = {
140
141
  task,
141
142
  config_snapshot: config,
142
143
  base_ref: baseRef,
@@ -149,7 +150,10 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
149
150
  sonar_repeat_count: 0,
150
151
  last_reviewer_issue_signature: null,
151
152
  reviewer_repeat_count: 0
152
- });
153
+ };
154
+ if (pgTaskId) sessionInit.pg_task_id = pgTaskId;
155
+ if (pgProject) sessionInit.pg_project_id = pgProject;
156
+ const session = await createSession(sessionInit);
153
157
 
154
158
  eventBase.sessionId = session.id;
155
159
 
@@ -179,6 +183,57 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
179
183
  if (triageResult.roleOverrides.testerEnabled !== undefined) testerEnabled = triageResult.roleOverrides.testerEnabled;
180
184
  if (triageResult.roleOverrides.securityEnabled !== undefined) securityEnabled = triageResult.roleOverrides.securityEnabled;
181
185
  stageResults.triage = triageResult.stageResult;
186
+
187
+ // --- PG decomposition: offer to create subtasks in Planning Game ---
188
+ const pgDecompose = triageResult.stageResult?.shouldDecompose
189
+ && triageResult.stageResult.subtasks?.length > 1
190
+ && pgTaskId
191
+ && pgProject
192
+ && config.planning_game?.enabled !== false
193
+ && askQuestion;
194
+
195
+ if (pgDecompose) {
196
+ try {
197
+ const { buildDecompositionQuestion, createDecompositionSubtasks } = await import("./planning-game/decomposition.js");
198
+ const { createCard, relateCards, fetchCard } = await import("./planning-game/client.js");
199
+
200
+ const question = buildDecompositionQuestion(triageResult.stageResult.subtasks, pgTaskId);
201
+ const answer = await askQuestion(question);
202
+
203
+ if (answer && (answer.trim().toLowerCase() === "yes" || answer.trim().toLowerCase() === "sí" || answer.trim().toLowerCase() === "si")) {
204
+ const parentCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId }).catch(() => null);
205
+ const createdSubtasks = await createDecompositionSubtasks({
206
+ client: { createCard, relateCards },
207
+ projectId: pgProject,
208
+ parentCardId: pgTaskId,
209
+ parentFirebaseId: parentCard?.firebaseId || null,
210
+ subtasks: triageResult.stageResult.subtasks,
211
+ epic: parentCard?.epic || null,
212
+ sprint: parentCard?.sprint || null,
213
+ codeveloper: config.planning_game?.codeveloper || null
214
+ });
215
+
216
+ stageResults.triage.pgSubtasks = createdSubtasks;
217
+ logger.info(`Planning Game: created ${createdSubtasks.length} subtasks from decomposition`);
218
+
219
+ emitProgress(
220
+ emitter,
221
+ makeEvent("pg:decompose", { ...eventBase, stage: "triage" }, {
222
+ message: `Created ${createdSubtasks.length} subtasks in Planning Game`,
223
+ detail: { subtasks: createdSubtasks.map((s) => ({ cardId: s.cardId, title: s.title })) }
224
+ })
225
+ );
226
+
227
+ await addCheckpoint(session, {
228
+ stage: "pg-decompose",
229
+ subtasksCreated: createdSubtasks.length,
230
+ cardIds: createdSubtasks.map((s) => s.cardId)
231
+ });
232
+ }
233
+ } catch (err) {
234
+ logger.warn(`Planning Game decomposition failed: ${err.message}`);
235
+ }
236
+ }
182
237
  }
183
238
 
184
239
  if (flags.enablePlanner !== undefined) plannerEnabled = Boolean(flags.enablePlanner);
@@ -198,8 +253,9 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
198
253
 
199
254
  // --- Planner ---
200
255
  let plannedTask = task;
256
+ const triageDecomposition = stageResults.triage?.shouldDecompose ? stageResults.triage.subtasks : null;
201
257
  if (plannerEnabled) {
202
- const plannerResult = await runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, trackBudget });
258
+ const plannerResult = await runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, triageDecomposition, trackBudget });
203
259
  plannedTask = plannerResult.plannedTask;
204
260
  stageResults.planner = plannerResult.stageResult;
205
261
  }
@@ -210,9 +266,65 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
210
266
  const { rules: reviewRules } = await resolveReviewProfile({ mode: config.review_mode, projectDir });
211
267
  await coderRoleInstance.init();
212
268
 
269
+ const checkpointIntervalMs = (config.session.checkpoint_interval_minutes ?? 5) * 60 * 1000;
270
+ let lastCheckpointAt = Date.now();
271
+ let checkpointDisabled = false;
272
+
213
273
  for (let i = 1; i <= config.max_iterations; i += 1) {
214
274
  const elapsedMinutes = (Date.now() - startedAt) / 60000;
215
- if (elapsedMinutes > config.session.max_total_minutes) {
275
+
276
+ // --- Interactive checkpoint: pause and ask every N minutes ---
277
+ if (!checkpointDisabled && askQuestion && (Date.now() - lastCheckpointAt) >= checkpointIntervalMs) {
278
+ const elapsedStr = elapsedMinutes.toFixed(1);
279
+ const iterInfo = `${i - 1}/${config.max_iterations} iterations completed`;
280
+ const budgetInfo = budgetTracker.total().cost_usd > 0 ? ` | Budget: $${budgetTracker.total().cost_usd.toFixed(2)}` : "";
281
+ const stagesCompleted = Object.keys(stageResults).join(", ") || "none";
282
+ const checkpointMsg = `Checkpoint — ${elapsedStr} min elapsed | ${iterInfo}${budgetInfo} | Stages completed: ${stagesCompleted}. What would you like to do?`;
283
+
284
+ emitProgress(
285
+ emitter,
286
+ makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
287
+ message: `Interactive checkpoint at ${elapsedStr} min`,
288
+ detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted }
289
+ })
290
+ );
291
+
292
+ const answer = await askQuestion(
293
+ `${checkpointMsg}\n\nOptions:\n1. Continue 5 more minutes\n2. Continue until done (no more checkpoints)\n3. Continue for N minutes (reply with the number)\n4. Stop now`
294
+ );
295
+
296
+ await addCheckpoint(session, { stage: "interactive-checkpoint", elapsed_minutes: Number(elapsedStr), answer });
297
+
298
+ if (!answer || answer.trim() === "4" || answer.trim().toLowerCase().startsWith("stop")) {
299
+ await markSessionStatus(session, "stopped");
300
+ emitProgress(
301
+ emitter,
302
+ makeEvent("session:end", { ...eventBase, iteration: i, stage: "user-stop" }, {
303
+ status: "stopped",
304
+ message: "Session stopped by user at checkpoint",
305
+ detail: { approved: false, reason: "user_stopped", elapsed_minutes: Number(elapsedStr), budget: budgetSummary() }
306
+ })
307
+ );
308
+ return { approved: false, sessionId: session.id, reason: "user_stopped", elapsed_minutes: Number(elapsedStr) };
309
+ }
310
+
311
+ if (answer.trim() === "2" || answer.trim().toLowerCase().startsWith("continue until")) {
312
+ checkpointDisabled = true;
313
+ } else if (answer.trim() === "1" || answer.trim().toLowerCase().includes("5 m")) {
314
+ lastCheckpointAt = Date.now();
315
+ } else {
316
+ const customMinutes = parseInt(answer.trim().replace(/\D/g, ""), 10);
317
+ if (customMinutes > 0) {
318
+ lastCheckpointAt = Date.now();
319
+ config.session.checkpoint_interval_minutes = customMinutes;
320
+ } else {
321
+ lastCheckpointAt = Date.now();
322
+ }
323
+ }
324
+ }
325
+
326
+ // --- Hard timeout: only when no askQuestion available ---
327
+ if (!askQuestion && elapsedMinutes > config.session.max_total_minutes) {
216
328
  await markSessionStatus(session, "failed");
217
329
  emitProgress(
218
330
  emitter,
@@ -366,7 +478,7 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
366
478
  }
367
479
 
368
480
  // --- All post-loop checks passed → finalize ---
369
- const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session });
481
+ const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults });
370
482
  if (stageResults.planner?.ok) {
371
483
  stageResults.planner.completedSteps = [...(stageResults.planner.steps || [])];
372
484
  }
@@ -89,3 +89,31 @@ export async function updateCard({ projectId, cardId, firebaseId, updates, timeo
89
89
  );
90
90
  return parseJsonResponse(response);
91
91
  }
92
+
93
+ export async function createCard({ projectId, card, timeoutMs = DEFAULT_TIMEOUT_MS }) {
94
+ const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
95
+ const response = await fetchWithRetry(
96
+ url,
97
+ {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify(card)
101
+ },
102
+ timeoutMs
103
+ );
104
+ return parseJsonResponse(response);
105
+ }
106
+
107
+ export async function relateCards({ projectId, sourceCardId, targetCardId, relationType, timeoutMs = DEFAULT_TIMEOUT_MS }) {
108
+ const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/relate`;
109
+ const response = await fetchWithRetry(
110
+ url,
111
+ {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify({ sourceCardId, targetCardId, relationType })
115
+ },
116
+ timeoutMs
117
+ );
118
+ return parseJsonResponse(response);
119
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Planning Game decomposition.
3
+ * Creates subtask cards in PG with blocks/blockedBy chain when triage
4
+ * recommends decomposition and user accepts.
5
+ */
6
+
7
+ export async function createDecompositionSubtasks({
8
+ client,
9
+ projectId,
10
+ parentCardId,
11
+ parentFirebaseId,
12
+ subtasks,
13
+ epic,
14
+ sprint,
15
+ codeveloper
16
+ }) {
17
+ if (!subtasks?.length || subtasks.length < 2) return [];
18
+
19
+ const created = [];
20
+
21
+ for (let i = 0; i < subtasks.length; i++) {
22
+ const card = {
23
+ type: "task",
24
+ title: subtasks[i],
25
+ descriptionStructured: [{
26
+ role: "developer",
27
+ goal: subtasks[i],
28
+ benefit: `Part ${i + 1}/${subtasks.length} of decomposed task ${parentCardId}`
29
+ }],
30
+ acceptanceCriteria: `Subtask ${i + 1} of ${parentCardId}: ${subtasks[i]}`
31
+ };
32
+
33
+ if (epic) card.epic = epic;
34
+ if (sprint) card.sprint = sprint;
35
+ if (codeveloper) card.codeveloper = codeveloper;
36
+
37
+ const result = await client.createCard({ projectId, card });
38
+ created.push({
39
+ cardId: result.cardId,
40
+ firebaseId: result.firebaseId,
41
+ title: subtasks[i],
42
+ index: i
43
+ });
44
+ }
45
+
46
+ // Chain blocks/blockedBy relationships: card[0] blocks card[1], card[1] blocks card[2], etc.
47
+ for (let i = 0; i < created.length - 1; i++) {
48
+ await client.relateCards({
49
+ projectId,
50
+ sourceCardId: created[i].cardId,
51
+ targetCardId: created[i + 1].cardId,
52
+ relationType: "blocks"
53
+ });
54
+ }
55
+
56
+ // Relate all subtasks to parent
57
+ for (const sub of created) {
58
+ await client.relateCards({
59
+ projectId,
60
+ sourceCardId: parentCardId,
61
+ targetCardId: sub.cardId,
62
+ relationType: "related"
63
+ });
64
+ }
65
+
66
+ return created;
67
+ }
68
+
69
+ export function buildDecompositionQuestion(subtasks, parentCardId) {
70
+ const lines = [
71
+ `Triage recommends decomposing this task into ${subtasks.length} subtasks:`,
72
+ ""
73
+ ];
74
+ for (let i = 0; i < subtasks.length; i++) {
75
+ lines.push(`${i + 1}. ${subtasks[i]}`);
76
+ }
77
+ lines.push("");
78
+ lines.push(`Create these as linked cards in Planning Game (parent: ${parentCardId})?`);
79
+ lines.push("Each subtask will block the next one (sequential chain).");
80
+ lines.push("");
81
+ lines.push("Reply: yes / no");
82
+ return lines.join("\n");
83
+ }
@@ -9,7 +9,7 @@ function resolveProvider(config) {
9
9
  );
10
10
  }
11
11
 
12
- function buildPrompt({ task, instructions, research }) {
12
+ function buildPrompt({ task, instructions, research, triageDecomposition }) {
13
13
  const sections = [];
14
14
 
15
15
  if (instructions) {
@@ -21,6 +21,17 @@ function buildPrompt({ task, instructions, research }) {
21
21
  sections.push("Return concise numbered steps focused on execution order and risk.");
22
22
  sections.push("");
23
23
 
24
+ if (triageDecomposition?.length) {
25
+ sections.push("## Triage decomposition recommendation");
26
+ sections.push("The triage stage determined this task should be decomposed. Suggested subtasks:");
27
+ for (let i = 0; i < triageDecomposition.length; i++) {
28
+ sections.push(`${i + 1}. ${triageDecomposition[i]}`);
29
+ }
30
+ sections.push("");
31
+ sections.push("Focus your plan on the FIRST subtask only. List the remaining subtasks as 'pending_subtasks' in your output for documentation.");
32
+ sections.push("");
33
+ }
34
+
24
35
  if (research) {
25
36
  sections.push("## Research findings");
26
37
  if (research.affected_files?.length) {
@@ -56,10 +67,11 @@ export class PlannerRole extends BaseRole {
56
67
  async execute(input) {
57
68
  const task = input || this.context?.task || "";
58
69
  const research = this.context?.research || null;
70
+ const triageDecomposition = this.context?.triageDecomposition || null;
59
71
  const provider = resolveProvider(this.config);
60
72
 
61
73
  const agent = this._createAgent(provider, this.config, this.logger);
62
- const prompt = buildPrompt({ task, instructions: this.instructions, research });
74
+ const prompt = buildPrompt({ task, instructions: this.instructions, research, triageDecomposition });
63
75
 
64
76
  const result = await agent.runTask({ prompt, role: "planner" });
65
77
 
@@ -26,10 +26,10 @@ function buildPrompt({ task, instructions }) {
26
26
  }
27
27
 
28
28
  sections.push(
29
- "Classify the task complexity and recommend only the necessary pipeline roles.",
29
+ "Classify the task complexity, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
30
30
  "Keep the reasoning short and practical.",
31
31
  "Return a single valid JSON object and nothing else.",
32
- 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string}'
32
+ 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}'
33
33
  );
34
34
 
35
35
  sections.push(`## Task\n${task}`);
@@ -49,6 +49,14 @@ function normalizeRoles(roles) {
49
49
  return Array.from(new Set(roles.filter((role) => VALID_ROLES.has(role))));
50
50
  }
51
51
 
52
+ function normalizeSubtasks(subtasks) {
53
+ if (!Array.isArray(subtasks)) return [];
54
+ return subtasks
55
+ .map((s) => (typeof s === "string" ? s.trim() : ""))
56
+ .filter(Boolean)
57
+ .slice(0, 5);
58
+ }
59
+
52
60
  export class TriageRole extends BaseRole {
53
61
  constructor({ config, logger, emitter = null, createAgentFn = null }) {
54
62
  super({ name: "triage", config, logger, emitter });
@@ -98,16 +106,28 @@ export class TriageRole extends BaseRole {
98
106
  const level = VALID_LEVELS.has(parsed.level) ? parsed.level : "medium";
99
107
  const roles = normalizeRoles(parsed.roles);
100
108
  const reasoning = String(parsed.reasoning || "").trim() || "No reasoning provided.";
109
+ const shouldDecompose = Boolean(parsed.shouldDecompose);
110
+ const subtasks = normalizeSubtasks(parsed.subtasks);
111
+
112
+ const triageResult = {
113
+ level,
114
+ roles,
115
+ reasoning,
116
+ provider
117
+ };
118
+
119
+ if (shouldDecompose && subtasks.length > 0) {
120
+ triageResult.shouldDecompose = true;
121
+ triageResult.subtasks = subtasks;
122
+ } else {
123
+ triageResult.shouldDecompose = false;
124
+ }
101
125
 
126
+ const decomposeNote = shouldDecompose ? " — decomposition recommended" : "";
102
127
  return {
103
128
  ok: true,
104
- result: {
105
- level,
106
- roles,
107
- reasoning,
108
- provider
109
- },
110
- summary: `Triage: ${level} (${roles.length} role${roles.length === 1 ? "" : "s"})`,
129
+ result: triageResult,
130
+ summary: `Triage: ${level} (${roles.length} role${roles.length === 1 ? "" : "s"})${decomposeNote}`,
111
131
  usage: result.usage
112
132
  };
113
133
  } catch {
@@ -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
+ }
@@ -7,6 +7,20 @@ export async function runCommand(command, args = [], options = {}) {
7
7
  ...rest
8
8
  });
9
9
 
10
+ let stdoutAccum = "";
11
+ let stderrAccum = "";
12
+
13
+ if (subprocess.stdout) {
14
+ subprocess.stdout.on("data", (chunk) => {
15
+ stdoutAccum += chunk.toString();
16
+ });
17
+ }
18
+ if (subprocess.stderr) {
19
+ subprocess.stderr.on("data", (chunk) => {
20
+ stderrAccum += chunk.toString();
21
+ });
22
+ }
23
+
10
24
  if (onOutput) {
11
25
  const handler = (stream) => {
12
26
  let partial = "";
@@ -25,7 +39,8 @@ export async function runCommand(command, args = [], options = {}) {
25
39
 
26
40
  try {
27
41
  if (!timeout) {
28
- return await subprocess;
42
+ const result = await subprocess;
43
+ return enrichResult(result, stdoutAccum, stderrAccum);
29
44
  }
30
45
 
31
46
  let timer = null;
@@ -38,15 +53,17 @@ export async function runCommand(command, args = [], options = {}) {
38
53
  }
39
54
  resolve({
40
55
  exitCode: 143,
41
- stdout: "",
42
- stderr: `Command timed out after ${timeout}ms`
56
+ stdout: stdoutAccum,
57
+ stderr: `Command timed out after ${timeout}ms`,
58
+ timedOut: true,
59
+ signal: "SIGKILL"
43
60
  });
44
61
  }, timeout);
45
62
  });
46
63
 
47
64
  const result = await Promise.race([subprocess, timeoutResult]);
48
65
  if (timer) clearTimeout(timer);
49
- return result;
66
+ return enrichResult(result, stdoutAccum, stderrAccum);
50
67
  } catch (error) {
51
68
  const details = [
52
69
  error?.shortMessage,
@@ -60,8 +77,29 @@ export async function runCommand(command, args = [], options = {}) {
60
77
 
61
78
  return {
62
79
  exitCode: 1,
63
- stdout: error?.stdout || "",
64
- stderr: details || String(error)
80
+ stdout: error?.stdout || stdoutAccum,
81
+ stderr: details || String(error),
82
+ signal: error?.signal || null
83
+ };
84
+ }
85
+ }
86
+
87
+ function enrichResult(result, stdoutAccum, stderrAccum) {
88
+ if (result.timedOut) return result;
89
+
90
+ const killed = result.killed || !!result.signal;
91
+ const signal = result.signal || null;
92
+
93
+ if (killed && !result.stderr) {
94
+ return {
95
+ ...result,
96
+ stdout: result.stdout || stdoutAccum,
97
+ stderr: signal
98
+ ? `Process killed by signal ${signal}`
99
+ : "Process was killed externally",
100
+ signal
65
101
  };
66
102
  }
103
+
104
+ return result;
67
105
  }
@@ -1,6 +1,6 @@
1
1
  You are the **Triage** role in a multi-role AI pipeline.
2
2
 
3
- Your job is to quickly classify task complexity and activate only the necessary roles.
3
+ Your job is to quickly classify task complexity, activate only the necessary roles, and assess whether the task should be decomposed into smaller subtasks before execution.
4
4
 
5
5
  ## Output format
6
6
  Return a single valid JSON object and nothing else:
@@ -9,7 +9,9 @@ Return a single valid JSON object and nothing else:
9
9
  {
10
10
  "level": "trivial|simple|medium|complex",
11
11
  "roles": ["planner", "researcher", "refactorer", "reviewer", "tester", "security"],
12
- "reasoning": "brief practical justification"
12
+ "reasoning": "brief practical justification",
13
+ "shouldDecompose": false,
14
+ "subtasks": []
13
15
  }
14
16
  ```
15
17
 
@@ -19,7 +21,20 @@ Return a single valid JSON object and nothing else:
19
21
  - `medium`: moderate scope/risk. Reviewer required; optional planner/researcher.
20
22
  - `complex`: high scope/risk, architecture or security/testing impact. Full pipeline.
21
23
 
24
+ ## Decomposition guidance
25
+ Analyze whether the task is too large for a single agent iteration. Set `shouldDecompose: true` when ANY of these apply:
26
+ - The task touches more than 3 unrelated areas of the codebase.
27
+ - It requires both architectural changes AND feature implementation.
28
+ - It combines multiple independent features or fixes in one request.
29
+ - It would likely require more than ~200 lines of changes across many files.
30
+ - It mixes refactoring with new functionality.
31
+
32
+ When `shouldDecompose` is true, provide `subtasks`: an array of 2-5 short strings, each describing one focused, independently deliverable piece of work. Order them by dependency (do first → do last).
33
+
34
+ When `shouldDecompose` is false, `subtasks` must be an empty array.
35
+
22
36
  ## Rules
23
37
  - Keep `reasoning` short.
24
38
  - Recommend only roles that add clear value.
25
39
  - Do not include `coder` or `sonar` in `roles` (they are always active).
40
+ - Subtask descriptions should be actionable and specific, not vague.