karajan-code 1.9.6 → 1.10.1

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.1",
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);
@@ -4,7 +4,7 @@ import { assertAgentsAvailable } from "../agents/availability.js";
4
4
  import { createActivityLog } from "../activity-log.js";
5
5
  import { printHeader, printEvent } from "../utils/display.js";
6
6
  import { resolveRole } from "../config.js";
7
- import { parseCardId, buildTaskFromCard, buildCompletionUpdates } from "../planning-game/adapter.js";
7
+ import { parseCardId } from "../planning-game/adapter.js";
8
8
 
9
9
  export async function runCommandHandler({ task, config, logger, flags }) {
10
10
  const requiredProviders = [
@@ -22,24 +22,10 @@ export async function runCommandHandler({ task, config, logger, flags }) {
22
22
  if (config.pipeline?.security?.enabled) requiredProviders.push(resolveRole(config, "security").provider);
23
23
  await assertAgentsAvailable(requiredProviders);
24
24
 
25
- // --- Planning Game: resolve card context ---
25
+ // --- Planning Game: resolve card ID ---
26
26
  const pgCardId = flags?.pgTask || parseCardId(task);
27
27
  const pgProject = flags?.pgProject || config.planning_game?.project_id || null;
28
- let pgCard = null;
29
- let enrichedTask = task;
30
-
31
- if (pgCardId && pgProject && config.planning_game?.enabled !== false) {
32
- try {
33
- const { fetchCard, updateCard } = await import("../planning-game/client.js");
34
- pgCard = await fetchCard({ projectId: pgProject, cardId: pgCardId });
35
- if (pgCard) {
36
- enrichedTask = buildTaskFromCard(pgCard);
37
- logger.info(`Planning Game: loaded card ${pgCardId} from project ${pgProject}`);
38
- }
39
- } catch (err) {
40
- logger.warn(`Planning Game: could not load card ${pgCardId}: ${err.message}`);
41
- }
42
- }
28
+ const enrichedTask = task;
43
29
 
44
30
  const jsonMode = flags?.json;
45
31
 
@@ -65,26 +51,8 @@ export async function runCommandHandler({ task, config, logger, flags }) {
65
51
  printHeader({ task: enrichedTask, config });
66
52
  }
67
53
 
68
- const startDate = new Date().toISOString();
69
54
  const result = await runFlow({ task: enrichedTask, config, logger, flags, emitter, pgTaskId: pgCardId || null, pgProject: pgProject || null });
70
55
 
71
- // --- Planning Game: update card on completion ---
72
- if (pgCard && pgProject && result?.approved) {
73
- try {
74
- const { updateCard } = await import("../planning-game/client.js");
75
- const updates = buildCompletionUpdates({
76
- approved: true,
77
- commits: result.git?.commits || [],
78
- startDate,
79
- codeveloper: config.planning_game?.codeveloper || null
80
- });
81
- await updateCard({ projectId: pgProject, cardId: pgCardId, firebaseId: pgCard.firebaseId, updates });
82
- logger.info(`Planning Game: updated ${pgCardId} to "${updates.status}"`);
83
- } catch (err) {
84
- logger.warn(`Planning Game: could not update card ${pgCardId}: ${err.message}`);
85
- }
86
- }
87
-
88
56
  if (jsonMode) {
89
57
  console.log(JSON.stringify(result, null, 2));
90
58
  }
@@ -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",
@@ -157,6 +157,32 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
157
157
 
158
158
  eventBase.sessionId = session.id;
159
159
 
160
+ // --- Planning Game: mark card as In Progress ---
161
+ let pgCard = null;
162
+ if (pgTaskId && pgProject && config.planning_game?.enabled !== false) {
163
+ try {
164
+ const { fetchCard, updateCard } = await import("./planning-game/client.js");
165
+ pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
166
+ if (pgCard && pgCard.status !== "In Progress") {
167
+ await updateCard({
168
+ projectId: pgProject,
169
+ cardId: pgTaskId,
170
+ firebaseId: pgCard.firebaseId,
171
+ updates: {
172
+ status: "In Progress",
173
+ startDate: new Date().toISOString(),
174
+ developer: "dev_016",
175
+ codeveloper: config.planning_game?.codeveloper || null
176
+ }
177
+ });
178
+ logger.info(`Planning Game: ${pgTaskId} → In Progress`);
179
+ }
180
+ } catch (err) {
181
+ logger.warn(`Planning Game: could not update ${pgTaskId}: ${err.message}`);
182
+ }
183
+ }
184
+ session.pg_card = pgCard || null;
185
+
160
186
  emitProgress(
161
187
  emitter,
162
188
  makeEvent("session:start", eventBase, {
@@ -295,7 +321,13 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
295
321
 
296
322
  await addCheckpoint(session, { stage: "interactive-checkpoint", elapsed_minutes: Number(elapsedStr), answer });
297
323
 
298
- if (!answer || answer.trim() === "4" || answer.trim().toLowerCase().startsWith("stop")) {
324
+ // Explicit stop: only when the user clearly chose option 4 or typed "stop".
325
+ // A null/empty answer (e.g. elicitInput failure, AI timeout) defaults to
326
+ // "continue 5 more minutes" so the session is not killed accidentally.
327
+ const trimmedAnswer = (answer || "").trim();
328
+ const isExplicitStop = trimmedAnswer === "4" || trimmedAnswer.toLowerCase().startsWith("stop");
329
+
330
+ if (isExplicitStop) {
299
331
  await markSessionStatus(session, "stopped");
300
332
  emitProgress(
301
333
  emitter,
@@ -308,12 +340,15 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
308
340
  return { approved: false, sessionId: session.id, reason: "user_stopped", elapsed_minutes: Number(elapsedStr) };
309
341
  }
310
342
 
311
- if (answer.trim() === "2" || answer.trim().toLowerCase().startsWith("continue until")) {
343
+ // No answer or unrecognized default to continue 5 more minutes
344
+ if (!trimmedAnswer) {
345
+ lastCheckpointAt = Date.now();
346
+ } else if (trimmedAnswer === "2" || trimmedAnswer.toLowerCase().startsWith("continue until")) {
312
347
  checkpointDisabled = true;
313
- } else if (answer.trim() === "1" || answer.trim().toLowerCase().includes("5 m")) {
348
+ } else if (trimmedAnswer === "1" || trimmedAnswer.toLowerCase().includes("5 m")) {
314
349
  lastCheckpointAt = Date.now();
315
350
  } else {
316
- const customMinutes = parseInt(answer.trim().replace(/\D/g, ""), 10);
351
+ const customMinutes = parseInt(trimmedAnswer.replace(/\D/g, ""), 10);
317
352
  if (customMinutes > 0) {
318
353
  lastCheckpointAt = Date.now();
319
354
  config.session.checkpoint_interval_minutes = customMinutes;
@@ -484,6 +519,30 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
484
519
  }
485
520
  session.budget = budgetSummary();
486
521
  await markSessionStatus(session, "approved");
522
+
523
+ // --- Planning Game: mark card as To Validate ---
524
+ if (pgCard && pgProject) {
525
+ try {
526
+ const { updateCard } = await import("./planning-game/client.js");
527
+ const { buildCompletionUpdates } = await import("./planning-game/adapter.js");
528
+ const pgUpdates = buildCompletionUpdates({
529
+ approved: true,
530
+ commits: gitResult?.commits || [],
531
+ startDate: session.pg_card?.startDate || session.created_at,
532
+ codeveloper: config.planning_game?.codeveloper || null
533
+ });
534
+ await updateCard({
535
+ projectId: pgProject,
536
+ cardId: session.pg_task_id,
537
+ firebaseId: pgCard.firebaseId,
538
+ updates: pgUpdates
539
+ });
540
+ logger.info(`Planning Game: ${session.pg_task_id} → To Validate`);
541
+ } catch (err) {
542
+ logger.warn(`Planning Game: could not update ${session.pg_task_id} on completion: ${err.message}`);
543
+ }
544
+ }
545
+
487
546
  emitProgress(
488
547
  emitter,
489
548
  makeEvent("session:end", { ...eventBase, stage: "done" }, {
@@ -561,11 +620,20 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
561
620
  return session;
562
621
  }
563
622
 
564
- if (session.status !== "running") {
565
- logger.info(`Session ${sessionId} has status ${session.status}`);
623
+ // Allow resuming "stopped" sessions (checkpoint stop) and "failed" sessions
624
+ const resumableStatuses = ["running", "stopped", "failed"];
625
+ if (!resumableStatuses.includes(session.status)) {
626
+ logger.info(`Session ${sessionId} has status ${session.status} — not resumable`);
566
627
  return session;
567
628
  }
568
629
 
630
+ // Mark as running again for stopped/failed sessions
631
+ if (session.status !== "running") {
632
+ logger.info(`Resuming ${session.status} session ${sessionId}`);
633
+ session.status = "running";
634
+ await saveSession(session);
635
+ }
636
+
569
637
  // Session was paused and now resumed with answer - re-run the flow
570
638
  const task = session.task;
571
639
  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) {