karajan-code 1.34.0 → 1.34.3

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.34.0",
3
+ "version": "1.34.3",
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
@@ -23,6 +23,7 @@ import { triageCommand } from "./commands/triage.js";
23
23
  import { researcherCommand } from "./commands/researcher.js";
24
24
  import { architectCommand } from "./commands/architect.js";
25
25
  import { auditCommand } from "./commands/audit.js";
26
+ import { boardCommand } from "./commands/board.js";
26
27
 
27
28
  const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
28
29
  const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
@@ -305,6 +306,17 @@ program
305
306
  }
306
307
  });
307
308
 
309
+ program
310
+ .command("board [action]")
311
+ .description("Manage HU Board (start|stop|status|open)")
312
+ .option("--port <number>", "Port (default: 4000)", "4000")
313
+ .action(async (action = "start", opts) => {
314
+ await withConfig("board", opts, async ({ config, logger }) => {
315
+ const port = Number(opts.port) || config.hu_board?.port || 4000;
316
+ await boardCommand({ action, port, logger });
317
+ });
318
+ });
319
+
308
320
  const sonar = program.command("sonar").description("Manage SonarQube container");
309
321
  sonar.command("status").action(async () => sonarCommand({ action: "status" }));
310
322
  sonar.command("start").action(async () => sonarCommand({ action: "start" }));
