karajan-code 1.33.0 → 1.34.2
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 +1 -1
- package/src/cli.js +12 -0
- package/src/commands/board.js +125 -0
- package/src/commands/init.js +9 -0
- package/src/config.js +40 -0
- package/src/mcp/server-handlers.js +24 -3
- package/src/mcp/tools.js +11 -0
- package/src/orchestrator.js +24 -2
- package/src/prompts/audit.js +4 -10
- package/src/prompts/coder.js +5 -1
- package/src/prompts/discover.js +4 -10
- package/src/prompts/hu-reviewer.js +4 -10
- package/src/review/parser.js +3 -15
- package/src/roles/security-role.js +2 -4
- package/src/roles/tester-role.js +2 -4
- package/src/roles/triage-role.js +2 -4
- package/src/utils/budget.js +51 -14
- package/src/utils/display.js +5 -3
- package/src/utils/json-extract.js +64 -0
- package/templates/roles/coder.md +7 -0
- package/templates/skills/kj-board.md +16 -0
- package/templates/skills/kj-run.md +4 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -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 }
|
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 },
|
|
@@ -420,6 +425,34 @@ export function applyRunOverrides(config, flags) {
|
|
|
420
425
|
return out;
|
|
421
426
|
}
|
|
422
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Check if a model string is compatible with an agent provider.
|
|
430
|
+
* Only returns false when the model clearly belongs to a DIFFERENT provider.
|
|
431
|
+
* Returns true if we can't determine or if the model is ambiguous.
|
|
432
|
+
*/
|
|
433
|
+
const AGENT_MODEL_SIGNATURES = {
|
|
434
|
+
claude: ["claude", "sonnet", "opus", "haiku"],
|
|
435
|
+
codex: ["o4-", "o3-", "gpt-", "codex"],
|
|
436
|
+
gemini: ["gemini", "flash-"]
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
export function isModelCompatible(agent, model) {
|
|
440
|
+
if (!model || !agent) return true;
|
|
441
|
+
const lower = model.toLowerCase();
|
|
442
|
+
|
|
443
|
+
// Check if model clearly belongs to a different provider
|
|
444
|
+
for (const [provider, signatures] of Object.entries(AGENT_MODEL_SIGNATURES)) {
|
|
445
|
+
if (provider === agent) continue;
|
|
446
|
+
if (signatures.some(s => lower.includes(s))) {
|
|
447
|
+
// Model belongs to a different provider — incompatible
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Model doesn't clearly belong to any other provider — allow it
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
423
456
|
export function resolveRole(config, role) {
|
|
424
457
|
const roles = config?.roles || {};
|
|
425
458
|
const roleConfig = roles[role] || {};
|
|
@@ -434,10 +467,17 @@ export function resolveRole(config, role) {
|
|
|
434
467
|
}
|
|
435
468
|
|
|
436
469
|
let model = roleConfig.model ?? null;
|
|
470
|
+
let modelIsInherited = false;
|
|
437
471
|
if (!model && role === "coder") model = config?.coder_options?.model ?? null;
|
|
438
472
|
if (!model && role === "reviewer") model = config?.reviewer_options?.model ?? null;
|
|
439
473
|
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")) {
|
|
440
474
|
model = config?.coder_options?.model ?? null;
|
|
475
|
+
modelIsInherited = !!model;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Drop inherited model if incompatible with the resolved provider
|
|
479
|
+
if (modelIsInherited && provider && model && !isModelCompatible(provider, model)) {
|
|
480
|
+
model = null;
|
|
441
481
|
}
|
|
442
482
|
|
|
443
483
|
return { provider, model };
|
|
@@ -790,7 +790,10 @@ async function handleRun(a, server, extra) {
|
|
|
790
790
|
}
|
|
791
791
|
}
|
|
792
792
|
if (!isPreflightAcked()) {
|
|
793
|
-
|
|
793
|
+
// Auto-acknowledge with defaults for autonomous operation
|
|
794
|
+
ackPreflight({});
|
|
795
|
+
const logger = createLogger("info", "mcp");
|
|
796
|
+
logger.info("Preflight auto-acknowledged with default agent config");
|
|
794
797
|
}
|
|
795
798
|
applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable"]);
|
|
796
799
|
return handleRunDirect(a, server, extra);
|
|
@@ -801,7 +804,10 @@ async function handleCode(a, server, extra) {
|
|
|
801
804
|
return failPayload("Missing required field: task");
|
|
802
805
|
}
|
|
803
806
|
if (!isPreflightAcked()) {
|
|
804
|
-
|
|
807
|
+
// Auto-acknowledge with defaults for autonomous operation
|
|
808
|
+
ackPreflight({});
|
|
809
|
+
const logger = createLogger("info", "mcp");
|
|
810
|
+
logger.info("Preflight auto-acknowledged with default agent config");
|
|
805
811
|
}
|
|
806
812
|
applySessionOverrides(a, ["coder"]);
|
|
807
813
|
return handleCodeDirect(a, server, extra);
|
|
@@ -857,6 +863,20 @@ async function handleAudit(a, server, extra) {
|
|
|
857
863
|
return handleAuditDirect(a, server, extra);
|
|
858
864
|
}
|
|
859
865
|
|
|
866
|
+
async function handleBoard(a) {
|
|
867
|
+
const action = a.action || "status";
|
|
868
|
+
const { loadConfig: lc } = await import("../config.js");
|
|
869
|
+
const { config } = await lc();
|
|
870
|
+
const port = a.port || config.hu_board?.port || 4000;
|
|
871
|
+
const { startBoard, stopBoard, boardStatus } = await import("../commands/board.js");
|
|
872
|
+
switch (action) {
|
|
873
|
+
case "start": return startBoard(port);
|
|
874
|
+
case "stop": return stopBoard();
|
|
875
|
+
case "status": return boardStatus(port);
|
|
876
|
+
default: return failPayload(`Unknown board action: ${action}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
860
880
|
/* ── Handler dispatch map ─────────────────────────────────────────── */
|
|
861
881
|
|
|
862
882
|
const toolHandlers = {
|
|
@@ -878,7 +898,8 @@ const toolHandlers = {
|
|
|
878
898
|
kj_triage: (a, server, extra) => handleTriage(a, server, extra),
|
|
879
899
|
kj_researcher: (a, server, extra) => handleResearcher(a, server, extra),
|
|
880
900
|
kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
|
|
881
|
-
kj_audit: (a, server, extra) => handleAudit(a, server, extra)
|
|
901
|
+
kj_audit: (a, server, extra) => handleAudit(a, server, extra),
|
|
902
|
+
kj_board: (a) => handleBoard(a)
|
|
882
903
|
};
|
|
883
904
|
|
|
884
905
|
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
|
];
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -1124,6 +1126,26 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
|
|
|
1124
1126
|
}));
|
|
1125
1127
|
}
|
|
1126
1128
|
|
|
1129
|
+
// --- HU Board auto-start ---
|
|
1130
|
+
if (config.hu_board?.enabled && config.hu_board?.auto_start) {
|
|
1131
|
+
try {
|
|
1132
|
+
const { startBoard } = await import("./commands/board.js");
|
|
1133
|
+
const boardPort = config.hu_board.port || 4000;
|
|
1134
|
+
const boardResult = await startBoard(boardPort);
|
|
1135
|
+
if (boardResult.alreadyRunning) {
|
|
1136
|
+
logger.info(`HU Board already running at ${boardResult.url}`);
|
|
1137
|
+
} else {
|
|
1138
|
+
logger.info(`HU Board started at ${boardResult.url}`);
|
|
1139
|
+
}
|
|
1140
|
+
emitProgress(emitter, makeEvent("board:started", ctx.eventBase, {
|
|
1141
|
+
message: `HU Board running at ${boardResult.url}`,
|
|
1142
|
+
detail: { pid: boardResult.pid, port: boardPort }
|
|
1143
|
+
}));
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
logger.warn(`HU Board auto-start failed (non-blocking): ${err.message}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1127
1149
|
// --- Product Context ---
|
|
1128
1150
|
const ctxProjectDir = config.projectDir || process.cwd();
|
|
1129
1151
|
const { content: productContext, source: productContextSource } = await loadProductContext(ctxProjectDir);
|
package/src/prompts/audit.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
2
|
+
|
|
1
3
|
const SUBAGENT_PREAMBLE = [
|
|
2
4
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
3
5
|
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
@@ -154,16 +156,8 @@ function parseRecommendation(raw) {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
export function parseAuditOutput(raw) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
if (!jsonMatch) return null;
|
|
160
|
-
|
|
161
|
-
let parsed;
|
|
162
|
-
try {
|
|
163
|
-
parsed = JSON.parse(jsonMatch[0]);
|
|
164
|
-
} catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
159
|
+
const parsed = extractFirstJson(raw);
|
|
160
|
+
if (!parsed) return null;
|
|
167
161
|
|
|
168
162
|
// Handle both wrapped (result.summary) and flat structures
|
|
169
163
|
const resultObj = parsed.result || parsed;
|
package/src/prompts/coder.js
CHANGED
|
@@ -31,7 +31,7 @@ const SERENA_INSTRUCTIONS = [
|
|
|
31
31
|
"Fall back to reading files only when Serena tools are not sufficient."
|
|
32
32
|
].join("\n");
|
|
33
33
|
|
|
34
|
-
export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null }) {
|
|
34
|
+
export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, plan = null }) {
|
|
35
35
|
const sections = [
|
|
36
36
|
serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
|
|
37
37
|
`Task:\n${task}`,
|
|
@@ -52,6 +52,10 @@ export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary =
|
|
|
52
52
|
sections.push(`## Product Context\n${productContext}`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
if (plan) {
|
|
56
|
+
sections.push(`## Implementation Plan (from planner)\nFollow these steps:\n${plan}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
55
59
|
if (coderRules) {
|
|
56
60
|
sections.push(`Coder rules (MUST follow):\n${coderRules}`);
|
|
57
61
|
}
|
package/src/prompts/discover.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
2
|
+
|
|
1
3
|
const SUBAGENT_PREAMBLE = [
|
|
2
4
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
3
5
|
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
@@ -205,16 +207,8 @@ function parseJtbds(rawJtbds) {
|
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
export function parseDiscoverOutput(raw) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
if (!jsonMatch) return null;
|
|
211
|
-
|
|
212
|
-
let parsed;
|
|
213
|
-
try {
|
|
214
|
-
parsed = JSON.parse(jsonMatch[0]);
|
|
215
|
-
} catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
210
|
+
const parsed = extractFirstJson(raw);
|
|
211
|
+
if (!parsed) return null;
|
|
218
212
|
|
|
219
213
|
return {
|
|
220
214
|
verdict: VALID_VERDICTS.has(parsed.verdict) ? parsed.verdict : "ready",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
2
|
+
|
|
1
3
|
const SUBAGENT_PREAMBLE = [
|
|
2
4
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
3
5
|
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
@@ -135,16 +137,8 @@ export function normalizeAcceptanceCriteria(criteria) {
|
|
|
135
137
|
* @returns {object|null} Parsed result with evaluations and batch_summary, or null.
|
|
136
138
|
*/
|
|
137
139
|
export function parseHuReviewerOutput(raw) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
if (!jsonMatch) return null;
|
|
141
|
-
|
|
142
|
-
let parsed;
|
|
143
|
-
try {
|
|
144
|
-
parsed = JSON.parse(jsonMatch[0]);
|
|
145
|
-
} catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
140
|
+
const parsed = extractFirstJson(raw);
|
|
141
|
+
if (!parsed) return null;
|
|
148
142
|
|
|
149
143
|
if (!Array.isArray(parsed.evaluations)) return null;
|
|
150
144
|
|
package/src/review/parser.js
CHANGED
|
@@ -3,23 +3,11 @@
|
|
|
3
3
|
* Extracted from orchestrator.js to improve testability and reduce complexity.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
7
|
+
|
|
6
8
|
export function parseMaybeJsonString(value) {
|
|
7
9
|
if (typeof value !== "string") return null;
|
|
8
|
-
|
|
9
|
-
return JSON.parse(value);
|
|
10
|
-
} catch {
|
|
11
|
-
const start = value.indexOf("{");
|
|
12
|
-
const end = value.lastIndexOf("}");
|
|
13
|
-
if (start >= 0 && end > start) {
|
|
14
|
-
const candidate = value.slice(start, end + 1);
|
|
15
|
-
try {
|
|
16
|
-
return JSON.parse(candidate);
|
|
17
|
-
} catch {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
10
|
+
return extractFirstJson(value);
|
|
23
11
|
}
|
|
24
12
|
|
|
25
13
|
function isReviewPayload(obj) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseRole } from "./base-role.js";
|
|
2
2
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
3
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
3
4
|
|
|
4
5
|
const SUBAGENT_PREAMBLE = [
|
|
5
6
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
@@ -38,10 +39,7 @@ function buildPrompt({ task, diff, instructions }) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function parseSecurityOutput(raw) {
|
|
41
|
-
|
|
42
|
-
const jsonMatch = /\{[\s\S]*\}/.exec(text);
|
|
43
|
-
if (!jsonMatch) return null;
|
|
44
|
-
return JSON.parse(jsonMatch[0]);
|
|
42
|
+
return extractFirstJson(raw);
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
function buildSummary(parsed) {
|
package/src/roles/tester-role.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseRole } from "./base-role.js";
|
|
2
2
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
3
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
3
4
|
|
|
4
5
|
const SUBAGENT_PREAMBLE = [
|
|
5
6
|
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
@@ -42,10 +43,7 @@ function buildPrompt({ task, diff, sonarIssues, instructions }) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function parseTesterOutput(raw) {
|
|
45
|
-
|
|
46
|
-
const jsonMatch = /\{[\s\S]*\}/.exec(text);
|
|
47
|
-
if (!jsonMatch) return null;
|
|
48
|
-
return JSON.parse(jsonMatch[0]);
|
|
46
|
+
return extractFirstJson(raw);
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
export class TesterRole extends BaseRole {
|
package/src/roles/triage-role.js
CHANGED
|
@@ -2,6 +2,7 @@ import { BaseRole } from "./base-role.js";
|
|
|
2
2
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
3
3
|
import { buildTriagePrompt } from "../prompts/triage.js";
|
|
4
4
|
import { VALID_TASK_TYPES } from "../guards/policy-resolver.js";
|
|
5
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
5
6
|
|
|
6
7
|
const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
|
|
7
8
|
const VALID_ROLES = new Set(["planner", "researcher", "refactorer", "reviewer", "tester", "security", "impeccable"]);
|
|
@@ -16,10 +17,7 @@ function resolveProvider(config) {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function parseTriageOutput(raw) {
|
|
19
|
-
|
|
20
|
-
const jsonMatch = /\{[\s\S]*\}/.exec(text);
|
|
21
|
-
if (!jsonMatch) return null;
|
|
22
|
-
return JSON.parse(jsonMatch[0]);
|
|
20
|
+
return extractFirstJson(raw);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
function normalizeRoles(roles) {
|
package/src/utils/budget.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { calculateUsageCostUsd, DEFAULT_MODEL_PRICING, mergePricing } from "./pricing.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Estimate token counts from character lengths when CLIs don't report usage.
|
|
5
|
+
* Rough heuristic: ~4 characters per token for English text.
|
|
6
|
+
*/
|
|
7
|
+
export function estimateTokens(promptLength, responseLength) {
|
|
8
|
+
return {
|
|
9
|
+
tokens_in: Math.ceil((promptLength || 0) / 4),
|
|
10
|
+
tokens_out: Math.ceil((responseLength || 0) / 4),
|
|
11
|
+
estimated: true
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export function extractUsageMetrics(result, defaultModel = null) {
|
|
4
16
|
const usage = result?.usage || result?.metrics || {};
|
|
5
17
|
const tokens_in =
|
|
@@ -27,7 +39,22 @@ export function extractUsageMetrics(result, defaultModel = null) {
|
|
|
27
39
|
defaultModel ??
|
|
28
40
|
null;
|
|
29
41
|
|
|
30
|
-
|
|
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.
|
|
44
|
+
let estimated = false;
|
|
45
|
+
let finalTokensIn = tokens_in;
|
|
46
|
+
let finalTokensOut = tokens_out;
|
|
47
|
+
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;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cost_usd, model, estimated };
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
function toSafeNumber(value) {
|
|
@@ -63,7 +90,7 @@ export class BudgetTracker {
|
|
|
63
90
|
this.pricing = mergePricing(DEFAULT_MODEL_PRICING, options.pricing || {});
|
|
64
91
|
}
|
|
65
92
|
|
|
66
|
-
record({ role, provider, model, tokens_in, tokens_out, cost_usd, duration_ms, stage_index } = {}) {
|
|
93
|
+
record({ role, provider, model, tokens_in, tokens_out, cost_usd, duration_ms, stage_index, estimated } = {}) {
|
|
67
94
|
const safeTokensIn = toSafeNumber(tokens_in);
|
|
68
95
|
const safeTokensOut = toSafeNumber(tokens_out);
|
|
69
96
|
const hasExplicitCost = cost_usd !== undefined && cost_usd !== null && cost_usd !== "";
|
|
@@ -89,6 +116,9 @@ export class BudgetTracker {
|
|
|
89
116
|
if (stage_index !== undefined && stage_index !== null) {
|
|
90
117
|
entry.stage_index = Number(stage_index);
|
|
91
118
|
}
|
|
119
|
+
if (estimated) {
|
|
120
|
+
entry.estimated = true;
|
|
121
|
+
}
|
|
92
122
|
this.entries.push(entry);
|
|
93
123
|
return entry;
|
|
94
124
|
}
|
|
@@ -133,26 +163,33 @@ export class BudgetTracker {
|
|
|
133
163
|
addToBreakdown(byRole, entry.role, entry);
|
|
134
164
|
}
|
|
135
165
|
|
|
136
|
-
|
|
166
|
+
const hasEstimates = this.entries.some(e => e.estimated);
|
|
167
|
+
const result = {
|
|
137
168
|
total_tokens: totals.tokens_in + totals.tokens_out,
|
|
138
169
|
total_cost_usd: totals.cost_usd,
|
|
139
170
|
breakdown_by_role: byRole,
|
|
140
171
|
entries: [...this.entries],
|
|
141
172
|
usage_available: this.hasUsageData()
|
|
142
173
|
};
|
|
174
|
+
if (hasEstimates) result.includes_estimates = true;
|
|
175
|
+
return result;
|
|
143
176
|
}
|
|
144
177
|
|
|
145
178
|
trace() {
|
|
146
|
-
return this.entries.map((entry, index) =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
179
|
+
return this.entries.map((entry, index) => {
|
|
180
|
+
const item = {
|
|
181
|
+
index: entry.stage_index ?? index,
|
|
182
|
+
role: entry.role,
|
|
183
|
+
provider: entry.provider,
|
|
184
|
+
model: entry.model,
|
|
185
|
+
timestamp: entry.timestamp,
|
|
186
|
+
duration_ms: entry.duration_ms ?? null,
|
|
187
|
+
tokens_in: entry.tokens_in,
|
|
188
|
+
tokens_out: entry.tokens_out,
|
|
189
|
+
cost_usd: entry.cost_usd
|
|
190
|
+
};
|
|
191
|
+
if (entry.estimated) item.estimated = true;
|
|
192
|
+
return item;
|
|
193
|
+
});
|
|
157
194
|
}
|
|
158
195
|
}
|
package/src/utils/display.js
CHANGED
|
@@ -225,11 +225,13 @@ function printSessionBudget(budget) {
|
|
|
225
225
|
console.log(` ${ANSI.dim}\ud83d\udcb0 Budget: N/A (provider does not report usage)${ANSI.reset}`);
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
const estPrefix = budget.includes_estimates ? "~" : "";
|
|
229
|
+
const estNote = budget.includes_estimates ? " (includes estimates)" : "";
|
|
230
|
+
console.log(` ${ANSI.dim}\ud83d\udcb0 Total tokens: ${estPrefix}${budget.total_tokens ?? 0}${estNote}${ANSI.reset}`);
|
|
231
|
+
console.log(` ${ANSI.dim}\ud83d\udcb0 Total cost: ${estPrefix}$${Number(budget.total_cost_usd || 0).toFixed(2)}${ANSI.reset}`);
|
|
230
232
|
for (const [role, metrics] of Object.entries(budget.breakdown_by_role || {})) {
|
|
231
233
|
console.log(
|
|
232
|
-
` ${ANSI.dim} - ${role}: ${metrics.total_tokens ?? 0} tokens, $${Number(metrics.total_cost_usd || 0).toFixed(2)}${ANSI.reset}`
|
|
234
|
+
` ${ANSI.dim} - ${role}: ${estPrefix}${metrics.total_tokens ?? 0} tokens, ${estPrefix}$${Number(metrics.total_cost_usd || 0).toFixed(2)}${ANSI.reset}`
|
|
233
235
|
);
|
|
234
236
|
}
|
|
235
237
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust JSON extraction from agent output.
|
|
3
|
+
* Extracts the first complete JSON object from a string,
|
|
4
|
+
* ignoring any trailing text that would cause parse errors.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract the first valid JSON object from a raw string.
|
|
9
|
+
* Handles cases where agents output valid JSON followed by extra text.
|
|
10
|
+
* @param {string} raw - Raw agent output.
|
|
11
|
+
* @returns {object|null} Parsed JSON object, or null if no valid JSON found.
|
|
12
|
+
*/
|
|
13
|
+
export function extractFirstJson(raw) {
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const str = typeof raw === "string" ? raw.trim() : String(raw).trim();
|
|
16
|
+
if (!str) return null;
|
|
17
|
+
|
|
18
|
+
// Fast path: try parsing the whole string first
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(str);
|
|
21
|
+
} catch { /* fall through to extraction */ }
|
|
22
|
+
|
|
23
|
+
// Find the first '{' and match to its closing '}'
|
|
24
|
+
const start = str.indexOf("{");
|
|
25
|
+
if (start === -1) return null;
|
|
26
|
+
|
|
27
|
+
let depth = 0;
|
|
28
|
+
let inString = false;
|
|
29
|
+
let escaped = false;
|
|
30
|
+
|
|
31
|
+
for (let i = start; i < str.length; i++) {
|
|
32
|
+
const ch = str[i];
|
|
33
|
+
|
|
34
|
+
if (escaped) {
|
|
35
|
+
escaped = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (ch === "\\") {
|
|
40
|
+
escaped = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ch === '"') {
|
|
45
|
+
inString = !inString;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (inString) continue;
|
|
50
|
+
|
|
51
|
+
if (ch === "{") depth++;
|
|
52
|
+
if (ch === "}") depth--;
|
|
53
|
+
|
|
54
|
+
if (depth === 0) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(str.substring(start, i + 1));
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
package/templates/roles/coder.md
CHANGED
|
@@ -21,6 +21,13 @@ Before reporting done, verify that ALL parts of the task are addressed:
|
|
|
21
21
|
- Run the test suite after implementation to verify nothing is broken.
|
|
22
22
|
- An incomplete implementation is worse than an error — never report success if parts are missing.
|
|
23
23
|
|
|
24
|
+
## Implementation Rules
|
|
25
|
+
- NEVER generate placeholder, stub, or TODO code. Every function must be fully implemented.
|
|
26
|
+
- If the task says "create X", create the complete working implementation, not a skeleton.
|
|
27
|
+
- If tests exist, the implementation MUST make all tests pass.
|
|
28
|
+
- If you write tests first (TDD), the implementation MUST make those tests pass.
|
|
29
|
+
- Do NOT commit code that doesn't compile or doesn't pass tests.
|
|
30
|
+
|
|
24
31
|
## File modification safety
|
|
25
32
|
|
|
26
33
|
- NEVER overwrite existing files entirely. Always make targeted, minimal edits.
|
|
@@ -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
|