karajan-code 1.7.0 → 1.9.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
@@ -42,6 +42,7 @@ Instead of running one AI agent and manually reviewing its output, `kj` chains a
42
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
43
  - **Task decomposition** — triage detects when tasks should be split and recommends subtasks; with Planning Game integration, creates linked cards with sequential blocking
44
44
  - **Retry with backoff** — automatic recovery from transient API errors (429, 5xx) with exponential backoff and jitter
45
+ - **Pipeline stage tracker** — cumulative progress view during `kj_run` showing which stages are done, running, or pending — both in CLI and via MCP events for real-time host rendering
45
46
  - **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
46
47
 
47
48
  > **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.
@@ -447,7 +448,7 @@ Use `kj roles show <role>` to inspect any template. Create a project override to
447
448
  git clone https://github.com/manufosela/karajan-code.git
448
449
  cd karajan-code
449
450
  npm install
450
- npm test # Run 1025+ tests with Vitest
451
+ npm test # Run 1040+ tests with Vitest
451
452
  npm run test:watch # Watch mode
452
453
  npm run validate # Lint + test
453
454
  ```
package/docs/README.es.md CHANGED
@@ -41,6 +41,7 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
41
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
42
  - **Descomposicion de tareas** — triage detecta cuando una tarea debe dividirse y recomienda subtareas; con integracion Planning Game, crea cards vinculadas con bloqueo secuencial
43
43
  - **Retry con backoff** — recuperacion automatica ante errores transitorios de API (429, 5xx) con backoff exponencial y jitter
44
+ - **Pipeline stage tracker** — vista de progreso acumulativo durante `kj_run` mostrando que stages estan completadas, en ejecucion o pendientes — tanto en CLI como via eventos MCP para renderizado en tiempo real en el host
44
45
  - **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
45
46
 
46
47
  > **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.
@@ -231,7 +232,7 @@ Usa `kj roles show <rol>` para inspeccionar cualquier template. Crea un override
231
232
  git clone https://github.com/manufosela/karajan-code.git
232
233
  cd karajan-code
233
234
  npm install
234
- npm test # Ejecutar 899+ tests con Vitest
235
+ npm test # Ejecutar 1040+ tests con Vitest
235
236
  npm run test:watch # Modo watch
236
237
  npm run validate # Lint + test
237
238
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",
@@ -2,13 +2,91 @@ import { BaseAgent } from "./base-agent.js";
2
2
  import { runCommand } from "../utils/process.js";
3
3
  import { resolveBin } from "./resolve-bin.js";
4
4
 
5
+ /**
6
+ * Extract the final text result from stream-json NDJSON output.
7
+ * Each line is a JSON object. We collect assistant text content from
8
+ * "result" messages and fall back to accumulating "content_block_delta" text.
9
+ */
10
+ function extractTextFromStreamJson(raw) {
11
+ const lines = (raw || "").split("\n").filter(Boolean);
12
+ // Try to find a "result" message with the final text
13
+ for (let i = lines.length - 1; i >= 0; i--) {
14
+ try {
15
+ const obj = JSON.parse(lines[i]);
16
+ if (obj.type === "result" && obj.result) {
17
+ return typeof obj.result === "string" ? obj.result : JSON.stringify(obj.result);
18
+ }
19
+ // Claude Code stream-json final message
20
+ if (obj.result && typeof obj.result === "string") {
21
+ return obj.result;
22
+ }
23
+ } catch { /* skip unparseable lines */ }
24
+ }
25
+ // Fallback: accumulate all assistant text deltas
26
+ const parts = [];
27
+ for (const line of lines) {
28
+ try {
29
+ const obj = JSON.parse(line);
30
+ if (obj.type === "assistant" && obj.message?.content) {
31
+ for (const block of obj.message.content) {
32
+ if (block.type === "text" && block.text) parts.push(block.text);
33
+ }
34
+ }
35
+ } catch { /* skip */ }
36
+ }
37
+ return parts.join("") || raw;
38
+ }
39
+
40
+ /**
41
+ * Create a wrapping onOutput that parses stream-json lines and forwards
42
+ * meaningful content (assistant text, tool usage) to the original callback.
43
+ */
44
+ function createStreamJsonFilter(onOutput) {
45
+ if (!onOutput) return null;
46
+ return ({ stream, line }) => {
47
+ try {
48
+ const obj = JSON.parse(line);
49
+ // Forward assistant text messages
50
+ if (obj.type === "assistant" && obj.message?.content) {
51
+ for (const block of obj.message.content) {
52
+ if (block.type === "text" && block.text) {
53
+ onOutput({ stream, line: block.text.slice(0, 200) });
54
+ } else if (block.type === "tool_use") {
55
+ onOutput({ stream, line: `[tool: ${block.name}]` });
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ // Forward result
61
+ if (obj.type === "result") {
62
+ const summary = typeof obj.result === "string"
63
+ ? obj.result.slice(0, 200)
64
+ : "result received";
65
+ onOutput({ stream, line: `[result] ${summary}` });
66
+ return;
67
+ }
68
+ } catch { /* not JSON, forward raw */ }
69
+ onOutput({ stream, line });
70
+ };
71
+ }
72
+
5
73
  export class ClaudeAgent extends BaseAgent {
6
74
  async runTask(task) {
7
75
  const role = task.role || "coder";
8
76
  const args = ["-p", task.prompt];
9
77
  const model = this.getRoleModel(role);
10
78
  if (model) args.push("--model", model);
11
- const res = await runCommand(resolveBin("claude"), args, { onOutput: task.onOutput });
79
+
80
+ // Use stream-json when onOutput is provided to get real-time feedback
81
+ if (task.onOutput) {
82
+ args.push("--output-format", "stream-json");
83
+ const streamFilter = createStreamJsonFilter(task.onOutput);
84
+ const res = await runCommand(resolveBin("claude"), args, { onOutput: streamFilter });
85
+ const output = extractTextFromStreamJson(res.stdout);
86
+ return { ok: res.exitCode === 0, output, error: res.stderr, exitCode: res.exitCode };
87
+ }
88
+
89
+ const res = await runCommand(resolveBin("claude"), args);
12
90
  return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
13
91
  }
14
92
 
@@ -6,6 +6,10 @@
6
6
  export const PROGRESS_STAGES = [
7
7
  "session:start",
8
8
  "iteration:start",
9
+ "triage:start",
10
+ "triage:end",
11
+ "researcher:start",
12
+ "researcher:end",
9
13
  "planner:start",
10
14
  "planner:end",
11
15
  "coder:start",
@@ -21,14 +25,89 @@ export const PROGRESS_STAGES = [
21
25
  "solomon:escalate",
22
26
  "question",
23
27
  "session:end",
24
- "dry-run:summary"
28
+ "dry-run:summary",
29
+ "pipeline:tracker",
30
+ "agent:heartbeat",
31
+ "agent:stall"
25
32
  ];
26
33
 
34
+ const PIPELINE_ORDER = [
35
+ "triage", "researcher", "planner", "coder", "refactorer", "sonar", "reviewer", "tester", "security", "commiter"
36
+ ];
37
+
38
+ export function buildPipelineTracker(config, emitter) {
39
+ const pipeline = config.pipeline || {};
40
+
41
+ const stages = PIPELINE_ORDER
42
+ .filter(name => {
43
+ if (name === "coder") return true;
44
+ if (name === "reviewer") return pipeline.reviewer?.enabled !== false;
45
+ if (name === "sonar") return pipeline.sonar?.enabled || config.sonarqube?.enabled;
46
+ return pipeline[name]?.enabled;
47
+ })
48
+ .map(name => ({ name, status: "pending", summary: undefined }));
49
+
50
+ const findStage = (name) => stages.find(s => s.name === name);
51
+
52
+ const emitTracker = () => {
53
+ emitter.emit("progress", {
54
+ type: "pipeline:tracker",
55
+ detail: { stages: stages.map(s => ({ ...s })) }
56
+ });
57
+ };
58
+
59
+ emitter.on("progress", (event) => {
60
+ const match = event.type?.match(/^(\w+):(start|end)$/);
61
+ if (!match) return;
62
+
63
+ const [, name, phase] = match;
64
+ const stage = findStage(name);
65
+ if (!stage) return;
66
+
67
+ if (phase === "start") {
68
+ stage.status = "running";
69
+ stage.summary = event.detail?.[name] || stage.summary;
70
+ } else {
71
+ stage.status = event.status === "fail" ? "failed" : "done";
72
+ stage.summary = event.detail?.summary || event.detail?.gateStatus || stage.summary;
73
+ }
74
+
75
+ emitTracker();
76
+ });
77
+
78
+ return { stages };
79
+ }
80
+
81
+ export function sendTrackerLog(server, stageName, status, summary) {
82
+ try {
83
+ server.sendLoggingMessage({
84
+ level: "info",
85
+ logger: "karajan",
86
+ data: {
87
+ type: "pipeline:tracker",
88
+ detail: {
89
+ stages: [{ name: stageName, status, summary: summary || undefined }]
90
+ }
91
+ }
92
+ });
93
+ } catch {
94
+ // best-effort
95
+ }
96
+ }
97
+
98
+ function resolveLogLevel(event) {
99
+ if (event.type === "agent:output") return "debug";
100
+ if (event.type === "agent:heartbeat") return "debug";
101
+ if (event.type === "agent:stall") return "warning";
102
+ if (event.status === "fail") return "error";
103
+ return "info";
104
+ }
105
+
27
106
  export function buildProgressHandler(server) {
28
107
  return (event) => {
29
108
  try {
30
109
  server.sendLoggingMessage({
31
- level: event.type === "agent:output" ? "debug" : event.status === "fail" ? "error" : "info",
110
+ level: resolveLogLevel(event),
32
111
  logger: "karajan",
33
112
  data: event
34
113
  });
@@ -7,7 +7,8 @@ import { EventEmitter } from "node:events";
7
7
  import fs from "node:fs/promises";
8
8
  import { runKjCommand } from "./run-kj.js";
9
9
  import { normalizePlanArgs } from "./tool-arg-normalizers.js";
10
- import { buildProgressHandler, buildProgressNotifier } from "./progress.js";
10
+ import { buildProgressHandler, buildProgressNotifier, buildPipelineTracker, sendTrackerLog } from "./progress.js";
11
+ import { createStallDetector } from "../utils/stall-detector.js";
11
12
  import { runFlow, resumeFlow } from "../orchestrator.js";
12
13
  import { loadConfig, applyRunOverrides, validateConfig, resolveRole } from "../config.js";
13
14
  import { createLogger } from "../utils/logger.js";
@@ -19,6 +20,24 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
19
20
  import { parseMaybeJsonString } from "../review/parser.js";
20
21
  import { computeBaseRef, generateDiff } from "../review/diff-generator.js";
21
22
  import { resolveReviewProfile } from "../review/profiles.js";
23
+ import { createRunLog, readRunLog } from "../utils/run-log.js";
24
+
25
+ /**
26
+ * Resolve the user's project directory via MCP roots.
27
+ * Falls back to process.cwd() if roots are not available.
28
+ */
29
+ async function resolveProjectDir(server) {
30
+ try {
31
+ const { roots } = await server.listRoots();
32
+ if (roots?.length > 0) {
33
+ const uri = roots[0].uri;
34
+ // MCP roots use file:// URIs
35
+ if (uri.startsWith("file://")) return new URL(uri).pathname;
36
+ return uri;
37
+ }
38
+ } catch { /* client may not support roots */ }
39
+ return process.cwd();
40
+ }
22
41
 
23
42
  export function asObject(value) {
24
43
  if (value && typeof value === "object") return value;
@@ -147,16 +166,27 @@ export async function handleRunDirect(a, server, extra) {
147
166
  if (config.pipeline?.security?.enabled) requiredProviders.push(resolveRole(config, "security").provider);
148
167
  await assertAgentsAvailable(requiredProviders);
149
168
 
169
+ const projectDir = await resolveProjectDir(server);
170
+ const runLog = createRunLog(projectDir);
171
+ runLog.logText(`[kj_run] started — task="${a.task.slice(0, 80)}..."`);
172
+
150
173
  const emitter = new EventEmitter();
151
174
  emitter.on("progress", buildProgressHandler(server));
175
+ emitter.on("progress", (event) => runLog.logEvent(event));
152
176
  const progressNotifier = buildProgressNotifier(extra);
153
177
  if (progressNotifier) emitter.on("progress", progressNotifier);
178
+ buildPipelineTracker(config, emitter);
154
179
 
155
180
  const askQuestion = buildAskQuestion(server);
156
181
  const pgTaskId = a.pgTask || null;
157
182
  const pgProject = a.pgProject || config.planning_game?.project_id || null;
158
- const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
159
- return { ok: !result.paused && (result.approved !== false), ...result };
183
+ try {
184
+ const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
185
+ runLog.logText(`[kj_run] finished — ok=${!result.paused && (result.approved !== false)}`);
186
+ return { ok: !result.paused && (result.approved !== false), ...result };
187
+ } finally {
188
+ runLog.close();
189
+ }
160
190
  }
161
191
 
162
192
  export async function handleResumeDirect(a, server, extra) {
@@ -181,6 +211,20 @@ export async function handleResumeDirect(a, server, extra) {
181
211
  return { ok: true, ...result };
182
212
  }
183
213
 
214
+ function buildDirectEmitter(server, runLog) {
215
+ const emitter = new EventEmitter();
216
+ emitter.on("progress", (event) => {
217
+ try {
218
+ const level = event.type === "agent:stall" ? "warning"
219
+ : event.type === "agent:heartbeat" ? "info"
220
+ : "debug";
221
+ server.sendLoggingMessage({ level, logger: "karajan", data: event });
222
+ } catch { /* best-effort */ }
223
+ if (runLog) runLog.logEvent(event);
224
+ });
225
+ return emitter;
226
+ }
227
+
184
228
  export async function handlePlanDirect(a, server, extra) {
185
229
  const options = normalizePlanArgs(a);
186
230
  const config = await buildConfig(options, "plan");
@@ -189,14 +233,38 @@ export async function handlePlanDirect(a, server, extra) {
189
233
  const plannerRole = resolveRole(config, "planner");
190
234
  await assertAgentsAvailable([plannerRole.provider]);
191
235
 
236
+ const projectDir = await resolveProjectDir(server);
237
+ const runLog = createRunLog(projectDir);
238
+ runLog.logText(`[kj_plan] started — provider=${plannerRole.provider}`);
239
+ const emitter = buildDirectEmitter(server, runLog);
240
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
241
+ const onOutput = ({ stream, line }) => {
242
+ emitter.emit("progress", { type: "agent:output", stage: "planner", message: line, detail: { stream, agent: plannerRole.provider } });
243
+ };
244
+ const stallDetector = createStallDetector({
245
+ onOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
246
+ });
247
+
192
248
  const planner = createAgent(plannerRole.provider, config, logger);
193
249
  const prompt = buildPlannerPrompt({ task: a.task, context: a.context });
194
- const result = await planner.runTask({ prompt, role: "planner" });
250
+ sendTrackerLog(server, "planner", "running", plannerRole.provider);
251
+ runLog.logText(`[planner] agent launched, waiting for response...`);
252
+ let result;
253
+ try {
254
+ result = await planner.runTask({ prompt, role: "planner", onOutput: stallDetector.onOutput });
255
+ } finally {
256
+ stallDetector.stop();
257
+ const stats = stallDetector.stats();
258
+ runLog.logText(`[planner] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
259
+ runLog.close();
260
+ }
195
261
 
