karajan-code 1.57.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):
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):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.57.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
 
@@ -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
package/src/config.js CHANGED
@@ -221,7 +221,7 @@ export async function loadProjectConfig(projectDir = process.cwd()) {
221
221
  return null;
222
222
  }
223
223
  const raw = await fs.readFile(projectConfigPath, "utf8");
224
- return yaml.load(raw) || {};
224
+ return yaml.load(raw, { json: true }) || {};
225
225
  }
226
226
 
227
227
  async function loadProjectPricingOverrides(projectDir = process.cwd()) {
@@ -231,7 +231,7 @@ async function loadProjectPricingOverrides(projectDir = process.cwd()) {
231
231
  }
232
232
 
233
233
  const raw = await fs.readFile(projectConfigPath, "utf8");
234
- const parsed = yaml.load(raw) || {};
234
+ const parsed = yaml.load(raw, { json: true }) || {};
235
235
  const pricing = parsed?.budget?.pricing;
236
236
  if (!pricing || typeof pricing !== "object") {
237
237
  return null;
@@ -249,7 +249,7 @@ export async function loadConfig(projectDir) {
249
249
  const globalExists = await exists(configPath);
250
250
  if (globalExists) {
251
251
  const raw = await fs.readFile(configPath, "utf8");
252
- globalConfig = yaml.load(raw) || {};
252
+ globalConfig = yaml.load(raw, { json: true }) || {};
253
253
  }
254
254
 
255
255
  // Load project config (.karajan/kj.config.yml)
@@ -469,6 +469,13 @@ const CODER_INHERITED_ROLES = new Set([
469
469
 
470
470
  function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
471
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
+
472
479
  if (role === "coder") return legacyCoder;
473
480
  if (role === "reviewer") return legacyReviewer;
474
481
  if (CODER_INHERITED_ROLES.has(role)) return roles.coder?.provider || legacyCoder;
@@ -476,7 +483,13 @@ function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
476
483
  }
477
484
 
478
485
  function resolveModel(roleConfig, role, config) {
479
- 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
+ }
480
493
  if (role === "coder") return { model: config?.coder_options?.model ?? null, inherited: false };
481
494
  if (role === "reviewer") return { model: config?.reviewer_options?.model ?? null, inherited: false };
482
495
  if (CODER_INHERITED_ROLES.has(role)) {
@@ -495,8 +508,8 @@ export function resolveRole(config, role) {
495
508
  const provider = resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer);
496
509
  let { model, inherited: modelIsInherited } = resolveModel(roleConfig, role, config);
497
510
 
498
- // Drop inherited model if incompatible with the resolved provider
499
- 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)) {
500
513
  model = null;
501
514
  }
502
515
 
@@ -38,19 +38,30 @@ async function checkDocker() {
38
38
  };
39
39
  }
40
40
 
41
+ const SONAR_STARTUP_POLL_MS = 5000;
42
+ const SONAR_STARTUP_MAX_WAIT_MS = 60000;
43
+
44
+ async function waitForSonar(host, maxWaitMs = SONAR_STARTUP_MAX_WAIT_MS) {
45
+ const deadline = Date.now() + maxWaitMs;
46
+ while (Date.now() < deadline) {
47
+ if (await isSonarReachable(host)) return true;
48
+ await new Promise(r => setTimeout(r, SONAR_STARTUP_POLL_MS));
49
+ }
50
+ return false;
51
+ }
52
+
41
53
  async function checkSonarReachable(host) {
42
54
  const reachable = await isSonarReachable(host);
43
55
  if (reachable) {
44
56
  return { name: "sonar-reachable", ok: true, detail: `SonarQube reachable at ${host}`, remediated: false };
45
57
  }
46
58
 
47
- // Auto-remediation: try to start SonarQube
59
+ // Auto-remediation: start SonarQube and wait for it to be ready
48
60
  try {
49
61
  const upResult = await sonarUp(host);
50
62
  if (upResult.exitCode === 0) {
51
- // Verify it's actually reachable now
52
- const reachableAfter = await isSonarReachable(host);
53
- if (reachableAfter) {
63
+ // SonarQube needs 20-40s to start. Poll until ready.
64
+ if (await waitForSonar(host)) {
54
65
  return { name: "sonar-reachable", ok: true, detail: `SonarQube started and reachable at ${host}`, remediated: true };
55
66
  }
56
67
  }
@@ -58,7 +69,7 @@ async function checkSonarReachable(host) {
58
69
  // sonarUp failed, fall through
59
70
  }
60
71
 
61
- return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed)` };
72
+ return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed after ${SONAR_STARTUP_MAX_WAIT_MS / 1000}s)` };
62
73
  }
63
74
 
64
75
  async function checkSonarAuth(config) {
@@ -76,6 +76,10 @@ async function awaitWithTimeout(subprocess, timeout, getStdout) {
76
76
 
77
77
  export async function runCommand(command, args = [], options = {}) {
78
78
  const { timeout, onOutput, silenceTimeoutMs, partialOutputFlushMs, ...rest } = options;
79
+ // Always detach stdin: KJ subprocesses never need interactive input.
80
+ // Without this, a subprocess prompting for input (sonar credentials,
81
+ // agent approval, npm prompts) hangs forever since there is no TTY.
82
+ if (!rest.stdin) rest.stdin = "ignore";
79
83
  const subprocess = execa(command, args, { reject: false, ...rest });
80
84
 
81
85
  let stdoutAccum = "";