karajan-code 1.9.6 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.9.6",
3
+ "version": "1.10.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { runCommandHandler } from "./commands/run.js";
17
17
  import { resumeCommand } from "./commands/resume.js";
18
18
  import { sonarCommand, sonarOpenCommand } from "./commands/sonar.js";
19
19
  import { rolesCommand } from "./commands/roles.js";
20
+ import { agentsCommand } from "./commands/agents.js";
20
21
 
21
22
  const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
22
23
  const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
@@ -161,6 +162,15 @@ program
161
162
  });
162
163
  });
163
164
 
165
+ program
166
+ .command("agents [subcommand] [role] [provider]")
167
+ .description("List or change AI agent assignments per role (e.g. kj agents set coder gemini)")
168
+ .action(async (subcommand, role, provider) => {
169
+ await withConfig("agents", {}, async ({ config }) => {
170
+ await agentsCommand({ config, subcommand: subcommand || "list", role, provider });
171
+ });
172
+ });
173
+
164
174
  program
165
175
  .command("plan")
166
176
  .description("Generate implementation plan")
@@ -0,0 +1,68 @@
1
+ import { loadConfig, writeConfig, getConfigPath, resolveRole } from "../config.js";
2
+ import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
3
+
4
+ const ASSIGNABLE_ROLES = [
5
+ "coder", "reviewer", "planner", "refactorer", "triage",
6
+ "researcher", "tester", "security", "solomon"
7
+ ];
8
+
9
+ const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
10
+
11
+ export function listAgents(config) {
12
+ return ASSIGNABLE_ROLES.map((role) => {
13
+ const resolved = resolveRole(config, role);
14
+ return {
15
+ role,
16
+ provider: resolved.provider || "-",
17
+ model: resolved.model || "-"
18
+ };
19
+ });
20
+ }
21
+
22
+ export async function setAgent(role, provider) {
23
+ if (!ASSIGNABLE_ROLES.includes(role)) {
24
+ throw new Error(`Unknown role "${role}". Valid roles: ${ASSIGNABLE_ROLES.join(", ")}`);
25
+ }
26
+ if (!VALID_PROVIDERS.includes(provider)) {
27
+ const bin = await checkBinary(provider);
28
+ if (!bin.ok) {
29
+ throw new Error(`Provider "${provider}" not found. Available: ${VALID_PROVIDERS.join(", ")}`);
30
+ }
31
+ }
32
+
33
+ const { config } = await loadConfig();
34
+ config.roles = config.roles || {};
35
+ config.roles[role] = config.roles[role] || {};
36
+ config.roles[role].provider = provider;
37
+
38
+ const configPath = getConfigPath();
39
+ await writeConfig(configPath, config);
40
+
41
+ return { role, provider, configPath };
42
+ }
43
+
44
+ export async function agentsCommand({ config, subcommand, role, provider }) {
45
+ if (subcommand === "set") {
46
+ if (!role || !provider) {
47
+ console.log("Usage: kj agents set <role> <provider>");
48
+ console.log(`Roles: ${ASSIGNABLE_ROLES.join(", ")}`);
49
+ console.log(`Providers: ${VALID_PROVIDERS.join(", ")}`);
50
+ return;
51
+ }
52
+ const result = await setAgent(role, provider);
53
+ console.log(`Set ${result.role} -> ${result.provider} (saved to ${result.configPath})`);
54
+ return result;
55
+ }
56
+
57
+ const agents = listAgents(config);
58
+ const roleWidth = Math.max(...agents.map((a) => a.role.length), 4);
59
+ const provWidth = Math.max(...agents.map((a) => a.provider.length), 8);
60
+ console.log(`${"Role".padEnd(roleWidth)} ${"Provider".padEnd(provWidth)} Model`);
61
+ console.log("-".repeat(roleWidth + provWidth + 10));
62
+ for (const a of agents) {
63
+ console.log(`${a.role.padEnd(roleWidth)} ${a.provider.padEnd(provWidth)} ${a.model}`);
64
+ }
65
+ return agents;
66
+ }
67
+
68
+ export { ASSIGNABLE_ROLES, VALID_PROVIDERS };
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
1
4
  import { runCommand } from "../utils/process.js";