196
262
  if (!result.ok) {
263
+ sendTrackerLog(server, "planner", "failed");
197
264
  throw new Error(result.error || result.output || "Planner failed");
198
265
  }
199
266
 
267
+ sendTrackerLog(server, "planner", "done");
200
268
  const parsed = parseMaybeJsonString(result.output);
201
269
  return { ok: true, plan: parsed || result.output, raw: result.output };
202
270
  }
@@ -208,6 +276,18 @@ export async function handleCodeDirect(a, server, extra) {
208
276
  const coderRole = resolveRole(config, "coder");
209
277
  await assertAgentsAvailable([coderRole.provider]);
210
278
 
279
+ const projectDir = await resolveProjectDir(server);
280
+ const runLog = createRunLog(projectDir);
281
+ runLog.logText(`[kj_code] started — provider=${coderRole.provider}`);
282
+ const emitter = buildDirectEmitter(server, runLog);
283
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
284
+ const onOutput = ({ stream, line }) => {
285
+ emitter.emit("progress", { type: "agent:output", stage: "coder", message: line, detail: { stream, agent: coderRole.provider } });
286
+ };
287
+ const stallDetector = createStallDetector({
288
+ onOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
289
+ });
290
+
211
291
  const coder = createAgent(coderRole.provider, config, logger);
212
292
  let coderRules = null;
213
293
  if (config.coder_rules) {
@@ -216,12 +296,24 @@ export async function handleCodeDirect(a, server, extra) {
216
296
  } catch { /* no coder rules file */ }
217
297
  }
218
298
  const prompt = buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
219
- const result = await coder.runTask({ prompt, role: "coder" });
299
+ sendTrackerLog(server, "coder", "running", coderRole.provider);
300
+ runLog.logText(`[coder] agent launched, waiting for response...`);
301
+ let result;
302
+ try {
303
+ result = await coder.runTask({ prompt, role: "coder", onOutput: stallDetector.onOutput });
304
+ } finally {
305
+ stallDetector.stop();
306
+ const stats = stallDetector.stats();
307
+ runLog.logText(`[coder] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
308
+ runLog.close();
309
+ }
220
310
 
221
311
  if (!result.ok) {
312
+ sendTrackerLog(server, "coder", "failed");
222
313
  throw new Error(result.error || result.output || `Coder failed (exit ${result.exitCode})`);
223
314
  }
224
315
 
316
+ sendTrackerLog(server, "coder", "done");
225
317
  return { ok: true, output: result.output, exitCode: result.exitCode };
226
318
  }
227
319
 
@@ -232,18 +324,42 @@ export async function handleReviewDirect(a, server, extra) {
232
324
  const reviewerRole = resolveRole(config, "reviewer");
233
325
  await assertAgentsAvailable([reviewerRole.provider, config.reviewer_options?.fallback_reviewer]);
234
326
 
327
+ const projectDir = await resolveProjectDir(server);
328
+ const runLog = createRunLog(projectDir);
329
+ runLog.logText(`[kj_review] started — provider=${reviewerRole.provider}`);
330
+ const emitter = buildDirectEmitter(server, runLog);
331
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
332
+ const onOutput = ({ stream, line }) => {
333
+ emitter.emit("progress", { type: "agent:output", stage: "reviewer", message: line, detail: { stream, agent: reviewerRole.provider } });
334
+ };
335
+ const stallDetector = createStallDetector({
336
+ onOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
337
+ });
338
+
235
339
  const reviewer = createAgent(reviewerRole.provider, config, logger);
236
340
  const resolvedBase = await computeBaseRef({ baseBranch: config.base_branch, baseRef: a.baseRef });
237
341
  const diff = await generateDiff({ baseRef: resolvedBase });
238
342
  const { rules } = await resolveReviewProfile({ mode: config.review_mode, projectDir: process.cwd() });
239
343
 
240
344
  const prompt = buildReviewerPrompt({ task: a.task, diff, reviewRules: rules, mode: config.review_mode });
241
- const result = await reviewer.reviewTask({ prompt, role: "reviewer" });
345
+ sendTrackerLog(server, "reviewer", "running", reviewerRole.provider);
346
+ runLog.logText(`[reviewer] agent launched, waiting for response...`);
347
+ let result;
348
+ try {
349
+ result = await reviewer.reviewTask({ prompt, role: "reviewer", onOutput: stallDetector.onOutput });
350
+ } finally {
351
+ stallDetector.stop();
352
+ const stats = stallDetector.stats();
353
+ runLog.logText(`[reviewer] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
354
+ runLog.close();
355
+ }
242
356
 
243
357
  if (!result.ok) {
358
+ sendTrackerLog(server, "reviewer", "failed");
244
359
  throw new Error(result.error || result.output || `Reviewer failed (exit ${result.exitCode})`);
245
360
  }
246
361
 
362
+ sendTrackerLog(server, "reviewer", "done");
247
363
  const parsed = parseMaybeJsonString(result.output);
248
364
  return { ok: true, review: parsed || result.output, raw: result.output };
249
365
  }
@@ -251,6 +367,12 @@ export async function handleReviewDirect(a, server, extra) {
251
367
  export async function handleToolCall(name, args, server, extra) {
252
368
  const a = asObject(args);
253
369
 
370
+ if (name === "kj_status") {
371
+ const maxLines = a.lines || 50;
372
+ const projectDir = await resolveProjectDir(server);
373
+ return readRunLog(maxLines, projectDir);
374
+ }
375
+
254
376
  if (name === "kj_init") {
255
377
  return runKjCommand({ command: "init", options: a });
256
378
  }
package/src/mcp/tools.js CHANGED
@@ -165,6 +165,16 @@ export const tools = [
165
165
  }
166
166
  }
167
167
  },
168
+ {
169
+ name: "kj_status",
170
+ description: "Show real-time log of the current or last Karajan run. Use this to monitor progress while kj_run/kj_plan/kj_code is executing. Reads from .kj/run.log in the project directory.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ lines: { type: "number", description: "Number of log lines to show (default 50)" }
175
+ }
176
+ }
177
+ },
168
178
  {
169
179
  name: "kj_plan",
170
180
  description: "Generate implementation plan for a task",
@@ -11,6 +11,7 @@ import { runReviewerWithFallback } from "./reviewer-fallback.js";
11
11
  import { runCoderWithFallback } from "./agent-fallback.js";
12
12
  import { invokeSolomon } from "./solomon-escalation.js";
13
13
  import { detectRateLimit } from "../utils/rate-limit-detector.js";
14
+ import { createStallDetector } from "../utils/stall-detector.js";
14
15
 
15
16
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
16
17
  logger.setContext({ iteration, stage: "coder" });
@@ -28,13 +29,21 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
28
29
  detail: { stream, agent: coderRole.provider }
29
30
  }));
