karajan-code 2.19.0 → 2.19.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/README.md +4 -0
- package/package.json +9 -1
- package/packages/hu-board/README.md +69 -0
- package/packages/hu-board/package.json +23 -0
- package/packages/hu-board/public/app.js +4038 -0
- package/packages/hu-board/public/index.html +55 -0
- package/packages/hu-board/public/pipeline.css +132 -0
- package/packages/hu-board/public/pipeline.html +48 -0
- package/packages/hu-board/public/pipeline.js +200 -0
- package/packages/hu-board/public/styles.css +918 -0
- package/packages/hu-board/src/auth.js +92 -0
- package/packages/hu-board/src/boot-info.js +16 -0
- package/packages/hu-board/src/cleanup-zombies.js +142 -0
- package/packages/hu-board/src/command-runner.js +134 -0
- package/packages/hu-board/src/config-yaml.js +285 -0
- package/packages/hu-board/src/db.js +739 -0
- package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
- package/packages/hu-board/src/event-bus.js +58 -0
- package/packages/hu-board/src/format.js +83 -0
- package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
- package/packages/hu-board/src/journal-parser.js +180 -0
- package/packages/hu-board/src/plan-mutations.js +422 -0
- package/packages/hu-board/src/preflight.js +285 -0
- package/packages/hu-board/src/routes/api.js +1271 -0
- package/packages/hu-board/src/routes/pipeline.js +45 -0
- package/packages/hu-board/src/run-tracker.js +121 -0
- package/packages/hu-board/src/server.js +435 -0
- package/packages/hu-board/src/sync.js +626 -0
- package/packages/hu-board/src/token-store.js +57 -0
- package/packages/hu-board/src/zombie-reaper.js +207 -0
- package/src/audit/webperf-input.js +4 -7
- package/src/checks/dir-setup.js +5 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/commands/plan/generate.js +15 -0
- package/src/commands/run.js +19 -1
- package/src/commands/webperf.js +4 -6
- package/src/config/role-resolver.js +3 -1
- package/src/mcp/handlers/direct-handlers.js +3 -0
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/tools.js +4 -2
- package/src/prompts/spec-reviewer.js +66 -0
- package/src/roles/perf-role.js +4 -4
- package/src/roles/spec-reviewer-role.js +106 -0
- package/src/sonar/api.js +17 -3
- package/src/sonar/token-recovery.js +67 -0
- package/src/spec-review/refine-loop.js +58 -0
- package/src/spec-review/run-spec-review.js +70 -0
- package/src/utils/board-prompt-bridge.js +4 -5
- package/src/utils/display/spec-findings.js +44 -0
- package/src/utils/paths.js +15 -0
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token authentication middleware for HU Board.
|
|
3
|
+
*
|
|
4
|
+
* Threat model — KJC-TSK-0355:
|
|
5
|
+
* The board is a localhost dev tool. The "shared dev machine" risk
|
|
6
|
+
* only kicks in when the server binds beyond loopback (e.g.
|
|
7
|
+
* `--bind 0.0.0.0` to access from another laptop on LAN). For pure
|
|
8
|
+
* loopback traffic, the OS already enforces "only the local user
|
|
9
|
+
* can connect", so a token would be UX friction with zero benefit
|
|
10
|
+
* (the browser opens `http://localhost:4000` and would stop working
|
|
11
|
+
* without manual `?token=...` injection on every link).
|
|
12
|
+
*
|
|
13
|
+
* Behaviour:
|
|
14
|
+
* - When `HU_BOARD_TOKEN` is set AND the request is from a non-
|
|
15
|
+
* loopback address → require the token in `Authorization: Bearer`
|
|
16
|
+
* header, `?token=` query param, or `kj_board_token` cookie.
|
|
17
|
+
* - When the request comes from loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1)
|
|
18
|
+
* → skip auth. The browser running on the same machine just works.
|
|
19
|
+
* - When `HU_BOARD_TOKEN` is unset → skip auth entirely. Tests and
|
|
20
|
+
* legacy callers keep working.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Loopback addresses we trust without auth, regardless of token state. */
|
|
24
|
+
const LOOPBACK_ADDRESSES = new Set([
|
|
25
|
+
"127.0.0.1",
|
|
26
|
+
"::1",
|
|
27
|
+
"::ffff:127.0.0.1",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns an Express middleware that enforces token auth for non-
|
|
32
|
+
* loopback requests when HU_BOARD_TOKEN is defined.
|
|
33
|
+
*
|
|
34
|
+
* @returns {import('express').RequestHandler}
|
|
35
|
+
*/
|
|
36
|
+
export function authMiddleware() {
|
|
37
|
+
return (req, res, next) => {
|
|
38
|
+
const expected = process.env.HU_BOARD_TOKEN;
|
|
39
|
+
if (!expected) return next();
|
|
40
|
+
|
|
41
|
+
if (isLoopback(req)) return next();
|
|
42
|
+
|
|
43
|
+
const token = extractToken(req);
|
|
44
|
+
if (token === expected) return next();
|
|
45
|
+
|
|
46
|
+
return res.status(401).json({
|
|
47
|
+
error: "Unauthorized",
|
|
48
|
+
message:
|
|
49
|
+
"HU Board is bound to a non-loopback interface. Pass the token from " +
|
|
50
|
+
"~/.karajan/hu-board/token via Authorization: Bearer <token>, " +
|
|
51
|
+
"?token=<token>, or the kj_board_token cookie.",
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {import('express').Request} req
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isLoopback(req) {
|
|
61
|
+
// Express populates req.ip from req.socket.remoteAddress unless
|
|
62
|
+
// `trust proxy` is set (we don't, by design — the board never sits
|
|
63
|
+
// behind a proxy in its supported deployment). req.ip is therefore
|
|
64
|
+
// the actual peer address.
|
|
65
|
+
const ip = req.ip || req.socket?.remoteAddress || "";
|
|
66
|
+
return LOOPBACK_ADDRESSES.has(ip);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {import('express').Request} req
|
|
71
|
+
* @returns {string|undefined}
|
|
72
|
+
*/
|
|
73
|
+
function extractToken(req) {
|
|
74
|
+
const authHeader = req.headers.authorization;
|
|
75
|
+
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
76
|
+
return authHeader.slice(7);
|
|
77
|
+
}
|
|
78
|
+
if (req.query && typeof req.query.token === "string") {
|
|
79
|
+
return req.query.token;
|
|
80
|
+
}
|
|
81
|
+
// Cookie parsing: we don't pull cookie-parser as a dep just for one
|
|
82
|
+
// header. Express stores the raw header in req.headers.cookie; the
|
|
83
|
+
// grammar is `name=value; name2=value2`. Good enough for one key.
|
|
84
|
+
const cookieHeader = req.headers.cookie;
|
|
85
|
+
if (cookieHeader) {
|
|
86
|
+
for (const part of cookieHeader.split(";")) {
|
|
87
|
+
const [name, ...rest] = part.trim().split("=");
|
|
88
|
+
if (name === "kj_board_token") return rest.join("=");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-scoped boot info (KJC-TSK-0379).
|
|
3
|
+
*
|
|
4
|
+
* BOOT_TIME is captured at module load and exposed via /api/version so
|
|
5
|
+
* the client can detect server restarts and trigger a reload. Lives in
|
|
6
|
+
* its own module to avoid the server.js ↔ routes/api.js import cycle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
|
|
11
|
+
export const BOOT_TIME = new Date().toISOString();
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
export const PKG_VERSION = (() => {
|
|
15
|
+
try { return require('../package.json').version; } catch { return 'unknown'; }
|
|
16
|
+
})();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup zombi resources of the HU Board (KJC-TSK-0380 PR C).
|
|
3
|
+
*
|
|
4
|
+
* Detects and removes orphan / abandoned entries that survive across
|
|
5
|
+
* dogfooding sessions and would otherwise reappear on every fullScan:
|
|
6
|
+
*
|
|
7
|
+
* 1. Ephemeral projects (`tmp_*`, `test_*`, `demo_*`, `kj-test-*`,
|
|
8
|
+
* `s_*`, `plan-*`) older than `staleProjectDays` (default 7).
|
|
9
|
+
* 2. Stale prompts (no `.answer.json` and file mtime older than
|
|
10
|
+
* `stalePromptHours`, default 24).
|
|
11
|
+
* 3. Orphan session directories (`~/.karajan/sessions/<id>/`) older
|
|
12
|
+
* than `staleProjectDays` with no matching DB row.
|
|
13
|
+
*
|
|
14
|
+
* Each removal goes through the same path the API endpoints use:
|
|
15
|
+
* tombstone + fs cleanup. So nothing resurrects on the next sync.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* import { cleanupZombies } from './cleanup-zombies.js';
|
|
19
|
+
* const report = cleanupZombies({ dryRun: false });
|
|
20
|
+
*
|
|
21
|
+
* Pure module; no HTTP. Safe to call with the server down.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import {
|
|
28
|
+
getDb,
|
|
29
|
+
getKjHome,
|
|
30
|
+
addTombstone,
|
|
31
|
+
isTombstoned,
|
|
32
|
+
deleteProject,
|
|
33
|
+
deleteSession,
|
|
34
|
+
initDb,
|
|
35
|
+
closeDb,
|
|
36
|
+
} from './db.js';
|
|
37
|
+
|
|
38
|
+
// Re-export the db lifecycle helpers so callers (e.g. `kj board cleanup`)
|
|
39
|
+
// can do a single `await import('cleanup-zombies.js')` and stay within
|
|
40
|
+
// the project's dynamic-import budget. The script otherwise needs both
|
|
41
|
+
// initDb/closeDb (lifecycle) and cleanupZombies (logic).
|
|
42
|
+
export { initDb, closeDb };
|
|
43
|
+
|
|
44
|
+
const EPHEMERAL_PREFIXES = ['tmp_', 'test_', 'demo_', 'kj-test-', 's_', 'plan-'];
|
|
45
|
+
|
|
46
|
+
function isEphemeralId(id) {
|
|
47
|
+
return EPHEMERAL_PREFIXES.some((p) => id.startsWith(p));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function plansRoot() {
|
|
51
|
+
return process.env.KJ_PLANS_DIR || path.join(homedir(), '.kj', 'plans');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function olderThanDays(isoStr, days) {
|
|
55
|
+
if (!isoStr) return true;
|
|
56
|
+
const t = Date.parse(isoStr);
|
|
57
|
+
if (Number.isNaN(t)) return true;
|
|
58
|
+
return (Date.now() - t) > days * 24 * 60 * 60 * 1000;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function olderThanHours(mtimeMs, hours) {
|
|
62
|
+
return (Date.now() - mtimeMs) > hours * 60 * 60 * 1000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {boolean} [opts.dryRun=false] - Compute the report without writing.
|
|
68
|
+
* @param {number} [opts.staleProjectDays=7]
|
|
69
|
+
* @param {number} [opts.stalePromptHours=24]
|
|
70
|
+
* @returns {{ projects: string[], prompts: string[], sessions: string[], dryRun: boolean }}
|
|
71
|
+
*/
|
|
72
|
+
export function cleanupZombies(opts = {}) {
|
|
73
|
+
const dryRun = !!opts.dryRun;
|
|
74
|
+
const staleProjectDays = opts.staleProjectDays ?? 7;
|
|
75
|
+
const stalePromptHours = opts.stalePromptHours ?? 24;
|
|
76
|
+
|
|
77
|
+
const report = { projects: [], prompts: [], sessions: [], dryRun };
|
|
78
|
+
const db = getDb();
|
|
79
|
+
|
|
80
|
+
// (1) Ephemeral projects -------------------------------------------------
|
|
81
|
+
const allProjects = db.prepare('SELECT id, last_activity FROM projects').all();
|
|
82
|
+
for (const proj of allProjects) {
|
|
83
|
+
if (!isEphemeralId(proj.id)) continue;
|
|
84
|
+
if (!olderThanDays(proj.last_activity, staleProjectDays)) continue;
|
|
85
|
+
report.projects.push(proj.id);
|
|
86
|
+
if (dryRun) continue;
|
|
87
|
+
|
|
88
|
+
const storyIds = db.prepare('SELECT id FROM stories WHERE project_id = ?').all(proj.id).map((r) => r.id);
|
|
89
|
+
const sessionIds = db.prepare('SELECT id FROM sessions WHERE project_id = ?').all(proj.id).map((r) => r.id);
|
|
90
|
+
const fsPaths = [
|
|
91
|
+
...storyIds.map((sid) => path.join(getKjHome(), 'hu-stories', sid)),
|
|
92
|
+
...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
|
|
93
|
+
path.join(plansRoot(), proj.id),
|
|
94
|
+
];
|
|
95
|
+
deleteProject(proj.id);
|
|
96
|
+
addTombstone('project', proj.id, { source: 'auto-cleanup', fsPaths });
|
|
97
|
+
for (const sid of storyIds) addTombstone('story', sid, { source: 'auto-cleanup' });
|
|
98
|
+
for (const sid of sessionIds) addTombstone('session', sid, { source: 'auto-cleanup' });
|
|
99
|
+
for (const p of fsPaths) { try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* */ } }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// (2) Stale prompts ------------------------------------------------------
|
|
103
|
+
const promptsDir = path.join(getKjHome(), 'prompts');
|
|
104
|
+
if (fs.existsSync(promptsDir)) {
|
|
105
|
+
for (const name of fs.readdirSync(promptsDir)) {
|
|
106
|
+
if (!name.endsWith('.json') || name.endsWith('.answer.json')) continue;
|
|
107
|
+
const promptId = name.replace(/\.json$/, '');
|
|
108
|
+
if (isTombstoned('prompt', promptId)) continue;
|
|
109
|
+
const fullPath = path.join(promptsDir, name);
|
|
110
|
+
const answerPath = path.join(promptsDir, `${promptId}.answer.json`);
|
|
111
|
+
if (fs.existsSync(answerPath)) continue; // already resolved
|
|
112
|
+
const stat = fs.statSync(fullPath);
|
|
113
|
+
if (!olderThanHours(stat.mtimeMs, stalePromptHours)) continue;
|
|
114
|
+
report.prompts.push(promptId);
|
|
115
|
+
if (dryRun) continue;
|
|
116
|
+
addTombstone('prompt', promptId, { source: 'auto-cleanup', fsPaths: [fullPath, answerPath] });
|
|
117
|
+
try { fs.unlinkSync(fullPath); } catch { /* */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// (3) Orphan session directories ----------------------------------------
|
|
122
|
+
const sessionsDir = path.join(getKjHome(), 'sessions');
|
|
123
|
+
if (fs.existsSync(sessionsDir)) {
|
|
124
|
+
for (const dir of fs.readdirSync(sessionsDir)) {
|
|
125
|
+
if (isTombstoned('session', dir)) continue;
|
|
126
|
+
const fullPath = path.join(sessionsDir, dir);
|
|
127
|
+
const stat = fs.statSync(fullPath);
|
|
128
|
+
if (!stat.isDirectory()) continue;
|
|
129
|
+
const inDb = db.prepare('SELECT id FROM sessions WHERE id = ?').get(dir);
|
|
130
|
+
if (inDb) continue; // active session, not orphan
|
|
131
|
+
const mtimeIso = new Date(stat.mtimeMs).toISOString();
|
|
132
|
+
if (!olderThanDays(mtimeIso, staleProjectDays)) continue;
|
|
133
|
+
report.sessions.push(dir);
|
|
134
|
+
if (dryRun) continue;
|
|
135
|
+
deleteSession(dir);
|
|
136
|
+
addTombstone('session', dir, { source: 'auto-cleanup', fsPaths: [fullPath] });
|
|
137
|
+
try { fs.rmSync(fullPath, { recursive: true, force: true }); } catch { /* */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Karajan-CLI launcher used by the board's "Commands" panel.
|
|
3
|
+
* Wraps the same spawn-detached-with-log pattern that `runPlan`
|
|
4
|
+
* already uses (see plan-mutations.js), but covers the broader set
|
|
5
|
+
* of commands the user wants to trigger from the UI: plan,
|
|
6
|
+
* architect, clean, etc.
|
|
7
|
+
*
|
|
8
|
+
* Security: the command name is matched against an explicit
|
|
9
|
+
* whitelist. Free-form arg arrays are NOT allowed — each command
|
|
10
|
+
* builds its argv from a typed schema. This keeps a curious browser
|
|
11
|
+
* client from doing `node cli.js -e "rm -rf /"` via a crafted POST.
|
|
12
|
+
*
|
|
13
|
+
* Output: each command writes to `~/.karajan/hu-board-runs/
|
|
14
|
+
* cmd-<commandId>.log`, which the existing `/api/plans/:planId/log`
|
|
15
|
+
* endpoint can tail (we generalise it to take a log id, not just a
|
|
16
|
+
* planId, in routes/api.js).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, openSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
// Repo root → so we can spawn `node <repo>/src/cli.js …` regardless
|
|
27
|
+
// of whether `kj` is on $PATH for the board process.
|
|
28
|
+
const REPO_ROOT = (() => {
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url)); // packages/hu-board/src
|
|
30
|
+
return join(here, "..", "..", ".."); // → repo root
|
|
31
|
+
})();
|
|
32
|
+
const KJ_CLI = join(REPO_ROOT, "src", "cli.js");
|
|
33
|
+
|
|
34
|
+
// Path now lives in db.js::getHuBoardRunsDir() — KJC-TSK-0421
|
|
35
|
+
// unification. The local helper is gone; callers import directly.
|
|
36
|
+
import { getHuBoardRunsDir } from "./db.js";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whitelist + argv builders. Each entry maps a logical command name
|
|
40
|
+
* (`plan`, `architect`, `clean`) to a function that turns the
|
|
41
|
+
* board-side input into a CLI argv. Adding a new command means: add
|
|
42
|
+
* an entry here and an entry in the board UI's `COMMANDS` table.
|
|
43
|
+
*
|
|
44
|
+
* Why typed builders instead of a generic args array: the browser
|
|
45
|
+
* client can't smuggle arbitrary flags. Anything that's not in the
|
|
46
|
+
* whitelist is rejected with 400.
|
|
47
|
+
*
|
|
48
|
+
* Each builder returns `{ args, projectDir? }`. `projectDir` is the
|
|
49
|
+
* cwd the spawn should use (defaults to the board's cwd).
|
|
50
|
+
*/
|
|
51
|
+
const COMMAND_BUILDERS = {
|
|
52
|
+
plan: (input) => {
|
|
53
|
+
const args = ["plan"];
|
|
54
|
+
if (input.taskFile) args.push("--task-file", String(input.taskFile));
|
|
55
|
+
if (input.context) args.push("--context", String(input.context));
|
|
56
|
+
if (input.quick) args.push("--quick");
|
|
57
|
+
if (input.noTestsSynth) args.push("--no-tests-synth");
|
|
58
|
+
if (input.noPlanReview) args.push("--no-plan-review");
|
|
59
|
+
// Positional task goes last when no --task-file was given.
|
|
60
|
+
if (!input.taskFile && input.task) args.push(String(input.task));
|
|
61
|
+
return { args, projectDir: input.projectDir };
|
|
62
|
+
},
|
|
63
|
+
architect: (input) => {
|
|
64
|
+
const args = ["architect"];
|
|
65
|
+
if (input.taskFile) args.push("--task-file", String(input.taskFile));
|
|
66
|
+
if (input.context) args.push("--context", String(input.context));
|
|
67
|
+
if (!input.taskFile && input.task) args.push(String(input.task));
|
|
68
|
+
return { args, projectDir: input.projectDir };
|
|
69
|
+
},
|
|
70
|
+
clean: (input) => {
|
|
71
|
+
const args = ["clean", "--yes"]; // never prompt; UI confirms ahead
|
|
72
|
+
if (input.nuke) args.push("--nuke");
|
|
73
|
+
if (input.dryRun) args.push("--dry-run");
|
|
74
|
+
return { args };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Spawn the requested CLI command as a detached child. Returns
|
|
80
|
+
* `{ ok, commandId, pid, logPath, argv }` so the caller can offer
|
|
81
|
+
* the log to the user via the existing log viewer.
|
|
82
|
+
*
|
|
83
|
+
* Test seam: `KJ_RUN_SPAWN_MODE=echo` short-circuits the spawn so
|
|
84
|
+
* vitest doesn't actually boot the orchestrator.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} args
|
|
87
|
+
* @param {string} args.command - one of the COMMAND_BUILDERS keys
|
|
88
|
+
* @param {object} [args.input]
|
|
89
|
+
* @returns {{ ok: boolean, commandId?: string, pid?: number,
|
|
90
|
+
* logPath?: string, argv?: string[], error?: string }}
|
|
91
|
+
*/
|
|
92
|
+
export function runKjCommand({ command, input = {} } = {}) {
|
|
93
|
+
const build = COMMAND_BUILDERS[command];
|
|
94
|
+
if (!build) {
|
|
95
|
+
return { ok: false, error: `Unsupported command: ${command}. Allowed: ${Object.keys(COMMAND_BUILDERS).join(", ")}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let built;
|
|
99
|
+
try {
|
|
100
|
+
built = build(input);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { ok: false, error: `Bad input for "${command}": ${err.message}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const dir = getHuBoardRunsDir();
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
const commandId = `cmd-${command}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
108
|
+
const logPath = join(dir, `${commandId}.log`);
|
|
109
|
+
const argv = [KJ_CLI, ...built.args];
|
|
110
|
+
|
|
111
|
+
if (process.env.KJ_RUN_SPAWN_MODE === "echo") {
|
|
112
|
+
return { ok: true, commandId, pid: 0, logPath, argv: [process.execPath, ...argv] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const out = openSync(logPath, "a");
|
|
116
|
+
const err = openSync(logPath, "a");
|
|
117
|
+
const child = spawn(process.execPath, argv, {
|
|
118
|
+
cwd: built.projectDir || process.cwd(),
|
|
119
|
+
detached: true,
|
|
120
|
+
stdio: ["ignore", out, err],
|
|
121
|
+
// KJ_INSIDE_BOARD: lets `kj clean --nuke` skip SIGTERMing the
|
|
122
|
+
// board (it'd kill the page the user is looking at). Other
|
|
123
|
+
// future "is this run hosted by the board" branches can read
|
|
124
|
+
// the same flag.
|
|
125
|
+
env: { ...process.env, CLAUDECODE: undefined, KJ_INSIDE_BOARD: "1" },
|
|
126
|
+
});
|
|
127
|
+
child.unref();
|
|
128
|
+
return { ok: true, commandId, pid: child.pid ?? 0, logPath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Test helper: list of command names exposed. */
|
|
132
|
+
export function listSupportedCommands() {
|
|
133
|
+
return Object.keys(COMMAND_BUILDERS);
|
|
134
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read / write the global Karajan config (~/.karajan/kj.config.yml)
|
|
3
|
+
* from the board.
|
|
4
|
+
*
|
|
5
|
+
* The board is a Node Express server that the user already has
|
|
6
|
+
* running, so it's the natural place to expose a "settings" page —
|
|
7
|
+
* no extra terminal trip, no risk of malformed YAML, plain Spanish
|
|
8
|
+
* labels for non-technical users.
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
*
|
|
12
|
+
* - The yml may contain dozens of fields (legacy, advanced, future
|
|
13
|
+
* experiments). The board only EXPOSES + EDITS a small whitelist
|
|
14
|
+
* that maps to the labels the UI shows. Everything else is read,
|
|
15
|
+
* preserved verbatim, and written back unchanged. So a power
|
|
16
|
+
* user editing the file by hand never loses keys when they later
|
|
17
|
+
* touch the modal.
|
|
18
|
+
*
|
|
19
|
+
* - Writes go through a tmp-file + atomic rename so a partial
|
|
20
|
+
* write never leaves the user with a corrupt yml.
|
|
21
|
+
*
|
|
22
|
+
* - A `.bak` copy is kept of the previous content so the user can
|
|
23
|
+
* recover from a botched save outside the modal (rare but
|
|
24
|
+
* irrecoverable otherwise).
|
|
25
|
+
*
|
|
26
|
+
* - VITEST aware: when KJ_HOME is set we honour it (every test
|
|
27
|
+
* harness uses tmp dirs already). When not set we use
|
|
28
|
+
* `~/.karajan/kj.config.yml` like the rest of Karajan does.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs';
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import yaml from 'js-yaml';
|
|
35
|
+
import { getKjHome } from './db.js';
|
|
36
|
+
|
|
37
|
+
function configPath() {
|
|
38
|
+
// Same path the parent uses (src/utils/paths.js::getKarajanHome).
|
|
39
|
+
// KJC-TSK-0420: delegate to db.js::getKjHome — gives us KARAJAN_HOME
|
|
40
|
+
// priority + KJ_HOME fallback + VITEST guard from one place, instead
|
|
41
|
+
// of duplicating the precedence here.
|
|
42
|
+
return join(getKjHome(), 'kj.config.yml');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function backupPath() { return `${configPath()}.bak`; }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whitelist of fields the board's modal exposes. Everything else
|
|
49
|
+
* stays untouched in the yml. Each entry says where in the yml the
|
|
50
|
+
* field lives (`path`) and how to render it for the UI (`type`,
|
|
51
|
+
* `label`, `options` for selects).
|
|
52
|
+
*
|
|
53
|
+
* The path is a dotted notation we resolve manually (so we never
|
|
54
|
+
* need to take a runtime dep on lodash for `_.get`/`_.set`).
|
|
55
|
+
*/
|
|
56
|
+
export const EDITABLE_FIELDS = [
|
|
57
|
+
{
|
|
58
|
+
key: 'coder',
|
|
59
|
+
path: 'coder',
|
|
60
|
+
type: 'select',
|
|
61
|
+
label: 'Agente para el rol "coder"',
|
|
62
|
+
help: 'Quién escribe el código. Claude es el más fiable, Codex es más rápido.',
|
|
63
|
+
options: ['claude', 'codex', 'gemini', 'aider'],
|
|
64
|
+
default: 'claude',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'reviewer',
|
|
68
|
+
path: 'reviewer',
|
|
69
|
+
type: 'select',
|
|
70
|
+
label: 'Agente para el rol "reviewer"',
|
|
71
|
+
help: 'Quién revisa el código del coder. Conviene que sea un agente distinto al coder para detectar errores.',
|
|
72
|
+
options: ['claude', 'codex', 'gemini', 'aider'],
|
|
73
|
+
default: 'codex',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'modelMode',
|
|
77
|
+
path: '_modelMode_virtual',
|
|
78
|
+
type: 'select',
|
|
79
|
+
label: 'Estrategia de modelos',
|
|
80
|
+
help: 'Auto = Karajan elige el modelo según la complejidad (haiku/sonnet/opus). Sonnet siempre = barato, ideal para pruebas. Opus siempre = caro, máxima calidad.',
|
|
81
|
+
options: ['auto', 'sonnet', 'opus'],
|
|
82
|
+
default: 'auto',
|
|
83
|
+
// Virtual field: doesn't map 1:1 to a yml key. Translate when
|
|
84
|
+
// reading + writing (see readConfig + writeConfigPatch).
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'maxIterationMinutes',
|
|
88
|
+
path: 'session.max_iteration_minutes',
|
|
89
|
+
type: 'number',
|
|
90
|
+
label: 'Tope de minutos por iteración',
|
|
91
|
+
help: 'Si una iteración (coder + reviewer) supera este tiempo, Karajan la corta y Solomon decide qué hacer.',
|
|
92
|
+
min: 1,
|
|
93
|
+
max: 120,
|
|
94
|
+
default: 30,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'maxTotalMinutes',
|
|
98
|
+
path: 'session.max_total_minutes',
|
|
99
|
+
type: 'number',
|
|
100
|
+
label: 'Tope de minutos total por sesión',
|
|
101
|
+
help: 'Tope absoluto del wall-clock de la sesión. Si se llega aquí, Karajan para aunque queden HUs.',
|
|
102
|
+
min: 5,
|
|
103
|
+
max: 480,
|
|
104
|
+
default: 120,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: 'sonarEnabled',
|
|
108
|
+
path: 'sonarqube.enabled',
|
|
109
|
+
type: 'boolean',
|
|
110
|
+
label: 'Activar análisis SonarQube',
|
|
111
|
+
help: 'Pasa el código por SonarQube y bloquea si el quality gate falla. Requiere SonarQube corriendo.',
|
|
112
|
+
default: false,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const ROLES_FOR_MODEL_MODE = [
|
|
117
|
+
'planner', 'architect', 'coder', 'reviewer', 'triage',
|
|
118
|
+
'researcher', 'tester', 'security', 'refactorer', 'impeccable'
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
function getDeep(obj, path) {
|
|
122
|
+
if (!obj || !path) return undefined;
|
|
123
|
+
const parts = path.split('.');
|
|
124
|
+
let cur = obj;
|
|
125
|
+
for (const p of parts) {
|
|
126
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
127
|
+
cur = cur[p];
|
|
128
|
+
}
|
|
129
|
+
return cur;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function setDeep(obj, path, value) {
|
|
133
|
+
const parts = path.split('.');
|
|
134
|
+
const last = parts.pop();
|
|
135
|
+
let cur = obj;
|
|
136
|
+
for (const p of parts) {
|
|
137
|
+
if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
|
|
138
|
+
cur = cur[p];
|
|
139
|
+
}
|
|
140
|
+
cur[last] = value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function deleteDeep(obj, path) {
|
|
144
|
+
const parts = path.split('.');
|
|
145
|
+
const last = parts.pop();
|
|
146
|
+
let cur = obj;
|
|
147
|
+
for (const p of parts) {
|
|
148
|
+
if (cur == null || typeof cur !== 'object') return;
|
|
149
|
+
cur = cur[p];
|
|
150
|
+
}
|
|
151
|
+
if (cur && typeof cur === 'object') delete cur[last];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Derive the virtual `modelMode` from the actual yml shape. Looks at
|
|
156
|
+
* roles.<role>.model: if every editable role pins the same model,
|
|
157
|
+
* that's the mode. Otherwise "auto" (the default — Karajan's
|
|
158
|
+
* smart-select picks per role).
|
|
159
|
+
*/
|
|
160
|
+
function deriveModelMode(parsed) {
|
|
161
|
+
const roles = parsed?.roles;
|
|
162
|
+
if (!roles || typeof roles !== 'object') return 'auto';
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const r of ROLES_FOR_MODEL_MODE) {
|
|
165
|
+
const m = roles[r]?.model;
|
|
166
|
+
if (m) seen.add(String(m).toLowerCase());
|
|
167
|
+
}
|
|
168
|
+
if (seen.size === 1) {
|
|
169
|
+
const only = [...seen][0];
|
|
170
|
+
if (only === 'sonnet' || only === 'opus') return only;
|
|
171
|
+
}
|
|
172
|
+
return 'auto';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Apply the virtual `modelMode` back to the yml: 'auto' deletes the
|
|
177
|
+
* roles.<r>.model overrides; 'sonnet' / 'opus' set them on every
|
|
178
|
+
* editable role. Other yml keys are left alone.
|
|
179
|
+
*/
|
|
180
|
+
function applyModelMode(parsed, mode) {
|
|
181
|
+
if (!parsed.roles) parsed.roles = {};
|
|
182
|
+
if (mode === 'auto') {
|
|
183
|
+
for (const r of ROLES_FOR_MODEL_MODE) {
|
|
184
|
+
if (parsed.roles[r]?.model) delete parsed.roles[r].model;
|
|
185
|
+
if (parsed.roles[r] && Object.keys(parsed.roles[r]).length === 0) delete parsed.roles[r];
|
|
186
|
+
}
|
|
187
|
+
if (Object.keys(parsed.roles).length === 0) delete parsed.roles;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const r of ROLES_FOR_MODEL_MODE) {
|
|
191
|
+
if (!parsed.roles[r] || typeof parsed.roles[r] !== 'object') parsed.roles[r] = {};
|
|
192
|
+
parsed.roles[r].model = mode;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the current config + flatten it into the shape the UI wants:
|
|
198
|
+
* {
|
|
199
|
+
* path: "/abs/path",
|
|
200
|
+
* fields: [{ key, label, help, type, options?, value, default }],
|
|
201
|
+
* raw: <full parsed yml> // power-user inspection
|
|
202
|
+
* }
|
|
203
|
+
*
|
|
204
|
+
* Returns sane defaults when the file doesn't exist yet.
|
|
205
|
+
*/
|
|
206
|
+
export function readConfig() {
|
|
207
|
+
const p = configPath();
|
|
208
|
+
let parsed = {};
|
|
209
|
+
let exists = false;
|
|
210
|
+
if (existsSync(p)) {
|
|
211
|
+
try {
|
|
212
|
+
parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {};
|
|
213
|
+
exists = true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw new Error(`No se pudo parsear ${p}: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const fields = EDITABLE_FIELDS.map((f) => {
|
|
219
|
+
let value;
|
|
220
|
+
if (f.key === 'modelMode') value = deriveModelMode(parsed);
|
|
221
|
+
else value = getDeep(parsed, f.path);
|
|
222
|
+
if (value === undefined) value = f.default;
|
|
223
|
+
return { ...f, value };
|
|
224
|
+
});
|
|
225
|
+
return { path: p, exists, fields, raw: parsed };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Apply a patch (subset of EDITABLE_FIELDS keys → new values) to the
|
|
230
|
+
* yml. Validates each field; refuses unknown keys. Writes via tmp +
|
|
231
|
+
* rename; keeps a .bak.
|
|
232
|
+
*
|
|
233
|
+
* @returns {{ path: string, written: boolean, applied: object[], errors: string[] }}
|
|
234
|
+
*/
|
|
235
|
+
export function writeConfigPatch(patch) {
|
|
236
|
+
if (!patch || typeof patch !== 'object') {
|
|
237
|
+
return { path: configPath(), written: false, applied: [], errors: ['patch debe ser un objeto'] };
|
|
238
|
+
}
|
|
239
|
+
// Read current state (or empty if file doesn't exist yet).
|
|
240
|
+
const p = configPath();
|
|
241
|
+
let parsed = {};
|
|
242
|
+
if (existsSync(p)) {
|
|
243
|
+
try { parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {}; }
|
|
244
|
+
catch (err) { return { path: p, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
|
|
245
|
+
}
|
|
246
|
+
const errors = [];
|
|
247
|
+
const applied = [];
|
|
248
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
249
|
+
const field = EDITABLE_FIELDS.find((f) => f.key === key);
|
|
250
|
+
if (!field) { errors.push(`Campo desconocido: ${key}`); continue; }
|
|
251
|
+
|
|
252
|
+
if (field.type === 'select' && !field.options.includes(val)) {
|
|
253
|
+
errors.push(`${key}: valor inválido "${val}". Permitidos: ${field.options.join(', ')}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (field.type === 'number') {
|
|
257
|
+
const n = Number(val);
|
|
258
|
+
if (!Number.isFinite(n)) { errors.push(`${key}: debe ser un número`); continue; }
|
|
259
|
+
if (field.min != null && n < field.min) { errors.push(`${key}: mínimo ${field.min}`); continue; }
|
|
260
|
+
if (field.max != null && n > field.max) { errors.push(`${key}: máximo ${field.max}`); continue; }
|
|
261
|
+
}
|
|
262
|
+
if (field.type === 'boolean' && typeof val !== 'boolean') {
|
|
263
|
+
errors.push(`${key}: debe ser true o false`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (field.key === 'modelMode') applyModelMode(parsed, val);
|
|
268
|
+
else setDeep(parsed, field.path, field.type === 'number' ? Number(val) : val);
|
|
269
|
+
applied.push({ key, value: val });
|
|
270
|
+
}
|
|
271
|
+
if (errors.length > 0) return { path: p, written: false, applied, errors };
|
|
272
|
+
|
|
273
|
+
// Write atomically: serialize → write to .tmp → rename. Backup the
|
|
274
|
+
// existing file first so the user can recover.
|
|
275
|
+
const dir = dirname(p);
|
|
276
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
277
|
+
if (existsSync(p)) { try { copyFileSync(p, backupPath()); } catch { /* best-effort */ } }
|
|
278
|
+
const dump = yaml.dump(parsed, { lineWidth: 120 });
|
|
279
|
+
// mkstemp would be nicer but we want to stay in the same dir for
|
|
280
|
+
// a guaranteed-atomic rename across the same filesystem.
|
|
281
|
+
const tmpFile = join(dir, `.kj.config.yml.${process.pid}.${Date.now()}.tmp`);
|
|
282
|
+
writeFileSync(tmpFile, dump, 'utf8');
|
|
283
|
+
renameSync(tmpFile, p);
|
|
284
|
+
return { path: p, written: true, applied, errors: [] };
|
|
285
|
+
}
|