karajan-code 1.56.0 → 1.57.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 CHANGED
@@ -60,8 +60,7 @@ npm install -g karajan-code
60
60
 
61
61
  **Homebrew** (macOS):
62
62
  ```bash
63
- brew tap manufosela/tap
64
- brew install karajan-code
63
+ brew install manufosela/tap/karajan-code
65
64
  ```
66
65
 
67
66
  **One-liner** (detects OS, installs via npm):
@@ -264,6 +263,14 @@ npm run validate # Lint + test
264
263
 
265
264
  Issues and pull requests welcome. If something doesn't work as documented, [open an issue](https://github.com/manufosela/karajan-code/issues). That's the most useful contribution at this stage.
266
265
 
266
+ ## Telemetry
267
+
268
+ Karajan collects anonymous usage statistics to improve the tool:
269
+ version, OS, command used, pipeline duration and success rate.
270
+ No code, task descriptions, or personal data is ever sent.
271
+
272
+ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
273
+
267
274
  ## Links
268
275
 
269
276
  - [Website](https://karajancode.com) (also [kj-code.com](https://kj-code.com))
package/docs/README.es.md CHANGED
@@ -60,8 +60,7 @@ npm install -g karajan-code
60
60
 
61
61
  **Homebrew** (macOS):
62
62
  ```bash
63
- brew tap manufosela/tap
64
- brew install karajan-code
63
+ brew install manufosela/tap/karajan-code
65
64
  ```
66
65
 
67
66
  **One-liner** (detecta SO, instala via npm):
@@ -237,6 +236,14 @@ npm run validate # Lint + test
237
236
 
238
237
  Issues y pull requests bienvenidos. Si algo no funciona como esta documentado, [abre un issue](https://github.com/manufosela/karajan-code/issues). Es la contribucion mas util en esta fase.
239
238
 
239
+ ## Telemetria
240
+
241
+ Karajan recopila estadisticas de uso anonimas para mejorar la herramienta:
242
+ version, sistema operativo, comando utilizado, duracion del pipeline y tasa de exito.
243
+ No se envia nunca codigo, descripciones de tareas ni datos personales.
244
+
245
+ Desactivar: establece `telemetry: false` en `~/.karajan/kj.config.yml`
246
+
240
247
  ## Enlaces
241
248
 
242
249
  - [Web](https://karajancode.com) (tambien [kj-code.com](https://kj-code.com))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.56.0",
3
+ "version": "1.57.2",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -0,0 +1,77 @@
1
+ # AI Attribution Guard — GitHub Action
2
+ #
3
+ # Copy this file to .github/workflows/ai-attribution-guard.yml in any repo.
4
+ # Blocks PRs that contain AI attribution comments or Co-Authored-By lines.
5
+ # No dependencies, no Node.js required. Pure bash + grep.
6
+
7
+ name: AI Attribution Guard
8
+
9
+ on:
10
+ pull_request:
11
+ types: [opened, synchronize, reopened]
12
+
13
+ jobs:
14
+ scan:
15
+ name: Scan for AI attribution
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0
21
+
22
+ - name: Check PR diff for AI attribution
23
+ run: |
24
+ DIFF=$(git diff origin/${{ github.base_ref }}...HEAD \
25
+ -- ':!*.png' ':!*.jpg' ':!*.svg' ':!*.ico' \
26
+ ':!*.lock' ':!package-lock.json' ':!yarn.lock' \
27
+ ':!*.min.js' ':!*.min.css' \
28
+ ':!.githooks/*' \
29
+ | grep '^+' \
30
+ | grep -v '^+++' \
31
+ || true)
32
+
33
+ [ -z "$DIFF" ] && echo "No diff to scan" && exit 0
34
+
35
+ # Attribution patterns (not functional references like provider: "claude")
36
+ ATTR='(Generated|Created|Written|Made|Built|Authored|Assisted|Powered|Produced) (by|with|using|via) .*(Claude|Anthropic|OpenAI|ChatGPT|GPT-[0-9]|Copilot|Gemini|Cursor|Windsurf|Codeium)'
37
+ COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
38
+ COMMENT='(//|#|/\*|\*|<!--|--)\s*.*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini).*(generated|assisted|helped|created|wrote|built|made)'
39
+
40
+ FOUND=""
41
+ for PAT in "$ATTR" "$COAUTH" "$COMMENT"; do
42
+ MATCH=$(echo "$DIFF" | grep -i -E "$PAT" || true)
43
+ [ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
44
+ done
45
+
46
+ if [ -n "$FOUND" ]; then
47
+ echo "::error::AI attribution detected in PR diff"
48
+ echo -e "$FOUND"
49
+ echo ""
50
+ echo "Remove AI attribution comments/lines and push again."
51
+ exit 1
52
+ fi
53
+
54
+ echo "AI attribution guard: clean"
55
+
56
+ - name: Check commit messages
57
+ run: |
58
+ COMMITS=$(git log --format='%s%n%b' origin/${{ github.base_ref }}...HEAD)
59
+
60
+ COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
61
+ ATTR='(Generated|Created|Written|Built|Authored) (by|with|using) .*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)'
62
+
63
+ FOUND=""
64
+ for PAT in "$COAUTH" "$ATTR"; do
65
+ MATCH=$(echo "$COMMITS" | grep -i -E "$PAT" || true)
66
+ [ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
67
+ done
68
+
69
+ if [ -n "$FOUND" ]; then
70
+ echo "::error::AI attribution detected in commit messages"
71
+ echo -e "$FOUND"
72
+ echo ""
73
+ echo "Amend commits to remove AI attribution and force-push."
74
+ exit 1
75
+ fi
76
+
77
+ echo "Commit messages: clean"
@@ -2,9 +2,90 @@
2
2
  * esbuild configuration for Node.js SEA (Single Executable Application) bundle.
3
3
  *
4
4
  * Bundles the entire CLI into a single CJS file suitable for SEA injection.
5
- * SEA requires CJS format — ESM entry points are transpiled by esbuild.
5
+ * A plugin handles:
6
+ * 1. import.meta.url / import.meta.dirname -> CJS equivalents
7
+ * 2. Top-level await in cli.js -> async IIFE wrapper
8
+ * 3. Runtime package.json reads -> inlined version string
6
9
  */
7
10
 
11
+ import path from "node:path";
12
+ import fs from "node:fs/promises";
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const PKG_VERSION = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
18
+
19
+ const seaTransformPlugin = {
20
+ name: "sea-transform",
21
+ setup(build) {
22
+ build.onLoad({ filter: /\.m?js$/ }, async (args) => {
23
+ let contents = await fs.readFile(args.path, "utf8");
24
+ let modified = false;
25
+
26
+ // Replace import.meta.dirname with __dirname
27
+ if (contents.includes("import.meta.dirname")) {
28
+ contents = contents.replaceAll("import.meta.dirname", "__dirname");
29
+ modified = true;
30
+ }
31
+
32
+ // Replace import.meta.url with CJS equivalent
33
+ if (contents.includes("import.meta.url")) {
34
+ contents = contents.replaceAll(
35
+ "import.meta.url",
36
+ 'require("node:url").pathToFileURL(__filename).href'
37
+ );
38
+ modified = true;
39
+ }
40
+
41
+ // Inline version: replace any pattern that reads ../../package.json or ../package.json
42
+ // to extract the version, with the hardcoded version string.
43
+ // Covers cli.js, display.js, doctor.js, bootstrap.js, init.js, server.js
44
+ if (contents.includes("package.json") && contents.match(/["']\.\.\/.*package\.json/)) {
45
+ // Pattern: const X = path.resolve(..., "../../package.json");
46
+ // const Y = JSON.parse(readFileSync(X, "utf8")).version;
47
+ // Replace both lines with: const Y = "1.57.0";
48
+ contents = contents.replace(
49
+ /const (\w+) = (?:path\.resolve|resolve)\(.*?["']\.\.\/.*?package\.json["']\);\s*\n\s*(?:const|return) (\w+) = JSON\.parse\(.*?readFileSync\(\1.*?\).*?\.version;/g,
50
+ `const $2 = ${JSON.stringify(PKG_VERSION)};`
51
+ );
52
+ // Single-line pattern: return JSON.parse(readFileSync(pkgPath...)).version
53
+ contents = contents.replace(
54
+ /const (\w+) = (?:path\.resolve|resolve)\(.*?["']\.\.\/.*?package\.json["']\);\s*\n\s*return JSON\.parse\(.*?readFileSync\(\1.*?\).*?\.version;/g,
55
+ `return ${JSON.stringify(PKG_VERSION)};`
56
+ );
57
+ // Pattern in cli.js: const PKG_PATH = ...; const PKG_VERSION = ...;
58
+ contents = contents.replace(
59
+ /const PKG_PATH = .*?package\.json.*?;\s*\nconst PKG_VERSION = .*?\.version;/,
60
+ `const PKG_VERSION = ${JSON.stringify(PKG_VERSION)};`
61
+ );
62
+ // Pattern in display.js: const DISPLAY_PKG_PATH = ...; const DISPLAY_VERSION = ...;
63
+ contents = contents.replace(
64
+ /const DISPLAY_PKG_PATH = .*?package\.json.*?;\s*\nconst DISPLAY_VERSION = .*?\.version;/,
65
+ `const DISPLAY_VERSION = ${JSON.stringify(PKG_VERSION)};`
66
+ );
67
+ // Pattern in server.js: const PKG_PATH = ...; (used later to read version)
68
+ contents = contents.replace(
69
+ /const PKG_PATH = .*?package\.json.*?;\s*\nconst PKG = JSON\.parse\(readFileSync\(PKG_PATH.*?\)\);/,
70
+ `const PKG = { version: ${JSON.stringify(PKG_VERSION)}, name: "karajan-code" };`
71
+ );
72
+ modified = true;
73
+ }
74
+
75
+ // Wrap top-level await in cli.js inside an async IIFE
76
+ if (args.path.endsWith("src/cli.js") || args.path.endsWith("src\\cli.js")) {
77
+ contents = contents.replace(
78
+ /^(try\s*\{[\s\S]*?await\s+program\.parseAsync\(\)[\s\S]*?^\})\s*$/m,
79
+ "(async () => {\n$1\n})();"
80
+ );
81
+ modified = true;
82
+ }
83
+
84
+ return modified ? { contents, loader: "js" } : undefined;
85
+ });
86
+ }
87
+ };
88
+
8
89
  /** @type {import('esbuild').BuildOptions} */
9
90
  export const seaBuildOptions = {
10
91
  entryPoints: ["src/cli.js"],
@@ -14,20 +95,7 @@ export const seaBuildOptions = {
14
95
  target: "node20",
15
96
  bundle: true,
16
97
  minify: false,
17
- // Bundle everything — no externals. The binary must be fully self-contained.
18
98
  external: [],
19
- // Shim for CJS compatibility in the SEA context.
20
- banner: {
21
- js: [
22
- "/* Karajan Code — SEA bundle */",
23
- "/* eslint-disable */",
24
- 'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;',
25
- ].join("\n"),
26
- },
27
- // Replace import.meta.url references with the CJS-compatible shim variable.
28
- define: {
29
- "import.meta.url": "__importMetaUrl",
30
- },
31
- // Log level for debugging build issues.
99
+ plugins: [seaTransformPlugin],
32
100
  logLevel: "info",
33
101
  };
@@ -0,0 +1,85 @@
1
+ #!/bin/bash
2
+ #
3
+ # Installs the AI Attribution Guard workflow in all repos of a GitHub user.
4
+ # Requires: gh CLI authenticated with admin/push permissions.
5
+ #
6
+ # Usage:
7
+ # ./scripts/install-guard-all-repos.sh # dry-run
8
+ # ./scripts/install-guard-all-repos.sh --apply # install in all repos
9
+ #
10
+ # What it does:
11
+ # - Creates .github/workflows/ai-attribution-guard.yml in each repo
12
+ # - Skips repos that already have the file
13
+ # - Skips archived repos
14
+
15
+ set -euo pipefail
16
+
17
+ OWNER="${GH_OWNER:-manufosela}"
18
+ APPLY=false
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+ WORKFLOW_FILE="$SCRIPT_DIR/ai-attribution-guard.yml"
21
+
22
+ if [[ "${1:-}" == "--apply" ]]; then
23
+ APPLY=true
24
+ fi
25
+
26
+ if [[ ! -f "$WORKFLOW_FILE" ]]; then
27
+ echo "ERROR: $WORKFLOW_FILE not found"
28
+ exit 1
29
+ fi
30
+
31
+ CONTENT=$(base64 -w 0 "$WORKFLOW_FILE")
32
+
33
+ echo "=== Install AI Attribution Guard in all repos of $OWNER ==="
34
+ echo ""
35
+
36
+ if [[ "$APPLY" == false ]]; then
37
+ echo " DRY RUN mode. Use --apply to actually install."
38
+ echo ""
39
+ fi
40
+
41
+ REPOS=$(gh repo list "$OWNER" --limit 500 --json name,isArchived \
42
+ --jq '.[] | select(.isArchived == false) | .name')
43
+
44
+ TOTAL=0
45
+ INSTALLED=0
46
+ SKIPPED=0
47
+ FAILED=0
48
+
49
+ while read -r REPO; do
50
+ [ -z "$REPO" ] && continue
51
+ TOTAL=$((TOTAL + 1))
52
+
53
+ # Check if workflow already exists
54
+ EXISTS=$(gh api "repos/$OWNER/$REPO/contents/.github/workflows/ai-attribution-guard.yml" --jq '.sha' 2>/dev/null || echo "")
55
+
56
+ if [[ -n "$EXISTS" ]]; then
57
+ echo " SKIP $REPO — workflow already exists"
58
+ SKIPPED=$((SKIPPED + 1))
59
+ continue
60
+ fi
61
+
62
+ if [[ "$APPLY" == false ]]; then
63
+ echo " WOULD $REPO"
64
+ continue
65
+ fi
66
+
67
+ if gh api "repos/$OWNER/$REPO/contents/.github/workflows/ai-attribution-guard.yml" -X PUT \
68
+ -f message="chore: add AI attribution guard workflow" \
69
+ -f content="$CONTENT" \
70
+ --jq '.commit.sha' 2>/dev/null; then
71
+ echo " OK $REPO"
72
+ INSTALLED=$((INSTALLED + 1))
73
+ else
74
+ echo " FAIL $REPO — check permissions"
75
+ FAILED=$((FAILED + 1))
76
+ fi
77
+
78
+ done <<< "$REPOS"
79
+
80
+ echo ""
81
+ echo "=== Summary ==="
82
+ echo " Total: $TOTAL"
83
+ echo " Installed: $INSTALLED"
84
+ echo " Skipped: $SKIPPED"
85
+ echo " Failed: $FAILED"
@@ -0,0 +1,88 @@
1
+ #!/bin/bash
2
+ #
3
+ # Configures branch protection on all repos of a GitHub user.
4
+ # Requires: gh CLI authenticated with admin permissions.
5
+ #
6
+ # Usage:
7
+ # ./scripts/protect-all-repos.sh # dry-run (shows what it would do)
8
+ # ./scripts/protect-all-repos.sh --apply # apply protection rules
9
+ #
10
+ # What it does:
11
+ # - Require PRs before merging (no direct push to default branch)
12
+ # - Block force push
13
+ # - Block branch deletion
14
+ #
15
+ # Skips repos where branch protection is already configured.
16
+
17
+ set -euo pipefail
18
+
19
+ OWNER="${GH_OWNER:-manufosela}"
20
+ APPLY=false
21
+
22
+ if [[ "${1:-}" == "--apply" ]]; then
23
+ APPLY=true
24
+ fi
25
+
26
+ echo "=== Branch Protection for all repos of $OWNER ==="
27
+ echo ""
28
+
29
+ if [[ "$APPLY" == false ]]; then
30
+ echo " DRY RUN mode. Use --apply to actually configure."
31
+ echo ""
32
+ fi
33
+
34
+ REPOS=$(gh repo list "$OWNER" --limit 500 --json name,defaultBranchRef,isArchived \
35
+ --jq '.[] | select(.isArchived == false) | "\(.name)\t\(.defaultBranchRef.name // "main")"')
36
+
37
+ TOTAL=0
38
+ PROTECTED=0
39
+ SKIPPED=0
40
+ FAILED=0
41
+
42
+ while IFS=$'\t' read -r REPO BRANCH; do
43
+ TOTAL=$((TOTAL + 1))
44
+
45
+ # Check if already protected
46
+ STATUS=$(gh api "repos/$OWNER/$REPO/branches/$BRANCH/protection" --jq '.required_pull_request_reviews.required_approving_review_count // 0' 2>/dev/null || echo "none")
47
+
48
+ if [[ "$STATUS" != "none" ]]; then
49
+ echo " SKIP $REPO ($BRANCH) — already protected"
50
+ SKIPPED=$((SKIPPED + 1))
51
+ continue
52
+ fi
53
+
54
+ if [[ "$APPLY" == false ]]; then
55
+ echo " WOULD $REPO ($BRANCH)"
56
+ continue
57
+ fi
58
+
59
+ # Apply protection
60
+ if gh api "repos/$OWNER/$REPO/branches/$BRANCH/protection" -X PUT \
61
+ --input - <<EOF 2>/dev/null; then
62
+ {
63
+ "required_pull_request_reviews": {
64
+ "required_approving_review_count": 0,
65
+ "dismiss_stale_reviews": false
66
+ },
67
+ "enforce_admins": false,
68
+ "required_status_checks": null,
69
+ "restrictions": null,
70
+ "allow_force_pushes": false,
71
+ "allow_deletions": false
72
+ }
73
+ EOF
74
+ echo " OK $REPO ($BRANCH)"
75
+ PROTECTED=$((PROTECTED + 1))
76
+ else
77
+ echo " FAIL $REPO ($BRANCH) — check permissions"
78
+ FAILED=$((FAILED + 1))
79
+ fi
80
+
81
+ done <<< "$REPOS"
82
+
83
+ echo ""
84
+ echo "=== Summary ==="
85
+ echo " Total: $TOTAL"
86
+ echo " Protected: $PROTECTED"
87
+ echo " Skipped: $SKIPPED"
88
+ echo " Failed: $FAILED"
package/src/bootstrap.js CHANGED
@@ -130,12 +130,16 @@ async function checkSonarQubeReady(config) {
130
130
  return { name: "sonarqube", ok: true, detail: `Reachable at ${host}`, fix: null };
131
131
  }
132
132
 
133
- // Auto-remediation: try to start
133
+ // Auto-remediation: start and wait up to 60s for SonarQube to be ready
134
134
  try {
135
135
  await sonarUp(host);
136
- reachable = await isSonarReachable(host);
137
- if (reachable) {
138
- return { name: "sonarqube", ok: true, detail: `Started and reachable at ${host}`, fix: null };
136
+ const deadline = Date.now() + 60000;
137
+ while (Date.now() < deadline) {
138
+ reachable = await isSonarReachable(host);
139
+ if (reachable) {
140
+ return { name: "sonarqube", ok: true, detail: `Started and reachable at ${host}`, fix: null };
141
+ }
142
+ await new Promise(r => setTimeout(r, 5000));
139
143
  }
140
144
  } catch { /* fall through */ }
141
145
 
package/src/cli.js CHANGED
@@ -40,6 +40,12 @@ async function withConfig(commandName, flags, fn) {
40
40
  const merged = applyRunOverrides(config, flags || {});
41
41
  validateConfig(merged, commandName);
42
42
  const logger = createLogger(merged.output.log_level);
43
+
44
+ // Telemetry: anonymous cli_command event (non-blocking)
45
+ import("./utils/telemetry.js")
46
+ .then(({ sendTelemetryEvent }) => sendTelemetryEvent("cli_command", { command: commandName, version: PKG_VERSION }, merged))
47
+ .catch(() => {});
48
+
43
49
  await fn({ config: merged, logger, flags });
44
50
  }
45
51
 
@@ -305,6 +305,35 @@ async function installSkills(logger, interactive) {
305
305
  }
306
306
  }
307
307
 
308
+ const GITIGNORE_ENTRIES = [
309
+ { pattern: ".kj/", comment: "Karajan runtime (logs, worktrees)" },
310
+ { pattern: ".agent/", comment: "Agent skills (OpenSkills)" },
311
+ { pattern: ".scannerwork/", comment: "SonarQube scanner temp" },
312
+ ];
313
+
314
+ async function ensureGitignoreEntries(projectDir, logger) {
315
+ const gitignorePath = path.join(projectDir, ".gitignore");
316
+ let content;
317
+ try {
318
+ content = await fs.readFile(gitignorePath, "utf8");
319
+ } catch {
320
+ // No .gitignore yet — will create one
321
+ }
322
+ if (!content) content = "";
323
+
324
+ const missing = GITIGNORE_ENTRIES.filter(e => !content.includes(e.pattern));
325
+ if (missing.length === 0) return;
326
+
327
+ const block = [
328
+ "",
329
+ "# Karajan Code",
330
+ ...missing.map(e => e.pattern)
331
+ ].join("\n") + "\n";
332
+
333
+ await fs.appendFile(gitignorePath, block, "utf8");
334
+ logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
335
+ }
336
+
308
337
  export async function initCommand({ logger, flags = {} }) {
309
338
  const karajanHome = getKarajanHome();
310
339
  await ensureDir(karajanHome);
@@ -329,6 +358,7 @@ export async function initCommand({ logger, flags = {} }) {
329
358
  await handleConfigSetup({ config, configExists, interactive, configPath, logger });
330
359
  await ensureReviewRules(reviewRulesPath, logger);
331
360
  await ensureCoderRules(coderRulesPath, logger);
361
+ await ensureGitignoreEntries(process.cwd(), logger);
332
362
  await installSkills(logger, interactive);
333
363
 
334
364
  // Auto-detect project stack and preconfigure
@@ -374,4 +404,15 @@ export async function initCommand({ logger, flags = {} }) {
374
404
 
375
405
  await setupSonarQube(config, logger);
376
406
  await scaffoldBecariaGateway(config, flags, logger);
407
+
408
+ // Telemetry: anonymous install event (non-blocking)
409
+ const { sendTelemetryEvent } = await import("../utils/telemetry.js");
410
+ const { readFileSync } = await import("node:fs");
411
+ const { resolve, dirname } = await import("node:path");
412
+ const { fileURLToPath } = await import("node:url");
413
+ try {
414
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
415
+ const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
416
+ sendTelemetryEvent("install", { version }, config).catch(() => {});
417
+ } catch { /* non-blocking */ }
377
418
  }
package/src/config.js CHANGED
@@ -171,6 +171,7 @@ const DEFAULTS = {
171
171
  devtools_mcp: false,
172
172
  thresholds: { lcp: 2500, cls: 0.1, inp: 200 }
173
173
  },
174
+ telemetry: true,
174
175
  guards: {
175
176
  output: {
176
177
  enabled: true,
@@ -220,7 +221,7 @@ export async function loadProjectConfig(projectDir = process.cwd()) {
220
221
  return null;
221
222
  }
222
223
  const raw = await fs.readFile(projectConfigPath, "utf8");
223
- return yaml.load(raw) || {};
224
+ return yaml.load(raw, { json: true }) || {};
224
225
  }
225
226
 
226
227
  async function loadProjectPricingOverrides(projectDir = process.cwd()) {
@@ -230,7 +231,7 @@ async function loadProjectPricingOverrides(projectDir = process.cwd()) {
230
231
  }
231
232
 
232
233
  const raw = await fs.readFile(projectConfigPath, "utf8");
233
- const parsed = yaml.load(raw) || {};
234
+ const parsed = yaml.load(raw, { json: true }) || {};
234
235
  const pricing = parsed?.budget?.pricing;
235
236
  if (!pricing || typeof pricing !== "object") {
236
237
  return null;
@@ -248,7 +249,7 @@ export async function loadConfig(projectDir) {
248
249
  const globalExists = await exists(configPath);
249
250
  if (globalExists) {
250
251
  const raw = await fs.readFile(configPath, "utf8");
251
- globalConfig = yaml.load(raw) || {};
252
+ globalConfig = yaml.load(raw, { json: true }) || {};
252
253
  }
253
254
 
254
255
  // Load project config (.karajan/kj.config.yml)
@@ -468,6 +469,13 @@ const CODER_INHERITED_ROLES = new Set([
468
469
 
469
470
  function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
470
471
  if (roleConfig.provider) return roleConfig.provider;
472
+
473
+ // If model has "provider/model" format (e.g. "gemini/pro"), extract the provider
474
+ if (roleConfig.model && roleConfig.model.includes("/")) {
475
+ const inferredProvider = roleConfig.model.split("/")[0].toLowerCase();
476
+ if (AGENT_MODEL_SIGNATURES[inferredProvider]) return inferredProvider;
477
+ }
478
+
471
479
  if (role === "coder") return legacyCoder;
472
480
  if (role === "reviewer") return legacyReviewer;
473
481
  if (CODER_INHERITED_ROLES.has(role)) return roles.coder?.provider || legacyCoder;
@@ -475,7 +483,13 @@ function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
475
483
  }
476
484
 
477
485
  function resolveModel(roleConfig, role, config) {
478
- if (roleConfig.model) return { model: roleConfig.model, inherited: false };
486
+ if (roleConfig.model) {
487
+ // Strip "provider/" prefix from models like "gemini/pro" → "pro"
488
+ const model = roleConfig.model.includes("/")
489
+ ? roleConfig.model.split("/").slice(1).join("/")
490
+ : roleConfig.model;
491
+ return { model, inherited: false };
492
+ }
479
493
  if (role === "coder") return { model: config?.coder_options?.model ?? null, inherited: false };
480
494
  if (role === "reviewer") return { model: config?.reviewer_options?.model ?? null, inherited: false };
481
495
  if (CODER_INHERITED_ROLES.has(role)) {
@@ -494,8 +508,8 @@ export function resolveRole(config, role) {
494
508
  const provider = resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer);
495
509
  let { model, inherited: modelIsInherited } = resolveModel(roleConfig, role, config);
496
510
 
497
- // Drop inherited model if incompatible with the resolved provider
498
- if (modelIsInherited && provider && model && !isModelCompatible(provider, model)) {
511
+ // Drop model if incompatible with the resolved provider (inherited or explicit)
512
+ if (provider && model && !isModelCompatible(provider, model)) {
499
513
  model = null;
500
514
  }
501
515
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Error classification for MCP tool responses.
3
+ * Extracted to its own module to avoid circular import TDZ issues
4
+ * (server-handlers.js ↔ handlers/*.js circular chain).
5
+ */
6
+
7
+ import { withDocLink } from "../utils/doc-links.js";
8
+
9
+ const ERROR_CLASSIFIERS = [
10
+ {
11
+ test: (lower) => lower.includes("without output") || lower.includes("silent for") || lower.includes("unresponsive") || lower.includes("exceeded max silence"),
12
+ category: "agent_stall",
13
+ suggestion: "Agent output stalled. Check live details with kj_status, then retry with a smaller prompt or increase session.max_agent_silence_minutes if needed."
14
+ },
15
+ {
16
+ test: (lower) => lower.includes("sonar") && (lower.includes("connect") || lower.includes("econnrefused") || lower.includes("not available") || lower.includes("not running")),
17
+ category: "sonar_unavailable",
18
+ suggestion: withDocLink("SonarQube is not reachable. Try: kj_init to set up SonarQube, or run 'docker start sonarqube' if already installed. Use --no-sonar to skip SonarQube.", "sonar_docker")
19
+ },
20
+ {
21
+ test: (lower) => lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid token"),
22
+ category: "auth_error",
23
+ suggestion: withDocLink("Authentication failed. Regenerate the SonarQube token and update it via kj_init or in ~/.karajan/kj.config.yml under sonarqube.token.", "sonar_token")
24
+ },
25
+ {
26
+ test: (lower) => lower.includes("config") && (lower.includes("missing") || lower.includes("not found") || lower.includes("invalid")),
27
+ category: "config_error",
28
+ suggestion: withDocLink("Configuration issue detected. Run kj_doctor to diagnose, or kj_init to create a fresh config.", "config_missing")
29
+ },
30
+ {
31
+ test: (lower) => lower.includes("missing provider") || lower.includes("not found") && (lower.includes("claude") || lower.includes("codex") || lower.includes("gemini") || lower.includes("aider")),
32
+ category: "agent_missing",
33
+ suggestion: withDocLink("Required agent CLI not found. Run kj_doctor to check which agents are installed and get installation instructions.", "agent_not_found")
34
+ },
35
+ {
36
+ test: (lower) => lower.includes("timed out") || lower.includes("timeout"),
37
+ category: "timeout",
38
+ suggestion: "The agent did not complete in time. Try: (1) increase --max-iteration-minutes (default: 5), (2) split the task into smaller pieces, (3) use kj_code for single-agent tasks. If a SonarQube scan timed out, check Docker health."
39
+ },
40
+ {
41
+ test: (lower) => lower.includes("you are on the base branch"),
42
+ category: "branch_error",
43
+ suggestion: withDocLink("Create a feature branch before running Karajan. Use 'git checkout -b feat/<task-description>' and then retry. Do NOT run kj_code directly on the base branch.", "branch_error")
44
+ },
45
+ {
46
+ test: (lower) => lower.includes("not a git repository"),
47
+ category: "git_error",
48
+ suggestion: "Current directory is not a git repository. Navigate to your project root or initialize git with 'git init'."
49
+ },
50
+ {
51
+ test: (lower) => lower.includes("bootstrap failed"),
52
+ category: "bootstrap_error",
53
+ suggestion: withDocLink("Environment prerequisites not met. Run kj_doctor for diagnostics, then fix the issues listed. Do NOT work around these — fix them properly.", "bootstrap_failed")
54
+ }
55
+ ];
56
+
57
+ export function classifyError(error) {
58
+ const msg = error?.message || String(error);
59
+ const lower = msg.toLowerCase();
60
+
61
+ const match = ERROR_CLASSIFIERS.find(c => c.test(lower));
62
+ if (match) {
63
+ return { category: match.category, suggestion: match.suggestion };
64
+ }
65
+ return { category: "unknown", suggestion: null };
66
+ }
@@ -28,7 +28,7 @@ import {
28
28
  assertNotOnBaseBranch,
29
29
  buildConfig,
30
30
  buildDirectEmitter,
31
- } from "../server-handlers.js";
31
+ } from "../shared-helpers.js";
32
32
 
33
33
  /**
34
34
  * Run bootstrap gate: validate all environment prerequisites before execution.