30
31
  };
31
- const coderStart = Date.now();
32
- const coderExecResult = await coderRoleInstance.execute({
33
- task: plannedTask,
34
- reviewerFeedback: session.last_reviewer_feedback,
35
- sonarSummary: session.last_sonar_summary,
36
- onOutput: coderOnOutput
32
+ const coderStall = createStallDetector({
33
+ onOutput: coderOnOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
37
34
  });
35
+ const coderStart = Date.now();
36
+ let coderExecResult;
37
+ try {
38
+ coderExecResult = await coderRoleInstance.execute({
39
+ task: plannedTask,
40
+ reviewerFeedback: session.last_reviewer_feedback,
41
+ sonarSummary: session.last_sonar_summary,
42
+ onOutput: coderStall.onOutput
43
+ });
44
+ } finally {
45
+ coderStall.stop();
46
+ }
38
47
  trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
39
48
 
40
49
  if (!coderExecResult.ok) {
@@ -130,10 +139,25 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
130
139
  detail: { refactorer: refactorerRole.provider }
131
140
  })
132
141
  );
142
+ const refactorerOnOutput = ({ stream, line }) => {
143
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "refactorer" }, {
144
+ message: line,
145
+ detail: { stream, agent: refactorerRole.provider }
146
+ }));
147
+ };
148
+ const refactorerStall = createStallDetector({
149
+ onOutput: refactorerOnOutput, emitter, eventBase, stage: "refactorer", provider: refactorerRole.provider
150
+ });
151
+
133
152
  const refRole = new RefactorerRole({ config, logger, emitter, createAgentFn: createAgent });
