karajan-code 1.35.0 → 1.36.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
@@ -152,6 +152,7 @@ Use `kj roles show <role>` to inspect any template.
152
152
  Karajan auto-detects and auto-configures everything it can:
153
153
 
154
154
  - **TDD**: Detects test framework (vitest, jest, mocha) → auto-enables TDD
155
+ - **Bootstrap gate**: Validates all prerequisites (git repo, remote, config, agents, SonarQube) before any tool runs. Fails hard with actionable fix instructions — never silently degrades
155
156
  - **SonarQube**: Auto-starts Docker container, generates config if missing
156
157
  - **Pipeline complexity**: Triage classifies task → trivial tasks skip reviewer loop
157
158
  - **Provider outages**: Retries on 500/502/503/504 with backoff (same as rate limits)
@@ -163,9 +164,9 @@ No per-project configuration required. If you want to customize, config is layer
163
164
 
164
165
  Because it should be.
165
166
 
166
- Karajan has **1847 tests** across 149 files. It runs on Node.js without a build step. You can read the source, understand it, fork it, and modify it without a TypeScript compiler between you and the code.
167
+ Karajan has **1966 tests** across 157 files. It runs on Node.js without a build step. You can read the source, understand it, fork it, and modify it without a TypeScript compiler between you and the code.
167
168
 
168
- This is a deliberate choice, not a limitation. The tests are the type safety. The legibility is a feature. **52 releases in 23 days** — that velocity is possible precisely because vanilla JS with good tests lets you move fast without fear.
169
+ This is a deliberate choice, not a limitation. The tests are the type safety. The legibility is a feature. **55 releases in 26 days** — that velocity is possible precisely because vanilla JS with good tests lets you move fast without fear.
169
170
 
170
171
  ## Recommended companions
171
172
 
package/docs/README.es.md CHANGED
@@ -29,8 +29,9 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
29
29
 
30
30
  **Caracteristicas principales:**
31
31
  - **Pipeline multi-agente** con 11 roles configurables
