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 +2 -1
- package/docs/README.es.md +2 -1
- package/package.json +1 -1
- package/src/agents/claude-agent.js +79 -1
- package/src/mcp/progress.js +81 -2
- package/src/mcp/server-handlers.js +128 -6
- package/src/mcp/tools.js +10 -0
- package/src/orchestrator/iteration-stages.js +51 -19
- package/src/orchestrator/pre-loop-stages.js +51 -3
- package/src/roles/planner-role.js +8 -3
- package/src/roles/refactorer-role.js +4 -1
- package/src/roles/researcher-role.js +4 -1
- package/src/roles/triage-role.js +4 -1
- package/src/utils/display.js +20 -0
- package/src/utils/run-log.js +126 -0
- package/src/utils/stall-detector.js +126 -0
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
package/src/mcp/progress.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
package/src/roles/triage-role.js
CHANGED
|
@@ -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
|
|
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 {
|
package/src/utils/display.js
CHANGED
|
@@ -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
|
+
}
|