134
153
  await refRole.init();
135
154
  const refactorerStart = Date.now();
136
- const refResult = await refRole.execute(plannedTask);
155
+ let refResult;
156
+ try {
157
+ refResult = await refRole.execute({ task: plannedTask, onOutput: refactorerStall.onOutput });
158
+ } finally {
159
+ refactorerStall.stop();
160
+ }
137
161
  trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
138
162
  if (!refResult.ok) {
139
163
  const details = refResult.result?.error || refResult.summary || "unknown error";
@@ -392,19 +416,27 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
392
416
  detail: { stream, agent: reviewerRole.provider }
393
417
  }));
394
418
  };
395
- const reviewerStart = Date.now();
396
- const reviewerExec = await runReviewerWithFallback({
397
- reviewerName: reviewerRole.provider,
398
- config,
399
- logger,
400
- emitter,
401
- reviewInput: { task, diff, reviewRules, onOutput: reviewerOnOutput },
402
- session,
403
- iteration,
404
- onAttemptResult: ({ reviewer, result }) => {
405
- trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
406
- }
419
+ const reviewerStall = createStallDetector({
420
+ onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
407
421
  });
422
+ const reviewerStart = Date.now();
423
+ let reviewerExec;
424
+ try {
425
+ reviewerExec = await runReviewerWithFallback({
426
+ reviewerName: reviewerRole.provider,
427
+ config,
428
+ logger,
429
+ emitter,
430
+ reviewInput: { task, diff, reviewRules, onOutput: reviewerStall.onOutput },
431
+ session,
432
+ iteration,
433
+ onAttemptResult: ({ reviewer, result }) => {
434
+ trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
435
+ }
436
+ });
437
+ } finally {
438
+ reviewerStall.stop();
439
+ }
408
440
 
409
441
  if (!reviewerExec.execResult || !reviewerExec.execResult.ok) {
410
442
  const lastAttempt = reviewerExec.attempts.at(-1);
@@ -6,6 +6,7 @@ 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
8
  import { selectModelsForRoles } from "../utils/model-selector.js";
9
+ import { createStallDetector } from "../utils/stall-detector.js";
9
10
 
10
11
  export async function runTriageStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
11
12
  logger.setContext({ iteration: 0, stage: "triage" });
@@ -16,10 +17,26 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
16
17
  })
17
18
  );