@@ -0,0 +1,125 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { getKarajanHome } from "../utils/paths.js";
5
+
6
+ const BOARD_DIR = path.resolve(import.meta.dirname, "../../packages/hu-board");
7
+ const PID_FILE = path.join(getKarajanHome(), "hu-board.pid");
8
+
9
+ function readPid() {
10
+ try {
11
+ const pid = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
12
+ return Number.isFinite(pid) ? pid : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function isProcessAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export async function startBoard(port = 4000) {
28
+ const existingPid = readPid();
29
+ if (existingPid && isProcessAlive(existingPid)) {
30
+ return { ok: true, alreadyRunning: true, pid: existingPid, url: `http://localhost:${port}` };
31
+ }
32
+
33
+ const serverPath = path.join(BOARD_DIR, "src/server.js");
34
+ const karajanHome = getKarajanHome();
35
+ fs.mkdirSync(karajanHome, { recursive: true });
36
+
37
+ const child = spawn("node", [serverPath], {
38
+ env: { ...process.env, PORT: String(port) },
39
+ detached: true,
40
+ stdio: "ignore",
41
+ cwd: BOARD_DIR
42
+ });
43
+ child.unref();
44
+
45
+ fs.writeFileSync(PID_FILE, String(child.pid));
46
+ return { ok: true, alreadyRunning: false, pid: child.pid, url: `http://localhost:${port}` };
47
+ }
48
+
49
+ export async function stopBoard() {
50
+ const pid = readPid();
51
+ if (!pid || !isProcessAlive(pid)) {
52
+ try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
53
+ return { ok: true, wasRunning: false };
54
+ }
55
+
56
+ try {
57
+ process.kill(pid, "SIGTERM");
58
+ } catch {
59
+ // already dead
60
+ }
61
+ try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
62
+ return { ok: true, wasRunning: true, pid };
63
+ }
64
+
65
+ export async function boardStatus(port = 4000) {
66
+ const pid = readPid();
67
+ const running = pid !== null && isProcessAlive(pid);
68
+ return {
69
+ ok: true,
70
+ running,
71
+ pid: running ? pid : null,
72
+ url: running ? `http://localhost:${port}` : null
73
+ };
74
+ }
75
+
76
+ export async function boardCommand({ action = "start", port = 4000, logger }) {
77
+ switch (action) {
78
+ case "start": {
79
+ const result = await startBoard(port);
80
+ if (result.alreadyRunning) {
81
+ logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
82
+ } else {
83
+ logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
84
+ }
85
+ return result;
86
+ }
87
+ case "stop": {
88
+ const result = await stopBoard();
89
+ if (result.wasRunning) {
90
+ logger.info(`HU Board stopped (PID ${result.pid})`);
91
+ } else {
92
+ logger.info("HU Board was not running");
93
+ }
94
+ return result;
95
+ }
96
+ case "status": {
97
+ const result = await boardStatus(port);
98
+ if (result.running) {
99
+ logger.info(`HU Board is running (PID ${result.pid}) at ${result.url}`);
100
+ } else {
101
+ logger.info("HU Board is not running");
102
+ }
103
+ return result;
104
+ }
105
+ case "open": {
106
+ const status = await boardStatus(port);
107
+ if (!status.running) {
108
+ logger.info("HU Board is not running. Starting it first...");
109
+ await startBoard(port);
110
+ }
111
+ const url = `http://localhost:${port}`;
112
+ const { default: open } = await import("open").catch(() => ({ default: null }));
113
+ if (open) {
114
+ await open(url);
115
+ logger.info(`Opened ${url}`);
116
+ } else {
117
+ logger.info(`Open in browser: ${url}`);
118
+ }
119
+ return { ok: true, url };
120
+ }
121
+ default:
122
+ logger.error(`Unknown board action: ${action}. Use start|stop|status|open`);
123
+ return { ok: false, error: `Unknown action: ${action}` };
124
+ }
125
+ }
@@ -162,23 +162,23 @@ async function checkBecariaSecrets() {
162
162
  const { detectRepo } = await import("../becaria/repo.js");
163
163
  const repo = await detectRepo();
164
164
  if (!repo) return null;
165
+
165
166
  const secretsRes = await runCommand("gh", ["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
166
167
  if (secretsRes.exitCode !== 0) return null;
168
+
167
169
  const names = new Set(secretsRes.stdout.split("\n").map((s) => s.trim()));
168
170
  const hasAppId = names.has("BECARIA_APP_ID");
169
171
  const hasKey = names.has("BECARIA_APP_PRIVATE_KEY");
170
172
  const secretsOk = hasAppId && hasKey;
173
+ const missing = [!hasAppId && "BECARIA_APP_ID", !hasKey && "BECARIA_APP_PRIVATE_KEY"].filter(Boolean).join(" ");
171
174
  return {
172
175
  name: "becaria:secrets",
173
176
  label: "BecarIA: GitHub secrets",
174
177
  ok: secretsOk,
175
- detail: secretsOk
176
- ? "BECARIA_APP_ID + BECARIA_APP_PRIVATE_KEY found"
177
- : `Missing: ${[!hasAppId && "BECARIA_APP_ID", !hasKey && "BECARIA_APP_PRIVATE_KEY"].filter(Boolean).join(" ")}`,
178
+ detail: secretsOk ? "BECARIA_APP_ID + BECARIA_APP_PRIVATE_KEY found" : `Missing: ${missing}`,
178
179
  fix: secretsOk ? null : "Add BECARIA_APP_ID and BECARIA_APP_PRIVATE_KEY as GitHub repository secrets"
179
180
  };
180
181
  } catch {
181
- // Skip secrets check if we can't access the API
182
182
  return null;
183
183
  }
184
184
  }
@@ -205,33 +205,15 @@ async function checkBecariaInfra(config) {
205
205
  }
206
206
 
207
207
  async function checkRtk() {
208
+ const NOT_FOUND_DETAIL = "Not found — install for 60-90% token savings: brew install rtk";
209
+ let detail = NOT_FOUND_DETAIL;
208
210
  try {
209
211
  const res = await runCommand("rtk", ["--version"]);
210
212
  if (res.exitCode === 0) {
211
- return {
212
- name: "rtk",
213
- label: "RTK (Rust Token Killer)",
214
- ok: true,
215
- detail: `${res.stdout.trim()} — token savings active`,
216
- fix: null
217
- };
213
+ detail = `${res.stdout.trim()} — token savings active`;
218
214
  }
219
- return {
220
- name: "rtk",
221
- label: "RTK (Rust Token Killer)",
222
- ok: true,
223
- detail: "Not found — install for 60-90% token savings: brew install rtk",
224
- fix: null
225
- };
226
- } catch {
227
- return {
228
- name: "rtk",
229
- label: "RTK (Rust Token Killer)",
230
- ok: true,
231
- detail: "Not found — install for 60-90% token savings: brew install rtk",
232
- fix: null
233
- };
234
- }
215
+ } catch { /* not installed */ }
216
+ return { name: "rtk", label: "RTK (Rust Token Killer)", ok: true, detail, fix: null };
235
217
  }
236
218
 
237
219
  async function checkRuleFiles(config) {
@@ -70,6 +70,15 @@ async function runWizard(config, logger) {
70
70
  config.sonarqube.enabled = enableSonar;
71
71
  logger.info(` -> SonarQube: ${enableSonar ? "enabled" : "disabled"}`);
72
72
 
73
+ const enableHuBoard = await wizard.confirm("Enable HU Board for story tracking?", false);
74
+ config.hu_board = config.hu_board || {};
75
+ config.hu_board.enabled = enableHuBoard;
76
+ if (enableHuBoard) {
77
+ config.hu_board.port = 4000;
78
+ config.hu_board.auto_start = true;
79
+ }
80
+ logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
81
+
73
82
  const methodology = await wizard.select("Development methodology:", [
74
83
  { label: "TDD (test-driven development)", value: "tdd", available: true },
75
84
  { label: "Standard (no TDD enforcement)", value: "standard", available: true }
@@ -155,50 +155,49 @@ async function buildReport(dir, sessionId) {
155
155
  return report;
156
156
  }
157
157
 
158
- function printTextReport(report) {
159
- let budgetText = "N/A";
160
- if (typeof report.budget_consumed?.consumed_usd === "number") {
161
- const limitSuffix = typeof report.budget_consumed?.limit_usd === "number"
162
- ? ` / $${report.budget_consumed.limit_usd.toFixed(2)}`
163
- : "";
164
- budgetText = `$${report.budget_consumed.consumed_usd.toFixed(2)}${limitSuffix}`;
165
- }
158
+ function formatBudgetText(budget) {
159
+ if (typeof budget?.consumed_usd !== "number") return "N/A";
160
+ const limitSuffix = typeof budget.limit_usd === "number" ? ` / $${budget.limit_usd.toFixed(2)}` : "";
161
+ return `$${budget.consumed_usd.toFixed(2)}${limitSuffix}`;
162
+ }
166
163
 
167
- const planText = report.plan_executed.length > 0 ? report.plan_executed.join(" -> ") : "N/A";
168
- const iterationText =
169
- report.iterations.length > 0
170
- ? report.iterations
171
- .map(
172
- (item) =>
173
- `#${item.iteration} coder=${item.coder_runs} reviewer_attempts=${item.reviewer_attempts} approved=${item.reviewer_approved}`
174
- )
175
- .join("\n")
176
- : "N/A";
177
- const commitsText =
178
- report.commits_generated.ids.length > 0
179
- ? `${report.commits_generated.count} (${report.commits_generated.ids.join(", ")})`
180
- : String(report.commits_generated.count);
164
+ function formatIterationsText(iterations) {
165
+ if (iterations.length === 0) return "N/A";
166
+ return iterations
167
+ .map((item) => `#${item.iteration} coder=${item.coder_runs} reviewer_attempts=${item.reviewer_attempts} approved=${item.reviewer_approved}`)
168
+ .join("\n");
169
+ }
170
+
171
+ function formatCommitsText(commits) {
172
+ return commits.ids.length > 0
173
+ ? `${commits.count} (${commits.ids.join(", ")})`
174
+ : String(commits.count);
175
+ }
176
+
177
+ function printPgCardLine(report) {
178
+ if (!report.pg_task_id) return;
179
+ const projectLabel = report.pg_project_id ? ` (${report.pg_project_id})` : "";
180
+ console.log(`Planning Game Card: ${report.pg_task_id}${projectLabel}`);
181
+ }
182
+
183
+ function printTextReport(report) {
184
+ const sonar = report.sonar_issues_resolved;
181
185
 
182
186
  console.log(`Session: ${report.session_id}`);
183
- if (report.pg_task_id) {
184
- const projectLabel = report.pg_project_id ? ` (${report.pg_project_id})` : "";
185
- console.log(`Planning Game Card: ${report.pg_task_id}${projectLabel}`);
186
- }
187
+ printPgCardLine(report);
187
188
  console.log(`Status: ${report.status}`);
188
189
  console.log("Task Description:");
189
190
  console.log(report.task_description || "N/A");
190
191
  console.log("Plan Executed:");
191
- console.log(planText);
192
+ console.log(report.plan_executed.length > 0 ? report.plan_executed.join(" -> ") : "N/A");
192
193
  console.log("Iterations (Coder/Reviewer):");
193
- console.log(iterationText);
194
+ console.log(formatIterationsText(report.iterations));
194
195
  console.log("Sonar Issues Resolved:");
195
- console.log(
196
- `initial=${report.sonar_issues_resolved.initial_open_issues ?? "N/A"} final=${report.sonar_issues_resolved.final_open_issues ?? "N/A"} resolved=${report.sonar_issues_resolved.resolved}`
197
- );
196
+ console.log(`initial=${sonar.initial_open_issues ?? "N/A"} final=${sonar.final_open_issues ?? "N/A"} resolved=${sonar.resolved}`);
198
197
  console.log("Budget Consumed:");
199
- console.log(budgetText);
198
+ console.log(formatBudgetText(report.budget_consumed));
200
199
  console.log("Commits Generated:");
201
- console.log(commitsText);
200
+ console.log(formatCommitsText(report.commits_generated));
202
201
  }
203
202
 
204
203
  function formatDuration(ms) {
@@ -329,10 +328,7 @@ async function resolveTraceOptions(currency) {
329
328
 
330
329
  function printTraceReport(report, currency, exchangeRate) {
331
330
  console.log(`Session: ${report.session_id}`);
332
- if (report.pg_task_id) {
333
- const projectLabel = report.pg_project_id ? ` (${report.pg_project_id})` : "";
334
- console.log(`Planning Game Card: ${report.pg_task_id}${projectLabel}`);
335
- }
331
+ printPgCardLine(report);
336
332
  console.log(`Status: ${report.status}`);
337
333
  console.log(`Task: ${report.task_description || "N/A"}`);
338
334
  console.log("");
package/src/config.js CHANGED
@@ -118,6 +118,11 @@ const DEFAULTS = {
118
118
  test_inclusions: "**/*.test.js,**/*.spec.js,**/tests/**,**/__tests__/**"
119
119
  }
120
120
  },
121
+ hu_board: {
122
+ enabled: false,
123
+ port: 4000,
124
+ auto_start: false
125
+ },
121
126
  policies: {},
122
127
  serena: { enabled: false },
123
128
  planning_game: { enabled: false, project_id: null, codeveloper: null },
@@ -448,27 +453,39 @@ export function isModelCompatible(agent, model) {
448
453
  return true;
449
454
  }
450
455
 
456
+ // Roles that inherit provider/model from the coder when not explicitly configured
457
+ const CODER_INHERITED_ROLES = new Set([
458
+ "planner", "refactorer", "solomon", "researcher", "tester", "security",
459
+ "impeccable", "triage", "discover", "architect", "audit", "hu_reviewer", "hu-reviewer"
460
+ ]);
461
+
462
+ function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
463
+ if (roleConfig.provider) return roleConfig.provider;
464
+ if (role === "coder") return legacyCoder;
465
+ if (role === "reviewer") return legacyReviewer;
466
+ if (CODER_INHERITED_ROLES.has(role)) return roles.coder?.provider || legacyCoder;
467
+ return null;
468
+ }
469
+
470
+ function resolveModel(roleConfig, role, config) {
471
+ if (roleConfig.model) return { model: roleConfig.model, inherited: false };
472
+ if (role === "coder") return { model: config?.coder_options?.model ?? null, inherited: false };
473
+ if (role === "reviewer") return { model: config?.reviewer_options?.model ?? null, inherited: false };
474
+ if (CODER_INHERITED_ROLES.has(role)) {
475
+ const model = config?.coder_options?.model ?? null;
476
+ return { model, inherited: !!model };
477
+ }
478
+ return { model: null, inherited: false };
479
+ }
480
+
451
481
  export function resolveRole(config, role) {
452
482
  const roles = config?.roles || {};
453
483
  const roleConfig = roles[role] || {};
454
484
  const legacyCoder = config?.coder || null;
455
485
  const legacyReviewer = config?.reviewer || null;
456
486
 
457
- let provider = roleConfig.provider ?? null;
458
- if (!provider && role === "coder") provider = legacyCoder;
459
- if (!provider && role === "reviewer") provider = legacyReviewer;
460
- if (!provider && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect" || role === "audit" || role === "hu_reviewer" || role === "hu-reviewer")) {
461
- provider = roles.coder?.provider || legacyCoder;
462
- }
463
-
464
- let model = roleConfig.model ?? null;
465
- let modelIsInherited = false;
466
- if (!model && role === "coder") model = config?.coder_options?.model ?? null;
467
- if (!model && role === "reviewer") model = config?.reviewer_options?.model ?? null;
468
- if (!model && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect" || role === "hu_reviewer" || role === "hu-reviewer")) {
469
- model = config?.coder_options?.model ?? null;
470
- modelIsInherited = !!model;
471
- }
487
+ const provider = resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer);
488
+ let { model, inherited: modelIsInherited } = resolveModel(roleConfig, role, config);
472
489
 
473
490
  // Drop inherited model if incompatible with the resolved provider
474
491
  if (modelIsInherited && provider && model && !isModelCompatible(provider, model)) {
@@ -252,6 +252,31 @@ async function attemptAutoResume({ err, config, logger, emitter, askQuestion, ru
252
252
  }
253
253
  }
254
254
 
255
+ const PIPELINE_PROVIDER_ROLES = [
256
+ ["triage", true],
257
+ ["planner", true],
258
+ ["refactorer", true],
259
+ ["researcher", true],
260
+ ["tester", true],
261
+ ["security", true]
262
+ ];
263
+
264
+ function collectRequiredProviders(config) {
265
+ const providers = [
266
+ resolveRole(config, "coder").provider,
267
+ config.reviewer_options?.fallback_reviewer
268
+ ];
269
+ if (config.pipeline?.reviewer?.enabled !== false) {
270
+ providers.push(resolveRole(config, "reviewer").provider);
271
+ }
272
+ for (const [role, requireEnabled] of PIPELINE_PROVIDER_ROLES) {
273
+ if (requireEnabled && config.pipeline?.[role]?.enabled) {
274
+ providers.push(resolveRole(config, role).provider);
275
+ }
276
+ }
277
+ return providers;
278
+ }
279
+
255
280
  export async function handleRunDirect(a, server, extra) {
256
281
  const config = await buildConfig(a);
257
282
  await assertNotOnBaseBranch(config);
@@ -263,20 +288,7 @@ export async function handleRunDirect(a, server, extra) {
263
288
  await cleanupExpiredSessions({ logger });
264
289
  } catch { /* non-blocking */ }
265
290
 
266
- const requiredProviders = [
267
- resolveRole(config, "coder").provider,
268
- config.reviewer_options?.fallback_reviewer
269
- ];
270
- if (config.pipeline?.reviewer?.enabled !== false) {
271
- requiredProviders.push(resolveRole(config, "reviewer").provider);
272
- }
273
- if (config.pipeline?.triage?.enabled) requiredProviders.push(resolveRole(config, "triage").provider);
274
- if (config.pipeline?.planner?.enabled) requiredProviders.push(resolveRole(config, "planner").provider);
275
- if (config.pipeline?.refactorer?.enabled) requiredProviders.push(resolveRole(config, "refactorer").provider);
276
- if (config.pipeline?.researcher?.enabled) requiredProviders.push(resolveRole(config, "researcher").provider);
277
- if (config.pipeline?.tester?.enabled) requiredProviders.push(resolveRole(config, "tester").provider);
278
- if (config.pipeline?.security?.enabled) requiredProviders.push(resolveRole(config, "security").provider);
279
- await assertAgentsAvailable(requiredProviders);
291
+ await assertAgentsAvailable(collectRequiredProviders(config));
280
292
 
281
293
  const projectDir = await resolveProjectDir(server, a.projectDir);
282
294
  const runLog = createRunLog(projectDir);
@@ -343,13 +355,16 @@ export async function handleResumeDirect(a, server, extra) {
343
355
  }
344
356
  }
345
357
 
358
+ const EVENT_LOG_LEVELS = {
359
+ "agent:stall": "warning",
360
+ "agent:heartbeat": "info"
361
+ };
362
+
346
363
  function buildDirectEmitter(server, runLog, extra) {
347
364
  const emitter = new EventEmitter();
348
365
  emitter.on("progress", (event) => {
349
366
  try {
350
- let level = "debug";
351
- if (event.type === "agent:stall") level = "warning";
352
- else if (event.type === "agent:heartbeat") level = "info";
367
+ const level = EVENT_LOG_LEVELS[event.type] || "debug";
353
368
  server.sendLoggingMessage({ level, logger: "karajan", data: event });
354
369
  } catch { /* best-effort */ }
355
370
  if (runLog) runLog.logEvent(event);
@@ -863,6 +878,20 @@ async function handleAudit(a, server, extra) {
863
878
  return handleAuditDirect(a, server, extra);
864
879
  }
865
880
 
881
+ async function handleBoard(a) {
882
+ const action = a.action || "status";
883
+ const { loadConfig: lc } = await import("../config.js");
884
+ const { config } = await lc();
885
+ const port = a.port || config.hu_board?.port || 4000;
886
+ const { startBoard, stopBoard, boardStatus } = await import("../commands/board.js");
887
+ switch (action) {
888
+ case "start": return startBoard(port);
889
+ case "stop": return stopBoard();
890
+ case "status": return boardStatus(port);
891
+ default: return failPayload(`Unknown board action: ${action}`);
892
+ }
893
+ }
894
+
866
895
  /* ── Handler dispatch map ─────────────────────────────────────────── */
867
896
 
868
897
  const toolHandlers = {
@@ -884,7 +913,8 @@ const toolHandlers = {
884
913
  kj_triage: (a, server, extra) => handleTriage(a, server, extra),
885
914
  kj_researcher: (a, server, extra) => handleResearcher(a, server, extra),
886
915
  kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
887
- kj_audit: (a, server, extra) => handleAudit(a, server, extra)
916
+ kj_audit: (a, server, extra) => handleAudit(a, server, extra),
917
+ kj_board: (a) => handleBoard(a)
888
918
  };
889
919
 
890
920
  export async function handleToolCall(name, args, server, extra) {
package/src/mcp/tools.js CHANGED
@@ -306,5 +306,16 @@ export const tools = [
306
306
  kjHome: { type: "string" }
307
307
  }
308
308
  }
309
+ },
310
+ {
311
+ name: "kj_board",
312
+ description: "Start, stop, or check status of the HU Board dashboard",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ action: { type: "string", enum: ["start", "stop", "status"], description: "Action to perform (default: status)" },
317
+ port: { type: "number", description: "Port (default: 4000)" }
318
+ }
319
+ }
309
320
  }
310
321
  ];
@@ -156,8 +156,10 @@ function createBudgetManager({ config, emitter, eventBase }) {
156
156
  return s;
157
157
  }
158
158
 
159
- function trackBudget({ role, provider, model, result, duration_ms }) {
160
- const metrics = extractUsageMetrics(result, model);
159
+ function trackBudget({ role, provider, model, result, duration_ms, promptSize }) {
160
+ // Attach promptSize to result if provided, so extractUsageMetrics can estimate tokens
161
+ const enrichedResult = promptSize && result ? { ...result, promptSize } : result;
162
+ const metrics = extractUsageMetrics(enrichedResult, model);
161
163
  budgetTracker.record({ role, provider, ...metrics, duration_ms, stage_index: stageCounter++ });
162
164
 
163
165
  if (!hasBudgetLimit) return;
@@ -474,7 +476,7 @@ async function checkBudgetExceeded({ budgetTracker, config, session, emitter, ev
474
476
  }
475
477
 
476
478
  async function handleStandbyResult({ stageResult, session, emitter, eventBase, i, stage, logger }) {
477
- if (!stageResult?.action || stageResult.action !== "standby") {
479
+ if (stageResult?.action !== "standby") {
478
480
  return { handled: false };
479
481
  }
480
482
 
@@ -509,6 +511,47 @@ async function handleStandbyResult({ stageResult, session, emitter, eventBase, i
509
511
  return { handled: true, action: "retry" };
510
512
  }
511
513
 
514
+ function formatCommitList(commits) {
515
+ return commits.map((c) => `- \`${c.hash.slice(0, 7)}\` ${c.message}`).join("\n");
516
+ }
517
+
518
+ async function becariaIncrementalPush({ config, session, gitCtx, task, logger, repo, dispatchComment }) {
519
+ const pushResult = await incrementalPush({ gitCtx, task, logger, session });
520
+ if (!pushResult) return;
521
+
522
+ session.becaria_commits = [...(session.becaria_commits ?? []), ...pushResult.commits];
523
+ await saveSession(session);
524
+
525
+ if (!repo) return;
526
+ const feedback = session.last_reviewer_feedback || "N/A";
527
+ await dispatchComment({
528
+ repo, prNumber: session.becaria_pr_number, agent: "Coder",
529
+ body: `Issues corregidos:\n${feedback}\n\nCommits:\n${formatCommitList(pushResult.commits)}`,
530
+ becariaConfig: config.becaria
531
+ });
532
+ }
533
+
534
+ async function becariaCreateEarlyPr({ config, session, emitter, eventBase, gitCtx, task, logger, stageResults, i, repo, dispatchComment }) {
535
+ const earlyPr = await earlyPrCreation({ gitCtx, task, logger, session, stageResults });
536
+ if (!earlyPr) return;
537
+
538
+ session.becaria_pr_number = earlyPr.prNumber;
539
+ session.becaria_pr_url = earlyPr.prUrl;
540
+ session.becaria_commits = earlyPr.commits;
541
+ await saveSession(session);
542
+ emitProgress(emitter, makeEvent("becaria:pr-created", { ...eventBase, stage: "becaria" }, {
543
+ message: `Early PR created: #${earlyPr.prNumber}`,
544
+ detail: { prNumber: earlyPr.prNumber, prUrl: earlyPr.prUrl }
545
+ }));
546
+
547
+ if (!repo) return;
548
+ await dispatchComment({
549
+ repo, prNumber: earlyPr.prNumber, agent: "Coder",
550
+ body: `Iteración ${i} completada.\n\nCommits:\n${formatCommitList(earlyPr.commits)}`,
551
+ becariaConfig: config.becaria
552
+ });
553
+ }
554
+
512
555
  async function handleBecariaEarlyPrOrPush({ becariaEnabled, config, session, emitter, eventBase, gitCtx, task, logger, stageResults, i }) {
513
556
  if (!becariaEnabled) return;
514
557
 
@@ -518,48 +561,26 @@ async function handleBecariaEarlyPrOrPush({ becariaEnabled, config, session, emi
518
561
  const repo = await detectRepo();
519
562
 
520
563
  if (session.becaria_pr_number) {
521
- const pushResult = await incrementalPush({ gitCtx, task, logger, session });
522
- if (pushResult) {
523
- session.becaria_commits = [...(session.becaria_commits ?? []), ...pushResult.commits];
524
- await saveSession(session);
525
-
526
- if (repo) {
527
- const feedback = session.last_reviewer_feedback || "N/A";
528
- const commitList = pushResult.commits.map((c) => `- \`${c.hash.slice(0, 7)}\` ${c.message}`).join("\n");
529
- await dispatchComment({
530
- repo, prNumber: session.becaria_pr_number, agent: "Coder",
531
- body: `Issues corregidos:\n${feedback}\n\nCommits:\n${commitList}`,
532
- becariaConfig: config.becaria
533
- });
534
- }
535
- }
564
+ await becariaIncrementalPush({ config, session, gitCtx, task, logger, repo, dispatchComment });
536
565
  } else {
537
- const earlyPr = await earlyPrCreation({ gitCtx, task, logger, session, stageResults });
538
- if (earlyPr) {
539
- session.becaria_pr_number = earlyPr.prNumber;
540
- session.becaria_pr_url = earlyPr.prUrl;
541
- session.becaria_commits = earlyPr.commits;
542
- await saveSession(session);
543
- emitProgress(emitter, makeEvent("becaria:pr-created", { ...eventBase, stage: "becaria" }, {
544
- message: `Early PR created: #${earlyPr.prNumber}`,
545
- detail: { prNumber: earlyPr.prNumber, prUrl: earlyPr.prUrl }
546
- }));
547
-
548
- if (repo) {
549
- const commitList = earlyPr.commits.map((c) => `- \`${c.hash.slice(0, 7)}\` ${c.message}`).join("\n");
550
- await dispatchComment({
551
- repo, prNumber: earlyPr.prNumber, agent: "Coder",
552
- body: `Iteración ${i} completada.\n\nCommits:\n${commitList}`,
553
- becariaConfig: config.becaria
554
- });
555
- }
556
- }
566
+ await becariaCreateEarlyPr({ config, session, emitter, eventBase, gitCtx, task, logger, stageResults, i, repo, dispatchComment });
557
567
  }
558
568
  } catch (err) {
559
569
  logger.warn(`BecarIA early PR/push failed (non-blocking): ${err.message}`);
560
570
  }
561
571
  }
562
572
 
573
+ function emitSolomonAlerts(alerts, emitter, eventBase, logger) {
574
+ for (const alert of alerts) {
575
+ emitProgress(emitter, makeEvent("solomon:alert", { ...eventBase, stage: "solomon" }, {
576
+ status: alert.severity === "critical" ? "fail" : "warn",
577
+ message: alert.message,
578
+ detail: alert.detail
579
+ }));
580
+ logger.warn(`Solomon alert [${alert.rule}]: ${alert.message}`);
581
+ }
582
+ }
583
+
563
584
  async function handleSolomonCheck({ config, session, emitter, eventBase, logger, task, i, askQuestion, becariaEnabled, blockingIssues }) {
564
585
  if (config.pipeline?.solomon?.enabled === false) return { action: "continue" };
565
586
 
@@ -569,15 +590,7 @@ async function handleSolomonCheck({ config, session, emitter, eventBase, logger,
569
590
  const rulesResult = evaluateRules(rulesContext, config.solomon?.rules);
570
591
 
571
592
  if (rulesResult.alerts.length > 0) {
572
- for (const alert of rulesResult.alerts) {
573
- emitProgress(emitter, makeEvent("solomon:alert", { ...eventBase, stage: "solomon" }, {
574
- status: alert.severity === "critical" ? "fail" : "warn",
575
- message: alert.message,
576
- detail: alert.detail
577
- }));
578
- logger.warn(`Solomon alert [${alert.rule}]: ${alert.message}`);
579
- }
580
-
593
+ emitSolomonAlerts(rulesResult.alerts, emitter, eventBase, logger);
581
594
  const pauseResult = await checkSolomonCriticalAlerts({ rulesResult, askQuestion, session, i });
582
595
  if (pauseResult) return pauseResult;
583
596
  }
@@ -621,6 +634,27 @@ async function checkSolomonCriticalAlerts({ rulesResult, askQuestion, session, i
621
634
  return null;
622
635
  }
623
636
 
637
+ function formatBlockingIssues(issues) {
638
+ return issues?.map((x) => `- ${x.id || "ISSUE"} [${x.severity || ""}] ${x.description}`).join("\n") || "";
639
+ }
640
+
641
+ function formatSuggestions(suggestions) {
642
+ return suggestions?.map((s) => {
643
+ const detail = typeof s === "string" ? s : `${s.id || ""} ${s.description || s}`;
644
+ return `- ${detail}`;
645
+ }).join("\n") || "";
646
+ }
647
+
648
+ function buildReviewCommentBody(review, i) {
649
+ const status = review.approved ? "APPROVED" : "REQUEST_CHANGES";
650
+ const blocking = formatBlockingIssues(review.blocking_issues);
651
+ const suggestions = formatSuggestions(review.non_blocking_suggestions);
652
+ let body = `Review iteración ${i}: ${status}`;
653
+ if (blocking) body += `\n\n**Blocking:**\n${blocking}`;
654
+ if (suggestions) body += `\n\n**Suggestions:**\n${suggestions}`;
655
+ return body;
656
+ }
657
+
624
658
  async function handleBecariaReviewDispatch({ becariaEnabled, config, session, review, i, logger }) {
625
659
  if (!becariaEnabled || !session.becaria_pr_number) return;
626
660
 
@@ -631,33 +665,19 @@ async function handleBecariaReviewDispatch({ becariaEnabled, config, session, re
631
665
  if (!repo) return;
632
666
 
633
667
  const bc = config.becaria;
634
- if (review.approved) {
635
- await dispatchReview({
636
- repo, prNumber: session.becaria_pr_number,
637
- event: "APPROVE", body: review.summary || "Approved", agent: "Reviewer", becariaConfig: bc
638
- });
639
- } else {
640
- const blocking = review.blocking_issues?.map((x) => `- ${x.id || "ISSUE"} [${x.severity || ""}] ${x.description}`).join("\n") || "";
641
- await dispatchReview({
642
- repo, prNumber: session.becaria_pr_number,
643
- event: "REQUEST_CHANGES",
644
- body: blocking || review.summary || "Changes requested",
645
- agent: "Reviewer", becariaConfig: bc
646
- });
647
- }
668
+ const reviewEvent = review.approved ? "APPROVE" : "REQUEST_CHANGES";
669
+ const reviewBody = review.approved
670
+ ? (review.summary || "Approved")
671
+ : (formatBlockingIssues(review.blocking_issues) || review.summary || "Changes requested");
672
+
673
+ await dispatchReview({
674
+ repo, prNumber: session.becaria_pr_number,
675
+ event: reviewEvent, body: reviewBody, agent: "Reviewer", becariaConfig: bc
676
+ });
648
677
 
649
- const status = review.approved ? "APPROVED" : "REQUEST_CHANGES";
650
- const blocking = review.blocking_issues?.map((x) => `- ${x.id || "ISSUE"} [${x.severity || ""}] ${x.description}`).join("\n") || "";
651
- const suggestions = review.non_blocking_suggestions?.map((s) => {
652
- const detail = typeof s === "string" ? s : `${s.id || ""} ${s.description || s}`;
653
- return `- ${detail}`;
654
- }).join("\n") || "";
655
- let reviewBody = `Review iteración ${i}: ${status}`;
656
- if (blocking) reviewBody += `\n\n**Blocking:**\n${blocking}`;
657
- if (suggestions) reviewBody += `\n\n**Suggestions:**\n${suggestions}`;
658
678
  await dispatchComment({
659
679
  repo, prNumber: session.becaria_pr_number, agent: "Reviewer",
660
- body: reviewBody, becariaConfig: bc
680
+ body: buildReviewCommentBody(review, i), becariaConfig: bc
661
681
  });
662
682
 
663
683
  logger.info(`BecarIA: dispatched review for PR #${session.becaria_pr_number}`);
@@ -1093,6 +1113,24 @@ async function handleMaxIterationsReached({ session, budgetSummary, emitter, eve
1093
1113
  return { approved: false, sessionId: session.id, reason: "max_iterations" };
1094
1114
  }
1095
1115
 
1116
+ async function tryAutoStartBoard(config, logger, emitter, eventBase) {
1117
+ if (!config.hu_board?.enabled || !config.hu_board?.auto_start) return;
1118
+
1119
+ try {
1120
+ const { startBoard } = await import("./commands/board.js");
1121
+ const boardPort = config.hu_board.port || 4000;
1122
+ const boardResult = await startBoard(boardPort);
1123
+ const status = boardResult.alreadyRunning ? "already running" : "started";
1124
+ logger.info(`HU Board ${status} at ${boardResult.url}`);
1125
+ emitProgress(emitter, makeEvent("board:started", eventBase, {
1126
+ message: `HU Board running at ${boardResult.url}`,
1127
+ detail: { pid: boardResult.pid, port: boardPort }
1128
+ }));
1129
+ } catch (err) {
1130
+ logger.warn(`HU Board auto-start failed (non-blocking): ${err.message}`);
1131
+ }
1132
+ }
1133
+
1096
1134
  async function initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags }) {
1097
1135
  const ctx = new PipelineContext({ config, session: null, logger, emitter, task, flags });
1098
1136
  ctx.askQuestion = askQuestion;
@@ -1124,6 +1162,9 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
1124
1162
  }));
1125
1163
  }
1126
1164
 
1165
+ // --- HU Board auto-start ---
1166
+ await tryAutoStartBoard(config, logger, emitter, ctx.eventBase);
1167
+
1127
1168
  // --- Product Context ---
1128
1169
  const ctxProjectDir = config.projectDir || process.cwd();
1129
1170
  const { content: productContext, source: productContextSource } = await loadProductContext(ctxProjectDir);
@@ -219,9 +219,14 @@ function printSessionGit(git) {
219
219
  }
220
220
  }
221
221
 
222
+ function isBudgetUnavailable(budget) {
223
+ return budget.usage_available === false ||
224
+ (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0);
225
+ }
226
+
222
227
  function printSessionBudget(budget) {
223
228
  if (!budget) return;
224
- if (budget.usage_available === false || (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0)) {
229
+ if (isBudgetUnavailable(budget)) {
225
230
  console.log(` ${ANSI.dim}\ud83d\udcb0 Budget: N/A (provider does not report usage)${ANSI.reset}`);
226
231
  return;
227
232
  }
@@ -381,12 +386,13 @@ const EVENT_HANDLERS = {
381
386
  },
382
387
 
383
388
  "budget:update": (event, icon) => {
384
- const total = Number(event.detail?.total_cost_usd || 0);
385
- const totalTokens = Number(event.detail?.total_tokens || 0);
386
- const max = Number(event.detail?.max_budget_usd);
387
- const pct = Number(event.detail?.pct_used ?? 0);
388
- const warn = Number(event.detail?.warn_threshold_pct ?? 80);
389
- const hasEntries = (event.detail?.entries?.length ?? 0) > 0 || Object.keys(event.detail?.breakdown_by_role || {}).length > 0;
389
+ const d = event.detail || {};
390
+ const total = Number(d.total_cost_usd || 0);
391
+ const totalTokens = Number(d.total_tokens || 0);
392
+ const max = Number(d.max_budget_usd);
393
+ const pct = Number(d.pct_used ?? 0);
394
+ const warn = Number(d.warn_threshold_pct ?? 80);
395
+ const hasEntries = (d.entries?.length ?? 0) > 0 || Object.keys(d.breakdown_by_role || {}).length > 0;
390
396
  if (hasEntries && totalTokens === 0 && total === 0) {
391
397
  console.log(` \u251c\u2500 ${icon} Budget: ${ANSI.dim}N/A (provider does not report usage)${ANSI.reset}`);
392
398
  return;
@@ -0,0 +1,16 @@
1
+ # kj-board — HU Board Dashboard
2
+
3
+ Manage the Karajan HU Board for visualizing user stories and sessions.
4
+
5
+ ## Your task
6
+
7
+ $ARGUMENTS
8
+
9
+ ## Actions
10
+
11
+ - **Start**: Run `kj board start` to launch the dashboard
12
+ - **Stop**: Run `kj board stop` to stop it
13
+ - **Status**: Run `kj board status` to check if running
14
+ - **Open**: Run `kj board open` to open in browser
15
+
16
+ The board auto-syncs with KJ pipeline data. No manual refresh needed.
@@ -61,6 +61,10 @@ If all steps pass:
61
61
  2. Commit with conventional commit message: `feat:`, `fix:`, `refactor:`, etc.
62
62
  3. Do NOT push unless the user explicitly asks
63
63
 
64
+ ## HU Board Integration
65
+ If HU Board is enabled (`hu_board.enabled: true` in config), stories and sessions
66
+ are automatically tracked and visible at the board URL. Run `kj board` to start it.
67
+
64
68
  ## Important rules
65
69
 
66
70
  - **Never skip steps** — execute all applicable steps in order