karajan-code 1.34.4 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/docs/README.es.md +3 -2
- package/package.json +1 -1
- package/src/agents/aider-agent.js +16 -9
- package/src/agents/base-agent.js +15 -0
- package/src/agents/claude-agent.js +51 -6
- package/src/agents/codex-agent.js +35 -13
- package/src/agents/gemini-agent.js +17 -9
- package/src/agents/model-registry.js +8 -7
- package/src/agents/opencode-agent.js +17 -10
- package/src/bootstrap.js +235 -0
- package/src/commands/doctor.js +98 -0
- package/src/config.js +1 -1
- package/src/mcp/server-handlers.js +33 -2
- package/src/orchestrator/preflight-checks.js +54 -27
- package/src/orchestrator/solomon-escalation.js +11 -2
- package/src/orchestrator.js +9 -2
- package/src/roles/solomon-role.js +17 -1
- package/src/sonar/credentials.js +35 -0
- package/src/sonar/scanner.js +8 -7
- package/src/utils/budget.js +12 -8
- package/src/utils/model-selector.js +3 -3
- package/src/utils/stall-detector.js +5 -5
- package/templates/kj.config.yml +3 -0
package/README.md
CHANGED
|
@@ -152,6 +152,7 @@ Use `kj roles show <role>` to inspect any template.
|
|
|
152
152
|
Karajan auto-detects and auto-configures everything it can:
|
|
153
153
|
|
|
154
154
|
- **TDD**: Detects test framework (vitest, jest, mocha) → auto-enables TDD
|
|
155
|
+
- **Bootstrap gate**: Validates all prerequisites (git repo, remote, config, agents, SonarQube) before any tool runs. Fails hard with actionable fix instructions — never silently degrades
|
|
155
156
|
- **SonarQube**: Auto-starts Docker container, generates config if missing
|
|
156
157
|
- **Pipeline complexity**: Triage classifies task → trivial tasks skip reviewer loop
|
|
157
158
|
- **Provider outages**: Retries on 500/502/503/504 with backoff (same as rate limits)
|
|
@@ -163,9 +164,9 @@ No per-project configuration required. If you want to customize, config is layer
|
|
|
163
164
|
|
|
164
165
|
Because it should be.
|
|
165
166
|
|
|
166
|
-
Karajan has **
|
|
167
|
+
Karajan has **1966 tests** across 157 files. It runs on Node.js without a build step. You can read the source, understand it, fork it, and modify it without a TypeScript compiler between you and the code.
|
|
167
168
|
|
|
168
|
-
This is a deliberate choice, not a limitation. The tests are the type safety. The legibility is a feature. **
|
|
169
|
+
This is a deliberate choice, not a limitation. The tests are the type safety. The legibility is a feature. **55 releases in 26 days** — that velocity is possible precisely because vanilla JS with good tests lets you move fast without fear.
|
|
169
170
|
|
|
170
171
|
## Recommended companions
|
|
171
172
|
|
package/docs/README.es.md
CHANGED
|
@@ -29,8 +29,9 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
|
|
|
29
29
|
|
|
30
30
|
**Caracteristicas principales:**
|
|
31
31
|
- **Pipeline multi-agente** con 11 roles configurables
|
|
32
|
-
- **
|
|
33
|
-
- **Servidor MCP** con
|
|
32
|
+
- **5 agentes de IA soportados**: Claude, Codex, Gemini, Aider, OpenCode
|
|
33
|
+
- **Servidor MCP** con 20 herramientas — usa `kj` desde Claude, Codex o cualquier host compatible con MCP sin salir de tu agente. [Ver configuracion MCP](#servidor-mcp)
|
|
34
|
+
- **Bootstrap obligatorio** — valida prerequisitos del entorno (git, remote, config, agentes, SonarQube) antes de cada ejecucion. Si algo falta, para con instrucciones claras
|
|
34
35
|
- **TDD obligatorio** — se exigen cambios en tests cuando se modifican ficheros fuente
|
|
35
36
|
- **Integracion con SonarQube** — analisis estatico con quality gates (requiere [Docker](#requisitos))
|
|
36
37
|
- **Perfiles de revision** — standard, strict, relaxed, paranoid
|
package/package.json
CHANGED
|
@@ -5,21 +5,28 @@ import { resolveBin } from "./resolve-bin.js";
|
|
|
5
5
|
export class AiderAgent extends BaseAgent {
|
|
6
6
|
async runTask(task) {
|
|
7
7
|
const role = task.role || "coder";
|
|
8
|
-
const args = ["--yes", "--message", task.prompt];
|
|
9
8
|
const model = this.getRoleModel(role);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
9
|
+
const result = await this._exec(task, model);
|
|
10
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
11
|
+
this.logger?.warn(`Aider model "${model}" not supported — retrying with agent default`);
|
|
12
|
+
return this._exec(task, null);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
async reviewTask(task) {
|
|
20
18
|
const role = task.role || "reviewer";
|
|
21
|
-
const args = ["--yes", "--message", task.prompt];
|
|
22
19
|
const model = this.getRoleModel(role);
|
|
20
|
+
const result = await this._exec(task, model);
|
|
21
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
22
|
+
this.logger?.warn(`Aider model "${model}" not supported — retrying with agent default`);
|
|
23
|
+
return this._exec(task, null);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _exec(task, model) {
|
|
29
|
+
const args = ["--yes", "--message", task.prompt];
|
|
23
30
|
if (model) args.push("--model", model);
|
|
24
31
|
const res = await runCommand(resolveBin("aider"), args, {
|
|
25
32
|
onOutput: task.onOutput,
|
package/src/agents/base-agent.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
const MODEL_NOT_SUPPORTED_PATTERNS = [
|
|
2
|
+
/model.{0,30}is not supported/i,
|
|
3
|
+
/model.{0,30}not available/i,
|
|
4
|
+
/model.{0,30}does not exist/i,
|
|
5
|
+
/unsupported model/i,
|
|
6
|
+
/invalid model/i,
|
|
7
|
+
/model_not_found/i
|
|
8
|
+
];
|
|
9
|
+
|
|
1
10
|
export class BaseAgent {
|
|
2
11
|
constructor(name, config, logger) {
|
|
3
12
|
this.name = name;
|
|
@@ -24,4 +33,10 @@ export class BaseAgent {
|
|
|
24
33
|
if (role === "reviewer") return false;
|
|
25
34
|
return Boolean(this.config?.coder_options?.auto_approve);
|
|
26
35
|
}
|
|
36
|
+
|
|
37
|
+
isModelNotSupportedError(result) {
|
|
38
|
+
const text = [result?.error, result?.output, result?.stderr, result?.stdout]
|
|
39
|
+
.filter(Boolean).join("\n");
|
|
40
|
+
return MODEL_NOT_SUPPORTED_PATTERNS.some(re => re.test(text));
|
|
41
|
+
}
|
|
27
42
|
}
|
|
@@ -35,6 +35,29 @@ function collectAssistantText(obj) {
|
|
|
35
35
|
.map(block => block.text);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Extract usage metrics from stream-json/json NDJSON output.
|
|
40
|
+
* Looks for the "result" line which contains total_cost_usd,
|
|
41
|
+
* usage.input_tokens/output_tokens, and modelUsage.
|
|
42
|
+
* Returns an object with tokens_in, tokens_out, cost_usd, model or null if not found.
|
|
43
|
+
*/
|
|
44
|
+
export function extractUsageFromStreamJson(raw) {
|
|
45
|
+
const lines = (raw || "").split("\n").filter(Boolean);
|
|
46
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
47
|
+
const obj = tryParseJson(lines[i]);
|
|
48
|
+
if (!obj || obj.type !== "result") continue;
|
|
49
|
+
|
|
50
|
+
const tokens_in = obj.usage?.input_tokens ?? 0;
|
|
51
|
+
const tokens_out = obj.usage?.output_tokens ?? 0;
|
|
52
|
+
const cost_usd = obj.total_cost_usd ?? undefined;
|
|
53
|
+
const modelUsage = obj.modelUsage;
|
|
54
|
+
const model = modelUsage ? Object.keys(modelUsage)[0] || null : null;
|
|
55
|
+
|
|
56
|
+
return { tokens_in, tokens_out, cost_usd, model };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
/**
|
|
39
62
|
* Extract the final text result from stream-json NDJSON output.
|
|
40
63
|
* Each line is a JSON object. We collect assistant text content from
|
|
@@ -135,8 +158,28 @@ const ALLOWED_TOOLS = [
|
|
|
135
158
|
export class ClaudeAgent extends BaseAgent {
|
|
136
159
|
async runTask(task) {
|
|
137
160
|
const role = task.role || "coder";
|
|
138
|
-
const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS];
|
|
139
161
|
const model = this.getRoleModel(role);
|
|
162
|
+
const result = await this._runTaskExec(task, model, role);
|
|
163
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
164
|
+
this.logger?.warn(`Claude model "${model}" not supported — retrying with agent default`);
|
|
165
|
+
return this._runTaskExec(task, null, role);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async reviewTask(task) {
|
|
171
|
+
const role = task.role || "reviewer";
|
|
172
|
+
const model = this.getRoleModel(role);
|
|
173
|
+
const result = await this._reviewTaskExec(task, model);
|
|
174
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
175
|
+
this.logger?.warn(`Claude model "${model}" not supported — retrying with agent default`);
|
|
176
|
+
return this._reviewTaskExec(task, null);
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _runTaskExec(task, model, role) {
|
|
182
|
+
const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS];
|
|
140
183
|
if (model) args.push("--model", model);
|
|
141
184
|
|
|
142
185
|
// Use stream-json when onOutput is provided to get real-time feedback
|
|
@@ -150,7 +193,8 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
150
193
|
}));
|
|
151
194
|
const raw = pickOutput(res);
|
|
152
195
|
const output = extractTextFromStreamJson(raw);
|
|
153
|
-
|
|
196
|
+
const usage = extractUsageFromStreamJson(raw);
|
|
197
|
+
return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
|
|
154
198
|
}
|
|
155
199
|
|
|
156
200
|
// Without streaming, use json output to get structured response via stderr
|
|
@@ -158,12 +202,12 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
158
202
|
const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts());
|
|
159
203
|
const raw = pickOutput(res);
|
|
160
204
|
const output = extractTextFromStreamJson(raw);
|
|
161
|
-
|
|
205
|
+
const usage = extractUsageFromStreamJson(raw);
|
|
206
|
+
return { ok: res.exitCode === 0, output, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
|
|
162
207
|
}
|
|
163
208
|
|
|
164
|
-
async
|
|
209
|
+
async _reviewTaskExec(task, model) {
|
|
165
210
|
const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS, "--output-format", "stream-json", "--verbose"];
|
|
166
|
-
const model = this.getRoleModel(task.role || "reviewer");
|
|
167
211
|
if (model) args.push("--model", model);
|
|
168
212
|
const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts({
|
|
169
213
|
onOutput: task.onOutput,
|
|
@@ -171,6 +215,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
171
215
|
timeout: task.timeoutMs
|
|
172
216
|
}));
|
|
173
217
|
const raw = pickOutput(res);
|
|
174
|
-
|
|
218
|
+
const usage = extractUsageFromStreamJson(raw);
|
|
219
|
+
return { ok: res.exitCode === 0, output: raw, error: res.exitCode === 0 ? "" : raw, exitCode: res.exitCode, ...usage };
|
|
175
220
|
}
|
|
176
221
|
}
|
|
@@ -2,27 +2,48 @@ import { BaseAgent } from "./base-agent.js";
|
|
|
2
2
|
import { runCommand } from "../utils/process.js";
|
|
3
3
|
import { resolveBin } from "./resolve-bin.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Extract token usage from Codex CLI stdout.
|
|
7
|
+
* Codex prints "tokens used\n<number>" at the end, where number may have comma separators.
|
|
8
|
+
* Returns { tokens_out } with the total token count, or null if not found.
|
|
9
|
+
* Since Codex doesn't split input/output, we assign the total to tokens_out
|
|
10
|
+
* as a conservative estimate for cost calculation.
|
|
11
|
+
*/
|
|
12
|
+
export function extractCodexTokens(stdout) {
|
|
13
|
+
const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
const total = Number(match[1].replace(/,/g, ""));
|
|
16
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
17
|
+
return { tokens_in: 0, tokens_out: total };
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
export class CodexAgent extends BaseAgent {
|
|
6
21
|
async runTask(task) {
|
|
7
22
|
const role = task.role || "coder";
|
|
8
|
-
const args = ["exec"];
|
|
9
23
|
const model = this.getRoleModel(role);
|
|
10
|
-
|
|
11
|
-
if (this.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
timeout: task.timeoutMs,
|
|
17
|
-
input: task.prompt
|
|
18
|
-
});
|
|
19
|
-
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
24
|
+
const result = await this._exec(task, model, role);
|
|
25
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
26
|
+
this.logger?.warn(`Codex model "${model}" not supported — retrying with agent default`);
|
|
27
|
+
return this._exec(task, null, role);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
async reviewTask(task) {
|
|
33
|
+
const role = task.role || "reviewer";
|
|
34
|
+
const model = this.getRoleModel(role);
|
|
35
|
+
const result = await this._exec(task, model, role);
|
|
36
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
37
|
+
this.logger?.warn(`Codex model "${model}" not supported — retrying with agent default`);
|
|
38
|
+
return this._exec(task, null, role);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async _exec(task, model, role) {
|
|
23
44
|
const args = ["exec"];
|
|
24
|
-
const model = this.getRoleModel(task.role || "reviewer");
|
|
25
45
|
if (model) args.push("--model", model);
|
|
46
|
+
if (role !== "reviewer" && this.isAutoApproveEnabled(role)) args.push("--full-auto");
|
|
26
47
|
args.push("-");
|
|
27
48
|
const res = await runCommand(resolveBin("codex"), args, {
|
|
28
49
|
onOutput: task.onOutput,
|
|
@@ -30,6 +51,7 @@ export class CodexAgent extends BaseAgent {
|
|
|
30
51
|
timeout: task.timeoutMs,
|
|
31
52
|
input: task.prompt
|
|
32
53
|
});
|
|
33
|
-
|
|
54
|
+
const usage = extractCodexTokens(res.stdout);
|
|
55
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
34
56
|
}
|
|
35
57
|
}
|
|
@@ -5,21 +5,29 @@ import { resolveBin } from "./resolve-bin.js";
|
|
|
5
5
|
export class GeminiAgent extends BaseAgent {
|
|
6
6
|
async runTask(task) {
|
|
7
7
|
const role = task.role || "coder";
|
|
8
|
-
const args = ["-p", task.prompt];
|
|
9
8
|
const model = this.getRoleModel(role);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
9
|
+
const result = await this._exec(task, model, "run");
|
|
10
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
11
|
+
this.logger?.warn(`Gemini model "${model}" not supported — retrying with agent default`);
|
|
12
|
+
return this._exec(task, null, "run");
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
async reviewTask(task) {
|
|
20
18
|
const role = task.role || "reviewer";
|
|
21
|
-
const args = ["-p", task.prompt, "--output-format", "json"];
|
|
22
19
|
const model = this.getRoleModel(role);
|
|
20
|
+
const result = await this._exec(task, model, "review");
|
|
21
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
22
|
+
this.logger?.warn(`Gemini model "${model}" not supported — retrying with agent default`);
|
|
23
|
+
return this._exec(task, null, "review");
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _exec(task, model, mode) {
|
|
29
|
+
const args = ["-p", task.prompt];
|
|
30
|
+
if (mode === "review") args.push("--output-format", "json");
|
|
23
31
|
if (model) args.push("--model", model);
|
|
24
32
|
const res = await runCommand(resolveBin("gemini"), args, {
|
|
25
33
|
onOutput: task.onOutput,
|
|
@@ -49,15 +49,16 @@ export function buildDefaultPricingTable() {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// Auto-register built-in models
|
|
52
|
+
// Names must match what each CLI accepts as --model argument
|
|
52
53
|
registerModel("claude", { provider: "anthropic", pricing: { input_per_million: 3, output_per_million: 15 } });
|
|
53
|
-
registerModel("
|
|
54
|
-
registerModel("
|
|
55
|
-
registerModel("
|
|
54
|
+
registerModel("sonnet", { provider: "anthropic", pricing: { input_per_million: 3, output_per_million: 15 } });
|
|
55
|
+
registerModel("opus", { provider: "anthropic", pricing: { input_per_million: 15, output_per_million: 75 } });
|
|
56
|
+
registerModel("haiku", { provider: "anthropic", pricing: { input_per_million: 0.25, output_per_million: 1.25 } });
|
|
56
57
|
registerModel("codex", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
|
|
57
|
-
registerModel("
|
|
58
|
-
registerModel("
|
|
58
|
+
registerModel("o4-mini", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
|
|
59
|
+
registerModel("o3", { provider: "openai", pricing: { input_per_million: 10, output_per_million: 40 } });
|
|
59
60
|
registerModel("gemini", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
|
|
60
|
-
registerModel("gemini
|
|
61
|
-
registerModel("gemini
|
|
61
|
+
registerModel("gemini-2.5-pro", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
|
|
62
|
+
registerModel("gemini-2.0-flash", { provider: "google", pricing: { input_per_million: 0.075, output_per_million: 0.3 } });
|
|
62
63
|
registerModel("aider", { provider: "aider", pricing: { input_per_million: 3, output_per_million: 15 } });
|
|
63
64
|
registerModel("opencode", { provider: "opencode", pricing: { input_per_million: 0, output_per_million: 0 } });
|
|
@@ -5,22 +5,29 @@ import { resolveBin } from "./resolve-bin.js";
|
|
|
5
5
|
export class OpenCodeAgent extends BaseAgent {
|
|
6
6
|
async runTask(task) {
|
|
7
7
|
const role = task.role || "coder";
|
|
8
|
-
const args = ["run"];
|
|
9
8
|
const model = this.getRoleModel(role);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
});
|
|
17
|
-
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
9
|
+
const result = await this._exec(task, model, false);
|
|
10
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
11
|
+
this.logger?.warn(`OpenCode model "${model}" not supported — retrying with agent default`);
|
|
12
|
+
return this._exec(task, null, false);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
async reviewTask(task) {
|
|
21
18
|
const role = task.role || "reviewer";
|
|
22
|
-
const args = ["run", "--format", "json"];
|
|
23
19
|
const model = this.getRoleModel(role);
|
|
20
|
+
const result = await this._exec(task, model, true);
|
|
21
|
+
if (!result.ok && model && this.isModelNotSupportedError(result)) {
|
|
22
|
+
this.logger?.warn(`OpenCode model "${model}" not supported — retrying with agent default`);
|
|
23
|
+
return this._exec(task, null, true);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _exec(task, model, jsonFormat) {
|
|
29
|
+
const args = ["run"];
|
|
30
|
+
if (jsonFormat) args.push("--format", "json");
|
|
24
31
|
if (model) args.push("--model", model);
|
|
25
32
|
args.push(task.prompt);
|
|
26
33
|
const res = await runCommand(resolveBin("opencode"), args, {
|
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project bootstrap — mandatory prerequisite gate.
|
|
3
|
+
*
|
|
4
|
+
* Before any KJ tool that executes agents (kj_run, kj_code, kj_review, etc.),
|
|
5
|
+
* this module validates that ALL environment prerequisites are met.
|
|
6
|
+
*
|
|
7
|
+
* Philosophy: NEVER degrade gracefully. If something is missing, STOP and
|
|
8
|
+
* tell the user exactly what to fix. No silent fallbacks, no auto-disabling.
|
|
9
|
+
*
|
|
10
|
+
* Results are cached in `.kj-ready.json` per project (TTL-based) so that
|
|
11
|
+
* slow checks (SonarQube reachability, agent detection) don't repeat on
|
|
12
|
+
* every invocation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { ensureGitRepo } from "./utils/git.js";
|
|
20
|
+
import { runCommand } from "./utils/process.js";
|
|
21
|
+
import { checkBinary } from "./utils/agent-detect.js";
|
|
22
|
+
import { exists } from "./utils/fs.js";
|
|
23
|
+
import { getConfigPath, resolveRole } from "./config.js";
|
|
24
|
+
import { isSonarReachable, sonarUp } from "./sonar/manager.js";
|
|
25
|
+
|
|
26
|
+
const BOOTSTRAP_VERSION = 1;
|
|
27
|
+
const BOOTSTRAP_TTL_HOURS = 24;
|
|
28
|
+
const BOOTSTRAP_FILENAME = ".kj-ready.json";
|
|
29
|
+
|
|
30
|
+
function getPackageVersion() {
|
|
31
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
32
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Individual checks ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function checkGitRepo() {
|
|
38
|
+
let ok = false;
|
|
39
|
+
try {
|
|
40
|
+
ok = await ensureGitRepo();
|
|
41
|
+
} catch {
|
|
42
|
+
ok = false;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: "gitRepo",
|
|
46
|
+
ok,
|
|
47
|
+
detail: ok ? "Inside a git repository" : "Not a git repository",
|
|
48
|
+
fix: "Run 'git init' in your project directory."
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function checkGitRemote() {
|
|
53
|
+
try {
|
|
54
|
+
const res = await runCommand("git", ["remote", "get-url", "origin"]);
|
|
55
|
+
if (res.exitCode === 0 && res.stdout.trim()) {
|
|
56
|
+
return { name: "gitRemote", ok: true, detail: res.stdout.trim(), fix: null };
|
|
57
|
+
}
|
|
58
|
+
} catch { /* fall through */ }
|
|
59
|
+
return {
|
|
60
|
+
name: "gitRemote",
|
|
61
|
+
ok: false,
|
|
62
|
+
detail: "origin remote not configured",
|
|
63
|
+
fix: "Run 'git remote add origin <your-repo-url>'."
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function checkConfigExists() {
|
|
68
|
+
const configPath = getConfigPath();
|
|
69
|
+
const configOk = await exists(configPath);
|
|
70
|
+
return {
|
|
71
|
+
name: "config",
|
|
72
|
+
ok: configOk,
|
|
73
|
+
detail: configOk ? configPath : "Config file not found",
|
|
74
|
+
fix: configOk ? null : "Run 'kj_init' to create your Karajan config file."
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function checkCoreBinaries() {
|
|
79
|
+
const missing = [];
|
|
80
|
+
for (const bin of ["node", "npm", "git"]) {
|
|
81
|
+
const result = await checkBinary(bin);
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
missing.push(bin);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (missing.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
name: "coreBinaries",
|
|
89
|
+
ok: false,
|
|
90
|
+
detail: `Missing: ${missing.join(", ")}`,
|
|
91
|
+
fix: `Install missing binaries: ${missing.join(", ")}.`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { name: "coreBinaries", ok: true, detail: "node, npm, git available", fix: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function checkConfiguredAgents(config) {
|
|
98
|
+
const { provider } = resolveRole(config, "coder");
|
|
99
|
+
if (!provider) {
|
|
100
|
+
return {
|
|
101
|
+
name: "agents",
|
|
102
|
+
ok: false,
|
|
103
|
+
detail: "No coder provider configured",
|
|
104
|
+
fix: "Run 'kj_init' or set a coder provider in kj.config.yml."
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const result = await checkBinary(provider);
|
|
108
|
+
if (!result.ok) {
|
|
109
|
+
return {
|
|
110
|
+
name: "agents",
|
|
111
|
+
ok: false,
|
|
112
|
+
detail: `Coder agent "${provider}" not found`,
|
|
113
|
+
fix: `Install "${provider}" CLI. Run 'kj_doctor' for installation instructions.`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { name: "agents", ok: true, detail: `coder: ${provider}`, fix: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function checkSonarQubeReady(config) {
|
|
120
|
+
if (config.sonarqube?.enabled === false) {
|
|
121
|
+
return { name: "sonarqube", ok: true, detail: "Disabled in config", fix: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const host = config.sonarqube?.host || "http://localhost:9000";
|
|
125
|
+
|
|
126
|
+
// First attempt
|
|
127
|
+
let reachable = await isSonarReachable(host);
|
|
128
|
+
if (reachable) {
|
|
129
|
+
return { name: "sonarqube", ok: true, detail: `Reachable at ${host}`, fix: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Auto-remediation: try to start
|
|
133
|
+
try {
|
|
134
|
+
await sonarUp(host);
|
|
135
|
+
reachable = await isSonarReachable(host);
|
|
136
|
+
if (reachable) {
|
|
137
|
+
return { name: "sonarqube", ok: true, detail: `Started and reachable at ${host}`, fix: null };
|
|
138
|
+
}
|
|
139
|
+
} catch { /* fall through */ }
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
name: "sonarqube",
|
|
143
|
+
ok: false,
|
|
144
|
+
detail: `Not reachable at ${host}`,
|
|
145
|
+
fix: `Start SonarQube: 'docker start karajan-sonarqube', or disable it: set sonarqube.enabled: false in kj.config.yml, or pass --no-sonar.`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Bootstrap file management ────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function bootstrapPath(projectDir) {
|
|
152
|
+
return path.join(projectDir, BOOTSTRAP_FILENAME);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readBootstrapFile(projectDir) {
|
|
156
|
+
try {
|
|
157
|
+
const raw = await fs.readFile(bootstrapPath(projectDir), "utf8");
|
|
158
|
+
return JSON.parse(raw);
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isBootstrapValid(bootstrap, projectDir) {
|
|
165
|
+
if (!bootstrap || bootstrap.version !== BOOTSTRAP_VERSION) return false;
|
|
166
|
+
if (bootstrap.karajanVersion !== getPackageVersion()) return false;
|
|
167
|
+
if (bootstrap.projectDir !== projectDir) return false;
|
|
168
|
+
const age = Date.now() - new Date(bootstrap.createdAt).getTime();
|
|
169
|
+
if (age > BOOTSTRAP_TTL_HOURS * 3600 * 1000) return false;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function writeBootstrapFile(projectDir, checks) {
|
|
174
|
+
const data = {
|
|
175
|
+
version: BOOTSTRAP_VERSION,
|
|
176
|
+
karajanVersion: getPackageVersion(),
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
projectDir,
|
|
179
|
+
checks: Object.fromEntries(checks.map(c => [c.name, { ok: c.ok, detail: c.detail }]))
|
|
180
|
+
};
|
|
181
|
+
await fs.writeFile(bootstrapPath(projectDir), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatBootstrapFailure(failures) {
|
|
185
|
+
const lines = failures.map(f =>
|
|
186
|
+
` FAIL ${f.name}: ${f.detail}\n Fix: ${f.fix}`
|
|
187
|
+
);
|
|
188
|
+
return [
|
|
189
|
+
"BOOTSTRAP FAILED — Environment not ready for Karajan Code.\n",
|
|
190
|
+
"The following prerequisite(s) are not met:\n",
|
|
191
|
+
...lines,
|
|
192
|
+
"",
|
|
193
|
+
"Run 'kj_doctor' for a complete environment diagnostic.",
|
|
194
|
+
"Do NOT work around these issues — fix them properly."
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Ensure the project environment is ready for KJ execution.
|
|
202
|
+
* Reads cached `.kj-ready.json` if valid; otherwise runs all checks.
|
|
203
|
+
* Throws Error with actionable message if any prerequisite fails.
|
|
204
|
+
*/
|
|
205
|
+
export async function ensureBootstrap(projectDir, config) {
|
|
206
|
+
const cached = await readBootstrapFile(projectDir);
|
|
207
|
+
if (isBootstrapValid(cached, projectDir)) {
|
|
208
|
+
return; // Environment already validated
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const checks = await Promise.all([
|
|
212
|
+
checkGitRepo(),
|
|
213
|
+
checkGitRemote(),
|
|
214
|
+
checkConfigExists(),
|
|
215
|
+
checkCoreBinaries(),
|
|
216
|
+
checkConfiguredAgents(config),
|
|
217
|
+
checkSonarQubeReady(config)
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const failures = checks.filter(c => !c.ok);
|
|
221
|
+
if (failures.length > 0) {
|
|
222
|
+
throw new Error(formatBootstrapFailure(failures));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await writeBootstrapFile(projectDir, checks);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Delete `.kj-ready.json` to force re-validation on next run.
|
|
230
|
+
*/
|
|
231
|
+
export async function invalidateBootstrap(projectDir) {
|
|
232
|
+
try {
|
|
233
|
+
await fs.unlink(bootstrapPath(projectDir));
|
|
234
|
+
} catch { /* file may not exist */ }
|
|
235
|
+
}
|