18
19
 
20
+ const triageProvider = config?.roles?.triage?.provider || coderRole.provider;
21
+ const triageOnOutput = ({ stream, line }) => {
22
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "triage" }, {
23
+ message: line,
24
+ detail: { stream, agent: triageProvider }
25
+ }));
26
+ };
27
+ const triageStall = createStallDetector({
28
+ onOutput: triageOnOutput, emitter, eventBase, stage: "triage", provider: triageProvider
29
+ });
30
+
19
31
  const triage = new TriageRole({ config, logger, emitter });
20
32
  await triage.init({ task: session.task, sessionId: session.id, iteration: 0 });
21
33
  const triageStart = Date.now();
22
- const triageOutput = await triage.run({ task: session.task });
34
+ let triageOutput;
35
+ try {
36
+ triageOutput = await triage.run({ task: session.task, onOutput: triageStall.onOutput });
37
+ } finally {
38
+ triageStall.stop();
39
+ }
23
40
  trackBudget({
24
41
  role: "triage",
25
42
  provider: config?.roles?.triage?.provider || coderRole.provider,
@@ -115,10 +132,26 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
115
132
  })
116
133
  );
117
134
 
135
+ const researcherProvider = config?.roles?.researcher?.provider || coderRole.provider;
136
+ const researcherOnOutput = ({ stream, line }) => {
137
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "researcher" }, {
138
+ message: line,
139
+ detail: { stream, agent: researcherProvider }
140
+ }));
141
+ };
142
+ const researcherStall = createStallDetector({
143
+ onOutput: researcherOnOutput, emitter, eventBase, stage: "researcher", provider: researcherProvider
144
+ });
145
+
118
146
  const researcher = new ResearcherRole({ config, logger, emitter });