32
- - **4 agentes de IA soportados**: Claude, Codex, Gemini, Aider
33
- - **Servidor MCP** con 15 herramientas — usa `kj` desde Claude, Codex o cualquier host compatible con MCP sin salir de tu agente. [Ver configuracion MCP](#servidor-mcp)
32
+ - **5 agentes de IA soportados**: Claude, Codex, Gemini, Aider, OpenCode
33
+ - **Servidor MCP** con 20 herramientas — usa `kj` desde Claude, Codex o cualquier host compatible con MCP sin salir de tu agente. [Ver configuracion MCP](#servidor-mcp)
34
+ - **Bootstrap obligatorio** — valida prerequisitos del entorno (git, remote, config, agentes, SonarQube) antes de cada ejecucion. Si algo falta, para con instrucciones claras
34
35
  - **TDD obligatorio** — se exigen cambios en tests cuando se modifican ficheros fuente
35
36
  - **Integracion con SonarQube** — analisis estatico con quality gates (requiere [Docker](#requisitos))
36
37
  - **Perfiles de revision** — standard, strict, relaxed, paranoid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.35.0",
3
+ "version": "1.36.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",
@@ -5,21 +5,28 @@ import { resolveBin } from "./resolve-bin.js";
5
5
  export class AiderAgent extends BaseAgent {
6
6
  async runTask(task) {
7
7
  const role = task.role || "coder";
8
- const args = ["--yes", "--message", task.prompt];
9
8
  const model = this.getRoleModel(role);
10
- if (model) args.push("--model", model);
11
- const res = await runCommand(resolveBin("aider"), args, {
12
- onOutput: task.onOutput,
13
- silenceTimeoutMs: task.silenceTimeoutMs,
14
- timeout: task.timeoutMs
15
- });
16
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
9
+ const result = await this._exec(task, model);
10
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
11
+ this.logger?.warn(`Aider model "${model}" not supported — retrying with agent default`);
12
+ return this._exec(task, null);
13
+ }
14
+ return result;
17
15
  }
18
16
 
19
17
  async reviewTask(task) {
20
18
  const role = task.role || "reviewer";
21
- const args = ["--yes", "--message", task.prompt];
22
19
  const model = this.getRoleModel(role);
20
+ const result = await this._exec(task, model);
21
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
22
+ this.logger?.warn(`Aider model "${model}" not supported — retrying with agent default`);
23
+ return this._exec(task, null);
24
+ }
25
+ return result;
26
+ }
27
+
28
+ async _exec(task, model) {
29
+ const args = ["--yes", "--message", task.prompt];
23
30
  if (model) args.push("--model", model);
24
31
  const res = await runCommand(resolveBin("aider"), args, {
25
32
  onOutput: task.onOutput,
@@ -1,3 +1,12 @@
1
+ const MODEL_NOT_SUPPORTED_PATTERNS = [
2
+ /model.{0,30}is not supported/i,
3
+ /model.{0,30}not available/i,
4
+ /model.{0,30}does not exist/i,
5
+ /unsupported model/i,
6
+ /invalid model/i,
7
+ /model_not_found/i
8
+ ];
9
+
1
10
  export class BaseAgent {
2
11
  constructor(name, config, logger) {
3
12
  this.name = name;
@@ -24,4 +33,10 @@ export class BaseAgent {
24
33
  if (role === "reviewer") return false;
25
34
  return Boolean(this.config?.coder_options?.auto_approve);
26
35
  }
36
+
37
+ isModelNotSupportedError(result) {
38
+ const text = [result?.error, result?.output, result?.stderr, result?.stdout]
39
+ .filter(Boolean).join("\n");
40
+ return MODEL_NOT_SUPPORTED_PATTERNS.some(re => re.test(text));
41
+ }
27
42
  }
@@ -35,6 +35,29 @@ function collectAssistantText(obj) {
35
35
  .map(block => block.text);
36
36
  }
37
37
 
38
+ /**
39
+ * Extract usage metrics from stream-json/json NDJSON output.
40
+ * Looks for the "result" line which contains total_cost_usd,
41
+ * usage.input_tokens/output_tokens, and modelUsage.
42
+ * Returns an object with tokens_in, tokens_out, cost_usd, model or null if not found.
43
+ */
44
+ export function extractUsageFromStreamJson(raw) {
45
+ const lines = (raw || "").split("\n").filter(Boolean);
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ const obj = tryParseJson(lines[i]);
48
+ if (!obj || obj.type !== "result") continue;
49
+
50
+ const tokens_in = obj.usage?.input_tokens ?? 0;
51
+ const tokens_out = obj.usage?.output_tokens ?? 0;
52
+ const cost_usd = obj.total_cost_usd ?? undefined;
53
+ const modelUsage = obj.modelUsage;
54
+ const model = modelUsage ? Object.keys(modelUsage)[0] || null : null;
55
+
56
+ return { tokens_in, tokens_out, cost_usd, model };
57
+ }
58
+ return null;
59
+ }
60
+
38
61
  /**
39
62
  * Extract the final text result from stream-json NDJSON output.
40
63
  * Each line is a JSON object. We collect assistant text content from
@@ -135,8 +158,28 @@ const ALLOWED_TOOLS = [
135
158
  export class ClaudeAgent extends BaseAgent {
136
159
  async runTask(task) {
137
160
  const role = task.role || "coder";
138
- const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS];
139
161
  const model = this.getRoleModel(role);
162
+ const result = await this._runTaskExec(task, model, role);
163
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
164
+ this.logger?.warn(`Claude model "${model}" not supported — retrying with agent default`);
165
+ return this._runTaskExec(task, null, role);
166
+ }
167
+ return result;
168
+ }
169
+
170
+ async reviewTask(task) {
171
+ const role = task.role || "reviewer";
172
+ const model = this.getRoleModel(role);
173
+ const result = await this._reviewTaskExec(task, model);
174
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
175
+ this.logger?.warn(`Claude model "${model}" not supported — retrying with agent default`);
176
+ return this._reviewTaskExec(task, null);
177
+ }
178
+ return result;
179
+ }
180
+
181
+ async _runTaskExec(task, model, role) {
182
+ const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS];
140
183
  if (model) args.push("--model", model);
141
184
 
142
185
  // Use stream-json when onOutput is provided to get real-time feedback
@@ -150,7 +193,8 @@ export class ClaudeAgent extends BaseAgent {
150
193
  }));
151
194
  const raw = pickOutput(res);
152
195
  const output = extractTextFromStreamJson(raw);
153
- return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode };
196
+ const usage = extractUsageFromStreamJson(raw);
197
+ return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
154
198
  }
155
199
 
156
200
  // Without streaming, use json output to get structured response via stderr
@@ -158,12 +202,12 @@ export class ClaudeAgent extends BaseAgent {
158
202
  const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts());
159
203
  const raw = pickOutput(res);
160
204
  const output = extractTextFromStreamJson(raw);
161
- return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode };
205
+ const usage = extractUsageFromStreamJson(raw);
206
+ return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
162
207
  }
163
208
 
164
- async reviewTask(task) {
209
+ async _reviewTaskExec(task, model) {
165
210
  const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS, "--output-format", "stream-json", "--verbose"];
166
- const model = this.getRoleModel(task.role || "reviewer");
167
211
  if (model) args.push("--model", model);
168
212
  const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts({
169
213
  onOutput: task.onOutput,
@@ -171,6 +215,7 @@ export class ClaudeAgent extends BaseAgent {
171
215
  timeout: task.timeoutMs
172
216
  }));
173
217
  const raw = pickOutput(res);
174
- return { ok: res.exitCode === 0, output: raw, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode };
218
+ const usage = extractUsageFromStreamJson(raw);
219
+ return { ok: res.exitCode === 0, output: raw, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
175
220
  }
176
221
  }
@@ -2,27 +2,48 @@ 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 token usage from Codex CLI stdout.
7
+ * Codex prints "tokens used\n<number>" at the end, where number may have comma separators.
8
+ * Returns { tokens_out } with the total token count, or null if not found.
9
+ * Since Codex doesn't split input/output, we assign the total to tokens_out
10
+ * as a conservative estimate for cost calculation.
11
+ */
12
+ export function extractCodexTokens(stdout) {
13
+ const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
14
+ if (!match) return null;
15
+ const total = Number(match[1].replace(/,/g, ""));
16
+ if (!Number.isFinite(total) || total <= 0) return null;
17
+ return { tokens_in: 0, tokens_out: total };
18
+ }
19
+
5
20
  export class CodexAgent extends BaseAgent {
6
21
  async runTask(task) {
7
22
  const role = task.role || "coder";
8
- const args = ["exec"];
9
23
  const model = this.getRoleModel(role);
10
- if (model) args.push("--model", model);
11
- if (this.isAutoApproveEnabled(role)) args.push("--full-auto");
12
- args.push("-");
13
- const res = await runCommand(resolveBin("codex"), args, {
14
- onOutput: task.onOutput,
15
- silenceTimeoutMs: task.silenceTimeoutMs,
16
- timeout: task.timeoutMs,
17
- input: task.prompt
18
- });
19
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
24
+ const result = await this._exec(task, model, role);
25
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
26
+ this.logger?.warn(`Codex model "${model}" not supported — retrying with agent default`);
27
+ return this._exec(task, null, role);
28
+ }
29
+ return result;
20
30
  }
21
31
 
22
32
  async reviewTask(task) {
33
+ const role = task.role || "reviewer";
34
+ const model = this.getRoleModel(role);
35
+ const result = await this._exec(task, model, role);
36
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
37
+ this.logger?.warn(`Codex model "${model}" not supported — retrying with agent default`);
38
+ return this._exec(task, null, role);
39
+ }
40
+ return result;
41
+ }
42
+
43
+ async _exec(task, model, role) {
23
44
  const args = ["exec"];
24
- const model = this.getRoleModel(task.role || "reviewer");
25
45
  if (model) args.push("--model", model);
46
+ if (role !== "reviewer" && this.isAutoApproveEnabled(role)) args.push("--full-auto");
26
47
  args.push("-");
27
48
  const res = await runCommand(resolveBin("codex"), args, {
28
49
  onOutput: task.onOutput,
@@ -30,6 +51,7 @@ export class CodexAgent extends BaseAgent {
30
51
  timeout: task.timeoutMs,
31
52
  input: task.prompt
32
53
  });
33
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
54
+ const usage = extractCodexTokens(res.stdout);
55
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
34
56
  }
35
57
  }
@@ -5,21 +5,29 @@ import { resolveBin } from "./resolve-bin.js";
5
5
  export class GeminiAgent extends BaseAgent {
6
6
  async runTask(task) {
7
7
  const role = task.role || "coder";
8
- const args = ["-p", task.prompt];
9
8
  const model = this.getRoleModel(role);
10
- if (model) args.push("--model", model);
11
- const res = await runCommand(resolveBin("gemini"), args, {
12
- onOutput: task.onOutput,
13
- silenceTimeoutMs: task.silenceTimeoutMs,
14
- timeout: task.timeoutMs
15
- });
16
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
9
+ const result = await this._exec(task, model, "run");
10
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
11
+ this.logger?.warn(`Gemini model "${model}" not supported — retrying with agent default`);
12
+ return this._exec(task, null, "run");
13
+ }
14
+ return result;
17
15
  }
18
16
 
19
17
  async reviewTask(task) {
20
18
  const role = task.role || "reviewer";
21
- const args = ["-p", task.prompt, "--output-format", "json"];
22
19
  const model = this.getRoleModel(role);
20
+ const result = await this._exec(task, model, "review");
21
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
22
+ this.logger?.warn(`Gemini model "${model}" not supported — retrying with agent default`);
23
+ return this._exec(task, null, "review");
24
+ }
25
+ return result;
26
+ }
27
+
28
+ async _exec(task, model, mode) {
29
+ const args = ["-p", task.prompt];
30
+ if (mode === "review") args.push("--output-format", "json");
23
31
  if (model) args.push("--model", model);
24
32
  const res = await runCommand(resolveBin("gemini"), args, {
25
33
  onOutput: task.onOutput,
@@ -49,15 +49,16 @@ export function buildDefaultPricingTable() {
49
49
  }
50
50
 
51
51
  // Auto-register built-in models
52
+ // Names must match what each CLI accepts as --model argument
52
53
  registerModel("claude", { provider: "anthropic", pricing: { input_per_million: 3, output_per_million: 15 } });
53
- registerModel("claude/sonnet", { provider: "anthropic", pricing: { input_per_million: 3, output_per_million: 15 } });
54
- registerModel("claude/opus", { provider: "anthropic", pricing: { input_per_million: 15, output_per_million: 75 } });
55
- registerModel("claude/haiku", { provider: "anthropic", pricing: { input_per_million: 0.25, output_per_million: 1.25 } });
54
+ registerModel("sonnet", { provider: "anthropic", pricing: { input_per_million: 3, output_per_million: 15 } });
55
+ registerModel("opus", { provider: "anthropic", pricing: { input_per_million: 15, output_per_million: 75 } });
56
+ registerModel("haiku", { provider: "anthropic", pricing: { input_per_million: 0.25, output_per_million: 1.25 } });
56
57
  registerModel("codex", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
57
- registerModel("codex/o4-mini", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
58
- registerModel("codex/o3", { provider: "openai", pricing: { input_per_million: 10, output_per_million: 40 } });
58
+ registerModel("o4-mini", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
59
+ registerModel("o3", { provider: "openai", pricing: { input_per_million: 10, output_per_million: 40 } });
59
60
  registerModel("gemini", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
60
- registerModel("gemini/pro", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
61
- registerModel("gemini/flash", { provider: "google", pricing: { input_per_million: 0.075, output_per_million: 0.3 } });
61
+ registerModel("gemini-2.5-pro", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
62
+ registerModel("gemini-2.0-flash", { provider: "google", pricing: { input_per_million: 0.075, output_per_million: 0.3 } });
62
63
  registerModel("aider", { provider: "aider", pricing: { input_per_million: 3, output_per_million: 15 } });
63
64
  registerModel("opencode", { provider: "opencode", pricing: { input_per_million: 0, output_per_million: 0 } });
@@ -5,22 +5,29 @@ import { resolveBin } from "./resolve-bin.js";
5
5
  export class OpenCodeAgent extends BaseAgent {
6
6
  async runTask(task) {
7
7
  const role = task.role || "coder";
8
- const args = ["run"];
9
8
  const model = this.getRoleModel(role);
10
- if (model) args.push("--model", model);
11
- args.push(task.prompt);
12
- const res = await runCommand(resolveBin("opencode"), args, {
13
- onOutput: task.onOutput,
14
- silenceTimeoutMs: task.silenceTimeoutMs,
15
- timeout: task.timeoutMs
16
- });
17
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
9
+ const result = await this._exec(task, model, false);
10
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
11
+ this.logger?.warn(`OpenCode model "${model}" not supported — retrying with agent default`);
12
+ return this._exec(task, null, false);
13
+ }
14
+ return result;
18
15
  }
19
16
 
20
17
  async reviewTask(task) {
21
18
  const role = task.role || "reviewer";
22
- const args = ["run", "--format", "json"];
23
19
  const model = this.getRoleModel(role);
20
+ const result = await this._exec(task, model, true);
21
+ if (!result.ok && model && this.isModelNotSupportedError(result)) {
22
+ this.logger?.warn(`OpenCode model "${model}" not supported — retrying with agent default`);
23
+ return this._exec(task, null, true);
24
+ }
25
+ return result;
26
+ }
27
+
28
+ async _exec(task, model, jsonFormat) {
29
+ const args = ["run"];
30
+ if (jsonFormat) args.push("--format", "json");
24
31
  if (model) args.push("--model", model);
25
32
  args.push(task.prompt);
26
33
  const res = await runCommand(resolveBin("opencode"), args, {
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import fs from "node:fs/promises";
2
3
  import { fileURLToPath } from "node:url";
4
+ import os from "node:os";
3
5
  import path from "node:path";
4
6
  import { runCommand } from "../utils/process.js";
5
7
  import { exists } from "../utils/fs.js";
@@ -239,6 +241,101 @@ async function checkRuleFiles(config) {
239
241
  ];
240
242
  }
241
243
 
244
+ /**
245
+ * Detect duplicate TOML table headers (e.g. [mcp_servers."karajan-mcp"] appearing twice).
246
+ * Full TOML parsing would require a dependency — this catches the most common config error.
247
+ */
248
+ function findDuplicateTomlKeys(content) {
249
+ const tableHeaders = [];
250
+ const duplicates = [];
251
+ for (const line of content.split("\n")) {
252
+ const match = line.match(/^\s*\[([^\]]+)\]\s*$/);
253
+ if (match) {
254
+ const key = match[1].trim();
255
+ if (tableHeaders.includes(key)) {
256
+ duplicates.push(key);
257
+ } else {
258
+ tableHeaders.push(key);
259
+ }
260
+ }
261
+ }
262
+ return duplicates;
263
+ }
264
+
265
+ async function checkAgentConfigs() {
266
+ const checks = [];
267
+ const home = os.homedir();
268
+
269
+ // Claude: ~/.claude.json
270
+ const claudeJsonPath = path.join(home, ".claude.json");
271
+ try {
272
+ const raw = await fs.readFile(claudeJsonPath, "utf8");
273
+ JSON.parse(raw);
274
+ checks.push({ name: "agent-config:claude", label: "Agent config: claude (~/.claude.json)", ok: true, detail: "Valid JSON", fix: null });
275
+ } catch (err) {
276
+ if (err.code === "ENOENT") {
277
+ // File doesn't exist — not an error, Claude may not be configured
278
+ } else {
279
+ checks.push({
280
+ name: "agent-config:claude",
281
+ label: "Agent config: claude (~/.claude.json)",
282
+ ok: false,
283
+ detail: `Invalid JSON: ${err.message.split("\n")[0]}`,
284
+ fix: "Fix the JSON syntax in ~/.claude.json. Common issues: trailing commas, missing quotes."
285
+ });
286
+ }
287
+ }
288
+
289
+ // Codex: ~/.codex/config.toml
290
+ const codexTomlPath = path.join(home, ".codex", "config.toml");
291
+ try {
292
+ const raw = await fs.readFile(codexTomlPath, "utf8");
293
+ const duplicates = findDuplicateTomlKeys(raw);
294
+ if (duplicates.length > 0) {
295
+ checks.push({
296
+ name: "agent-config:codex",
297
+ label: "Agent config: codex (~/.codex/config.toml)",
298
+ ok: false,
299
+ detail: `Duplicate TOML keys: ${duplicates.join(", ")}`,
300
+ fix: `Remove duplicate entries in ~/.codex/config.toml: ${duplicates.join(", ")}`
301
+ });
302
+ } else {
303
+ checks.push({ name: "agent-config:codex", label: "Agent config: codex (~/.codex/config.toml)", ok: true, detail: "Valid TOML (no duplicate keys)", fix: null });
304
+ }
305
+ } catch (err) {
306
+ if (err.code !== "ENOENT") {
307
+ checks.push({
308
+ name: "agent-config:codex",
309
+ label: "Agent config: codex (~/.codex/config.toml)",
310
+ ok: false,
311
+ detail: `Cannot read: ${err.message.split("\n")[0]}`,
312
+ fix: "Check file permissions on ~/.codex/config.toml"
313
+ });
314
+ }
315
+ }
316
+
317
+ // KJ config: ~/.karajan/kj.config.yml (validate YAML)
318
+ const kjConfigPath = getConfigPath();
319
+ try {
320
+ const raw = await fs.readFile(kjConfigPath, "utf8");
321
+ const yaml = await import("js-yaml");
322
+ yaml.default.load(raw);
323
+ checks.push({ name: "agent-config:karajan", label: "Agent config: karajan (kj.config.yml)", ok: true, detail: "Valid YAML", fix: null });
324
+ } catch (err) {
325
+ if (err.code !== "ENOENT") {
326
+ checks.push({
327
+ name: "agent-config:karajan",
328
+ label: "Agent config: karajan (kj.config.yml)",
329
+ ok: false,
330
+ detail: `Invalid YAML: ${err.message.split("\n")[0]}`,
331
+ fix: `Fix YAML syntax in ${kjConfigPath}. Run 'kj init' to regenerate if needed.`
332
+ });
333
+ }
334
+ }
335
+
336
+ return checks;
337
+ }
338
+
242
339
  export async function runChecks({ config }) {
243
340
  const checks = [];
244
341
 
@@ -260,6 +357,7 @@ export async function runChecks({ config }) {
260
357
  checks.push(...await checkBecariaInfra(config));
261
358
  }
262
359
 
360
+ checks.push(...await checkAgentConfigs());
263
361
  checks.push(...await checkRuleFiles(config));
264
362
  checks.push(await checkRtk());
265
363
 
@@ -30,10 +30,17 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
30
30
  });
31
31
  }
32
32
 
33
+ const solomonError = ruling.result?.error;
34
+ if (!ruling.ok && solomonError) {
35
+ logger.warn(`Solomon execution failed: ${solomonError}`);
36
+ }
37
+
33
38
  emitProgress(
34
39
  emitter,
35
40
  makeEvent("solomon:end", { ...eventBase, stage: "solomon" }, {
36
- message: `Solomon ruling: ${ruling.result?.ruling || "unknown"}`,
41
+ message: ruling.ok
42
+ ? `Solomon ruling: ${ruling.result?.ruling || "unknown"}`
43
+ : `Solomon failed: ${(solomonError || ruling.summary || "unknown error").slice(0, 200)}`,
37
44
  detail: ruling.result
38
45
  })
39
46
  );
@@ -43,13 +50,15 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
43
50
  iteration,
44
51
  ruling: ruling.result?.ruling,
45
52
  escalate: ruling.result?.escalate,
53
+ error: solomonError ? solomonError.slice(0, 500) : undefined,
46
54
  subtask: ruling.result?.subtask?.title || null
47
55
  });
48
56
 
49
57
  if (!ruling.ok) {
58
+ const reason = ruling.result?.escalate_reason || solomonError || ruling.summary;
50
59
  return escalateToHuman({
51
60
  askQuestion, session, emitter, eventBase, stage, iteration,
52
- conflict: { ...conflict, solomonReason: ruling.result?.escalate_reason }
61
+ conflict: { ...conflict, solomonReason: reason }
53
62
  });
54
63
  }
55
64
 
@@ -62,12 +62,28 @@ function buildPrompt({ conflict, task, instructions }) {
62
62
  const iterationCount = conflict?.iterationCount ?? "?";
63
63
  const maxIterations = conflict?.maxIterations ?? "?";
64
64
 
65
+ const isFirstRejection = conflict?.isFirstRejection ?? false;
66
+ const isRepeat = conflict?.isRepeat ?? false;
67
+
65
68
  sections.push(
66
69
  `## Conflict context`,
67
70
  `Stage: ${stage}`,
68
- `Iterations exhausted: ${iterationCount}/${maxIterations}`
71
+ `Iterations exhausted: ${iterationCount}/${maxIterations}`,
72
+ `isFirstRejection: ${isFirstRejection}`,
73
+ `isRepeat: ${isRepeat}`
69
74
  );
70
75
 
76
+ if (conflict?.issueCategories) {
77
+ sections.push(`## Issue categories\n${JSON.stringify(conflict.issueCategories, null, 2)}`);
78
+ }
79
+
80
+ if (conflict?.blockingIssues?.length) {
81
+ const issueList = conflict.blockingIssues
82
+ .map((issue, i) => `${i + 1}. [${issue.severity || "unknown"}] ${issue.description || issue}`)
83
+ .join("\n");
84
+ sections.push(`## Blocking issues\n${issueList}`);
85
+ }
86
+
71
87
  if (task) {
72
88
  sections.push(`## Original task\n${task}`);
73
89
  }
@@ -40,18 +40,22 @@ export function extractUsageMetrics(result, defaultModel = null) {
40
40
  null;
41
41
 
42
42
  // If no real token data AND no explicit cost, estimate from prompt/output sizes.
43
- // Estimation is opt-in: only triggered when result.promptSize is explicitly provided.
43
+ // Primary: uses result.promptSize when explicitly provided.
44
+ // Fallback: estimates from result.output or result.error text length.
44
45
  let estimated = false;
45
46
  let finalTokensIn = tokens_in;
46
47
  let finalTokensOut = tokens_out;
47
48
  const hasExplicitCost = cost_usd !== undefined && cost_usd !== null && cost_usd !== "";
48
- if (!tokens_in && !tokens_out && !hasExplicitCost && result?.promptSize > 0) {
49
- const promptSize = result.promptSize;
50
- const outputSize = (result?.output || result?.summary || "").length;
51
- const est = estimateTokens(promptSize, outputSize);
52
- finalTokensIn = est.tokens_in;
53
- finalTokensOut = est.tokens_out;
54
- estimated = true;
49
+ if (!tokens_in && !tokens_out && !hasExplicitCost) {
50
+ const outputText = result?.output || result?.error || result?.summary || "";
51
+ const promptSize = result?.promptSize || 0;
52
+ const MIN_TEXT_FOR_ESTIMATION = 40;
53
+ if (promptSize > 0 || outputText.length >= MIN_TEXT_FOR_ESTIMATION) {
54
+ const est = estimateTokens(promptSize, outputText.length);
55
+ finalTokensIn = est.tokens_in;
56
+ finalTokensOut = est.tokens_out;
57
+ estimated = true;
58
+ }
55
59
  }
56
60
 
57
61
  return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cost_usd, model, estimated };
@@ -1,7 +1,7 @@
1
1
  const DEFAULT_MODEL_TIERS = {
2
- claude: { trivial: "claude/haiku", simple: "claude/haiku", medium: "claude/sonnet", complex: "claude/opus" },
3
- codex: { trivial: "codex/o4-mini", simple: "codex/o4-mini", medium: "codex/o4-mini", complex: "codex/o3" },
4
- gemini: { trivial: "gemini/flash", simple: "gemini/flash", medium: "gemini/pro", complex: "gemini/pro" },
2
+ claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
3
+ codex: { trivial: "o4-mini", simple: "o4-mini", medium: "o4-mini", complex: "o3" },
4
+ gemini: { trivial: "gemini-2.0-flash", simple: "gemini-2.0-flash", medium: "gemini-2.5-pro", complex: "gemini-2.5-pro" },
5
5
  aider: { trivial: null, simple: null, medium: null, complex: null }
6
6
  };
7
7
 
@@ -51,7 +51,7 @@ export function createStallDetector({
51
51
  lastCriticalWarnAt = now;
52
52
  emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
53
53
  status: "critical",
54
- message: `Agent ${provider} unresponsive for ${Math.round(silenceMs / 1000)}s — may be hung`,
54
+ message: `[${stage}] Agent ${provider} unresponsive for ${Math.round(silenceMs / 1000)}s — may be hung`,
55
55
  detail: {
56
56
  provider,
57
57
  silenceMs,
@@ -65,7 +65,7 @@ export function createStallDetector({
65
65
  lastStallWarnAt = now;
66
66
  emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
67
67
  status: "warning",
68
- message: `Agent ${provider} silent for ${Math.round(silenceMs / 1000)}s — still waiting`,
68
+ message: `[${stage}] Agent ${provider} silent for ${Math.round(silenceMs / 1000)}s — still waiting`,
69
69
  detail: {
70
70
  provider,
71
71
  silenceMs,
@@ -79,8 +79,8 @@ export function createStallDetector({
79
79
 
80
80
  emitProgress(emitter, makeEvent("agent:heartbeat", { ...eventBase, stage }, {
81
81
  message: silenceMs < stallTimeoutMs
82
- ? `Agent ${provider} active — ${lineCount} lines, ${Math.round(elapsedMs / 1000)}s elapsed`
83
- : `Agent ${provider} waiting — silent ${Math.round(silenceMs / 1000)}s, ${Math.round(elapsedMs / 1000)}s elapsed`,
82
+ ? `[${stage}] Agent ${provider} active — ${lineCount} lines, ${Math.round(elapsedMs / 1000)}s elapsed`
83
+ : `[${stage}] Agent ${provider} waiting — silent ${Math.round(silenceMs / 1000)}s, ${Math.round(elapsedMs / 1000)}s elapsed`,
84
84
  detail: {
85
85
  provider,
86
86
  elapsedMs,
@@ -96,7 +96,7 @@ export function createStallDetector({
96
96
  maxSilenceTriggered = true;
97
97
  emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
98
98
  status: "fail",
99
- message: `Agent ${provider} exceeded max silence (${Math.round(hardLimit / 1000)}s)`,
99
+ message: `[${stage}] Agent ${provider} exceeded max silence (${Math.round(hardLimit / 1000)}s)`,
100
100
  detail: {
101
101
  provider,
102
102
  silenceMs,
@@ -34,6 +34,9 @@ development:
34
34
  - .spec.
35
35
 
36
36
  # SonarQube settings
37
+ # Authentication: set token below, or KJ_SONAR_TOKEN env var,
38
+ # or save admin credentials in ~/.karajan/sonar-credentials.json:
39
+ # {"user": "admin", "password": "your-password"}
37
40
  sonarqube:
38
41
  enabled: true
39
42
  host: http://localhost:9000