2
5
  import { exists } from "../utils/fs.js";
3
6
  import { getConfigPath } from "../config.js";
@@ -6,9 +9,24 @@ import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
6
9
  import { ensureGitRepo } from "../utils/git.js";
7
10
  import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
8
11
 
12
+ function getPackageVersion() {
13
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json");
14
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
15
+ }
16
+
9
17
  export async function runChecks({ config }) {
10
18
  const checks = [];
11
19
 
20
+ // 0. Karajan version
21
+ const version = getPackageVersion();
22
+ checks.push({
23
+ name: "karajan",
24
+ label: "Karajan Code",
25
+ ok: true,
26
+ detail: `v${version}`,
27
+ fix: null
28
+ });
29
+
12
30
  // 1. Config file
13
31
  const configPath = getConfigPath();
14
32
  const configExists = await exists(configPath);
@@ -442,6 +442,21 @@ export async function handleToolCall(name, args, server, extra) {
442
442
  return runKjCommand({ command: "doctor", options: a });
443
443
  }
444
444
 
445
+ if (name === "kj_agents") {
446
+ const action = a.action || "list";
447
+ if (action === "set") {
448
+ if (!a.role || !a.provider) {
449
+ return failPayload("Missing required fields: role and provider");
450
+ }
451
+ const { setAgent } = await import("../commands/agents.js");
452
+ const result = await setAgent(a.role, a.provider);
453
+ return { ok: true, ...result, message: `${result.role} now uses ${result.provider}` };
454
+ }
455
+ const config = await buildConfig(a);
456
+ const { listAgents } = await import("../commands/agents.js");
457
+ return { ok: true, agents: listAgents(config) };
458
+ }
459
+
445
460
  if (name === "kj_config") {
446
461
  return runKjCommand({
447
462
  command: "config",
package/src/mcp/tools.js CHANGED
@@ -97,7 +97,7 @@ export const tools = [
97
97
  {
98
98
  name: "kj_resume",
99
99
  description:
100
- "Resume a paused session by ID. Provide an answer to the question that caused the pause. Sends real-time progress notifications via MCP logging.",
100
+ "Resume a paused, stopped, or failed session by ID. For paused sessions, provide an answer. For stopped/failed sessions, re-runs the flow from scratch. Sends real-time progress notifications via MCP logging.",
101
101
  inputSchema: {
102
102
  type: "object",
103
103
  required: ["sessionId"],
@@ -136,6 +136,19 @@ export const tools = [
136
136
  }
137
137
  }
138
138
  },
139
+ {
140
+ name: "kj_agents",
141
+ description: "List or change which AI agent (provider) is assigned to each pipeline role. Use action='list' to see current assignments. Use action='set' with role and provider to change it persistently (writes to kj.config.yml, no restart needed).",
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ action: { type: "string", enum: ["list", "set"], description: "Action: list current agents or set a role's provider" },
146
+ role: { type: "string", description: "Role to change (e.g. coder, reviewer, planner, triage)" },
147
+ provider: { type: "string", description: "New provider to assign (e.g. claude, codex, gemini, aider)" },
148
+ kjHome: { type: "string" }
149
+ }
150
+ }
151
+ },
139
152
  {
140
153
  name: "kj_code",
141
154
  description: "Run coder-only mode",
@@ -295,7 +295,13 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
295
295
 
296
296
  await addCheckpoint(session, { stage: "interactive-checkpoint", elapsed_minutes: Number(elapsedStr), answer });
297
297
 
298
- if (!answer || answer.trim() === "4" || answer.trim().toLowerCase().startsWith("stop")) {
298
+ // Explicit stop: only when the user clearly chose option 4 or typed "stop".
299
+ // A null/empty answer (e.g. elicitInput failure, AI timeout) defaults to
300
+ // "continue 5 more minutes" so the session is not killed accidentally.
301
+ const trimmedAnswer = (answer || "").trim();
302
+ const isExplicitStop = trimmedAnswer === "4" || trimmedAnswer.toLowerCase().startsWith("stop");
303
+
304
+ if (isExplicitStop) {
299
305
  await markSessionStatus(session, "stopped");
300
306
  emitProgress(
301
307
  emitter,
@@ -308,12 +314,15 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
308
314
  return { approved: false, sessionId: session.id, reason: "user_stopped", elapsed_minutes: Number(elapsedStr) };
309
315
  }
310
316
 
311
- if (answer.trim() === "2" || answer.trim().toLowerCase().startsWith("continue until")) {
317
+ // No answer or unrecognized default to continue 5 more minutes
318
+ if (!trimmedAnswer) {
319
+ lastCheckpointAt = Date.now();
320
+ } else if (trimmedAnswer === "2" || trimmedAnswer.toLowerCase().startsWith("continue until")) {
312
321
  checkpointDisabled = true;
313
- } else if (answer.trim() === "1" || answer.trim().toLowerCase().includes("5 m")) {
322
+ } else if (trimmedAnswer === "1" || trimmedAnswer.toLowerCase().includes("5 m")) {
314
323
  lastCheckpointAt = Date.now();
315
324
  } else {
316
- const customMinutes = parseInt(answer.trim().replace(/\D/g, ""), 10);
325
+ const customMinutes = parseInt(trimmedAnswer.replace(/\D/g, ""), 10);
317
326
  if (customMinutes > 0) {
318
327
  lastCheckpointAt = Date.now();
319
328
  config.session.checkpoint_interval_minutes = customMinutes;
@@ -561,11 +570,20 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
561
570
  return session;
562
571
  }
563
572
 
564
- if (session.status !== "running") {
565
- logger.info(`Session ${sessionId} has status ${session.status}`);
573
+ // Allow resuming "stopped" sessions (checkpoint stop) and "failed" sessions
574
+ const resumableStatuses = ["running", "stopped", "failed"];
575
+ if (!resumableStatuses.includes(session.status)) {
576
+ logger.info(`Session ${sessionId} has status ${session.status} — not resumable`);
566
577
  return session;
567
578
  }
568
579
 
580
+ // Mark as running again for stopped/failed sessions
581
+ if (session.status !== "running") {
582
+ logger.info(`Resuming ${session.status} session ${sessionId}`);
583
+ session.status = "running";
584
+ await saveSession(session);
585
+ }
586
+
569
587
  // Session was paused and now resumed with answer - re-run the flow
570
588
  const task = session.task;
571
589
  const sessionConfig = config || session.config_snapshot;
@@ -10,6 +10,14 @@ const SUBAGENT_PREAMBLE_SERENA = [
10
10
  "Execute the task directly. Focus only on coding."
11
11
  ].join(" ");
12
12
 
13
+ const SUBPROCESS_CONSTRAINTS = [
14
+ "## Environment constraints",
15
+ "You run as a non-interactive subprocess (no stdin, no TTY).",
16
+ "- If the task requires a CLI wizard or interactive init (e.g. `create-react-app`, `pnpm create astro`, `npm init`), ALWAYS use non-interactive flags: `--yes`, `--no-input`, `--template <name>`, `--defaults`, etc.",
17
+ "- Never run a command that waits for user input — it will hang forever.",
18
+ "- If a task absolutely cannot be done non-interactively, say so explicitly instead of hanging."
19
+ ].join("\n");
20
+
13
21
  const SERENA_INSTRUCTIONS = [
14
22
  "## Serena MCP — symbol-level code navigation",
15
23
  "You have access to Serena MCP tools for efficient code navigation.",
@@ -26,7 +34,8 @@ export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary =
26
34
  serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
27
35
  `Task:\n${task}`,
28
36
  "Implement directly in the repository.",
29
- "Keep changes minimal and production-ready."
37
+ "Keep changes minimal and production-ready.",
38
+ SUBPROCESS_CONSTRAINTS
30
39
  ];
31
40
 
32
41
  if (serenaEnabled) {