119
147
  await researcher.init({ task: session.task });
120
148
  const researchStart = Date.now();
121
- const researchOutput = await researcher.run({ task: session.task });
149
+ let researchOutput;
150
+ try {
151
+ researchOutput = await researcher.run({ task: session.task, onOutput: researcherStall.onOutput });
152
+ } finally {
153
+ researcherStall.stop();
154
+ }
122
155
  trackBudget({
123
156
  role: "researcher",
124
157
  provider: config?.roles?.researcher?.provider || coderRole.provider,
@@ -160,11 +193,26 @@ export async function runPlannerStage({ config, logger, emitter, eventBase, sess
160
193
  })
161
194
  );
162
195
 
196
+ const plannerOnOutput = ({ stream, line }) => {
197
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "planner" }, {
198
+ message: line,
199
+ detail: { stream, agent: plannerRole.provider }
200
+ }));
201
+ };
202
+ const plannerStall = createStallDetector({
203
+ onOutput: plannerOnOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
204
+ });
205
+
163
206
  const planRole = new PlannerRole({ config, logger, emitter, createAgentFn: createAgent });
164
207
  planRole.context = { task, research: researchContext, triageDecomposition };
165
208
  await planRole.init();
166
209
  const plannerStart = Date.now();
167
- const planResult = await planRole.execute(task);
210
+ let planResult;
211
+ try {
212
+ planResult = await planRole.execute({ task, onOutput: plannerStall.onOutput });
213
+ } finally {
214
+ plannerStall.stop();
215
+ }
168
216
  trackBudget({ role: "planner", provider: plannerRole.provider, model: plannerRole.model, result: planResult.result, duration_ms: Date.now() - plannerStart });
169
217
  await addCheckpoint(session, {
170
218
  stage: "planner",
@@ -65,15 +65,20 @@ export class PlannerRole extends BaseRole {
65
65
  }
66
66
 
67
67
  async execute(input) {
68
- const task = input || this.context?.task || "";
68
+ const { task, onOutput } = typeof input === "string"
69
+ ? { task: input, onOutput: null }
70
+ : { task: input?.task || input || "", onOutput: input?.onOutput || null };
71
+ const taskStr = task || this.context?.task || "";
69
72
  const research = this.context?.research || null;
70
73
  const triageDecomposition = this.context?.triageDecomposition || null;
71
74
  const provider = resolveProvider(this.config);
72
75
 
73
76
  const agent = this._createAgent(provider, this.config, this.logger);
74
- const prompt = buildPrompt({ task, instructions: this.instructions, research, triageDecomposition });
77
+ const prompt = buildPrompt({ task: taskStr, instructions: this.instructions, research, triageDecomposition });
75
78
 
76
- const result = await agent.runTask({ prompt, role: "planner" });
79
+ const runArgs = { prompt, role: "planner" };
80
+ if (onOutput) runArgs.onOutput = onOutput;
81
+ const result = await agent.runTask(runArgs);
77
82
 
78
83
  if (!result.ok) {
79
84
  return {
@@ -36,12 +36,15 @@ export class RefactorerRole extends BaseRole {
36
36
  const task = typeof input === "string"
37
37
  ? input
38
38
  : input?.task || this.context?.task || "";
39
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
39
40
 
40
41
  const provider = resolveProvider(this.config);
41
42
  const agent = this._createAgent(provider, this.config, this.logger);
42
43
 
43
44
  const prompt = buildPrompt({ task, instructions: this.instructions });
44
- const result = await agent.runTask({ prompt, role: "refactorer" });
45
+ const runArgs = { prompt, role: "refactorer" };
46
+ if (onOutput) runArgs.onOutput = onOutput;
47
+ const result = await agent.runTask(runArgs);
45
48
 
46
49
  if (!result.ok) {
47
50
  return {
@@ -64,12 +64,15 @@ export class ResearcherRole extends BaseRole {
64
64
  const task = typeof input === "string"
65
65
  ? input
66
66
  : input?.task || this.context?.task || "";
67
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
67
68
 
68
69
  const provider = resolveProvider(this.config);
69
70
  const agent = this._createAgent(provider, this.config, this.logger);
70
71
 
71
72
  const prompt = buildPrompt({ task, instructions: this.instructions });
72
- const result = await agent.runTask({ prompt, role: "researcher" });
73
+ const runArgs = { prompt, role: "researcher" };
74
+ if (onOutput) runArgs.onOutput = onOutput;
75
+ const result = await agent.runTask(runArgs);
73
76
 
74
77
  if (!result.ok) {
75
78
  return {
@@ -67,12 +67,15 @@ export class TriageRole extends BaseRole {
67
67
  const task = typeof input === "string"
68
68
  ? input
69
69
  : input?.task || this.context?.task || "";
70
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
70
71
 
71
72
  const provider = resolveProvider(this.config);
72
73
  const agent = this._createAgent(provider, this.config, this.logger);
73
74
 
74
75
  const prompt = buildPrompt({ task, instructions: this.instructions });
75
- const result = await agent.runTask({ prompt, role: "triage" });
76
+ const runArgs = { prompt, role: "triage" };
77
+ if (onOutput) runArgs.onOutput = onOutput;
78
+ const result = await agent.runTask(runArgs);
76
79
 
77
80
  if (!result.ok) {
78
81
  return {
@@ -343,6 +343,26 @@ export function printEvent(event) {
343
343
  console.log(`${ANSI.dim}Resume with: kj resume ${event.sessionId} --answer "<response>"${ANSI.reset}`);
344
344
  break;
345
345
 
346
+ case "pipeline:tracker": {
347
+ const trackerStages = event.detail?.stages || [];
348
+ console.log(` ${ANSI.dim}\u250c Pipeline${ANSI.reset}`);
349
+ for (const stage of trackerStages) {
350
+ let stIcon, stColor;
351
+ switch (stage.status) {
352
+ case "done": stIcon = "\u2713"; stColor = ANSI.green; break;
353
+ case "running": stIcon = "\u25b6"; stColor = ANSI.cyan; break;
354
+ case "failed": stIcon = "\u2717"; stColor = ANSI.red; break;
355
+ default: stIcon = "\u00b7"; stColor = ANSI.dim; break;
356
+ }
357
+ const suffix = stage.summary
358
+ ? stage.status === "running" ? ` (${stage.summary})` : ` \u2192 ${stage.summary}`
359
+ : "";
360
+ console.log(` ${ANSI.dim}\u2502${ANSI.reset} ${stColor}${stIcon} ${stage.name}${suffix}${ANSI.reset}`);
361
+ }
362
+ console.log(` ${ANSI.dim}\u2514${ANSI.reset}`);
363
+ break;
364
+ }
365
+
346
366
  case "agent:output":
347
367
  console.log(` \u2502 ${ANSI.dim}${event.message}${ANSI.reset}`);
348
368
  break;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * File-based run logger.
3
+ *
4
+ * Writes progress events to a known file so that external tools
5
+ * (tail -f, kj_status, another Claude process) can monitor what
6
+ * Karajan is doing in real time.
7
+ *
8
+ * Log location: <projectDir>/.kj/run.log (overwritten each run)
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const LOG_FILENAME = "run.log";
15
+
16
+ function resolveLogDir(baseDir) {
17
+ return path.join(baseDir || process.cwd(), ".kj");
18
+ }
19
+
20
+ function resolveLogPath(baseDir) {
21
+ return path.join(resolveLogDir(baseDir), LOG_FILENAME);
22
+ }
23
+
24
+ function ensureDir(dir) {
25
+ try {
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ } catch { /* already exists */ }
28
+ }
29
+
30
+ function formatLine(event) {
31
+ const ts = new Date().toISOString().slice(11, 23);
32
+ const stage = event.stage || event.detail?.stage || "";
33
+ const type = event.type || "info";
34
+ const msg = event.message || "";
35
+ const extra = [];
36
+
37
+ if (event.detail?.provider) extra.push(`agent=${event.detail.provider}`);
38
+ if (event.detail?.lineCount !== undefined) extra.push(`lines=${event.detail.lineCount}`);
39
+ if (event.detail?.elapsedMs !== undefined) extra.push(`elapsed=${Math.round(event.detail.elapsedMs / 1000)}s`);
40
+ if (event.detail?.silenceMs !== undefined) extra.push(`silence=${Math.round(event.detail.silenceMs / 1000)}s`);
41
+ if (event.detail?.severity) extra.push(`severity=${event.detail.severity}`);
42
+ if (event.detail?.stream) extra.push(`stream=${event.detail.stream}`);
43
+
44
+ const extraStr = extra.length ? ` (${extra.join(", ")})` : "";
45
+ return `${ts} [${type}] ${stage ? `[${stage}] ` : ""}${msg}${extraStr}`;
46
+ }
47
+
48
+ export function createRunLog(projectDir) {
49
+ const logPath = resolveLogPath(projectDir);
50
+ const logDir = resolveLogDir(projectDir);
51
+ ensureDir(logDir);
52
+
53
+ // Truncate/create the log file
54
+ fs.writeFileSync(logPath, `--- Karajan run started at ${new Date().toISOString()} ---\n`);
55
+
56
+ let fd = null;
57
+ try {
58
+ fd = fs.openSync(logPath, "a");
59
+ } catch {
60
+ // If we can't open for append, use writeFile fallback
61
+ }
62
+
63
+ function write(line) {
64
+ try {
65
+ if (fd !== null) {
66
+ fs.writeSync(fd, line + "\n");
67
+ } else {
68
+ fs.appendFileSync(logPath, line + "\n");
69
+ }
70
+ } catch { /* best-effort */ }
71
+ }
72
+
73
+ function logEvent(event) {
74
+ write(formatLine(event));
75
+ }
76
+
77
+ function logText(text) {
78
+ const ts = new Date().toISOString().slice(11, 23);
79
+ write(`${ts} ${text}`);
80
+ }
81
+
82
+ function close() {
83
+ try {
84
+ if (fd !== null) {
85
+ fs.closeSync(fd);
86
+ fd = null;
87
+ }
88
+ } catch { /* best-effort */ }
89
+ }
90
+
91
+ return {
92
+ logEvent,
93
+ logText,
94
+ close,
95
+ get path() { return logPath; }
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Read the current run log contents.
101
+ * Returns the last N lines (default 50).
102
+ */
103
+ export function readRunLog(maxLines = 50, projectDir) {
104
+ const logPath = resolveLogPath(projectDir);
105
+ try {
106
+ const content = fs.readFileSync(logPath, "utf8");
107
+ const lines = content.split("\n").filter(Boolean);
108
+ const total = lines.length;
109
+ const shown = lines.slice(-maxLines);
110
+ return {
111
+ ok: true,
112
+ path: logPath,
113
+ totalLines: total,
114
+ lines: shown,
115
+ summary: shown.join("\n")
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ path: logPath,
121
+ error: err.code === "ENOENT"
122
+ ? "No active run log found. Start a run with kj_run first."
123
+ : `Failed to read log: ${err.message}`
124
+ };
125
+ }
126
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Stall detector for agent execution.
3
+ *
4
+ * Wraps an onOutput callback to track activity and emit heartbeat / stall
5
+ * warnings when an agent stops producing output for too long.
6
+ *
7
+ * Usage:
8
+ * const detector = createStallDetector({ onOutput, emitter, eventBase, stage, provider, stallTimeoutMs });
9
+ * // pass detector.onOutput to the agent
10
+ * // when done: detector.stop();
11
+ */
12
+
13
+ import { emitProgress, makeEvent } from "./events.js";
14
+
15
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; // heartbeat every 30s
16
+ const DEFAULT_STALL_TIMEOUT_MS = 120_000; // warn after 2min silence
17
+ const DEFAULT_CRITICAL_TIMEOUT_MS = 300_000; // critical after 5min silence
18
+
19
+ export function createStallDetector({
20
+ onOutput,
21
+ emitter,
22
+ eventBase,
23
+ stage,
24
+ provider,
25
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
26
+ stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
27
+ criticalTimeoutMs = DEFAULT_CRITICAL_TIMEOUT_MS
28
+ }) {
29
+ let lastActivityAt = Date.now();
30
+ let lineCount = 0;
31
+ let bytesReceived = 0;
32
+ let stallWarned = false;
33
+ let criticalWarned = false;
34
+ let heartbeatTimer = null;
35
+ const startedAt = Date.now();
36
+
37
+ function emitHeartbeat() {
38
+ const now = Date.now();
39
+ const silenceMs = now - lastActivityAt;
40
+ const elapsedMs = now - startedAt;
41
+
42
+ if (silenceMs >= criticalTimeoutMs && !criticalWarned) {
43
+ criticalWarned = true;
44
+ emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
45
+ status: "critical",
46
+ message: `Agent ${provider} unresponsive for ${Math.round(silenceMs / 1000)}s — may be hung`,
47
+ detail: {
48
+ provider,
49
+ silenceMs,
50
+ elapsedMs,
51
+ lineCount,
52
+ bytesReceived,
53
+ severity: "critical"
54
+ }
55
+ }));
56
+ } else if (silenceMs >= stallTimeoutMs && !stallWarned) {
57
+ stallWarned = true;
58
+ emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
59
+ status: "warning",
60
+ message: `Agent ${provider} silent for ${Math.round(silenceMs / 1000)}s — still waiting`,
61
+ detail: {
62
+ provider,
63
+ silenceMs,
64
+ elapsedMs,
65
+ lineCount,
66
+ bytesReceived,
67
+ severity: "warning"
68
+ }
69
+ }));
70
+ } else if (silenceMs < stallTimeoutMs) {
71
+ // Reset warning flags when activity resumes
72
+ stallWarned = false;
73
+ criticalWarned = false;
74
+
75
+ emitProgress(emitter, makeEvent("agent:heartbeat", { ...eventBase, stage }, {
76
+ message: `Agent ${provider} active — ${lineCount} lines, ${Math.round(elapsedMs / 1000)}s elapsed`,
77
+ detail: {
78
+ provider,
79
+ elapsedMs,
80
+ lineCount,
81
+ bytesReceived
82
+ }
83
+ }));
84
+ }
85
+ }
86
+
87
+ // Start periodic heartbeat
88
+ heartbeatTimer = setInterval(emitHeartbeat, heartbeatIntervalMs);
89
+
90
+ function wrappedOnOutput(data) {
91
+ lastActivityAt = Date.now();
92
+ lineCount++;
93
+ bytesReceived += data.line?.length || 0;
94
+
95
+ // Reset stall flags on new activity
96
+ stallWarned = false;
97
+ criticalWarned = false;
98
+
99
+ // Forward to the original callback
100
+ if (onOutput) {
101
+ onOutput(data);
102
+ }
103
+ }
104
+
105
+ function stop() {
106
+ if (heartbeatTimer) {
107
+ clearInterval(heartbeatTimer);
108
+ heartbeatTimer = null;
109
+ }
110
+ }
111
+
112
+ function stats() {
113
+ return {
114
+ lineCount,
115
+ bytesReceived,
116
+ elapsedMs: Date.now() - startedAt,
117
+ lastActivityMs: Date.now() - lastActivityAt
118
+ };
119
+ }
120
+
121
+ return {
122
+ onOutput: wrappedOnOutput,
123
+ stop,
124
+ stats
125
+ };
126
+ }