karajan-code 2.7.3 → 2.8.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.
Files changed (167) hide show
  1. package/README.md +4 -4
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/bootstrap.js +26 -1
  10. package/src/brain/decision-tracker.js +3 -1
  11. package/src/brain/decisor.js +1 -1
  12. package/src/canvas/parse-md.js +238 -0
  13. package/src/canvas/schema.js +101 -0
  14. package/src/canvas/to-flat.js +85 -0
  15. package/src/canvas/validate.js +163 -0
  16. package/src/checks/binaries.js +1 -1
  17. package/src/checks/ports.js +97 -27
  18. package/src/checks/runner.js +26 -10
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +1 -1
  21. package/src/checks/tokens.js +77 -24
  22. package/src/cli/_shared.js +30 -0
  23. package/src/cli/register-meta.js +204 -0
  24. package/src/cli/register-pipeline.js +212 -0
  25. package/src/cli/register-plan.js +152 -0
  26. package/src/cli/register-roles-skills.js +38 -0
  27. package/src/cli/register-sonar.js +28 -0
  28. package/src/cli.js +83 -468
  29. package/src/commands/agents.js +18 -6
  30. package/src/commands/architect.js +29 -2
  31. package/src/commands/audit.js +27 -3
  32. package/src/commands/board.js +160 -30
  33. package/src/commands/clean.js +83 -0
  34. package/src/commands/code.js +7 -2
  35. package/src/commands/discover.js +7 -2
  36. package/src/commands/init.js +2 -2
  37. package/src/commands/plan/_shared.js +40 -0
  38. package/src/commands/plan/add-hu.js +21 -0
  39. package/src/commands/plan/delete.js +9 -0
  40. package/src/commands/plan/generate.js +284 -0
  41. package/src/commands/plan/index.js +14 -0
  42. package/src/commands/plan/list.js +21 -0
  43. package/src/commands/plan/ready.js +46 -0
  44. package/src/commands/plan/remove-hu.js +17 -0
  45. package/src/commands/plan/show.js +81 -0
  46. package/src/commands/plan/validate.js +20 -0
  47. package/src/commands/plan.js +14 -291
  48. package/src/commands/researcher.js +7 -2
  49. package/src/commands/resume.js +24 -1
  50. package/src/commands/review.js +7 -2
  51. package/src/commands/run.js +52 -1
  52. package/src/commands/status.js +1 -1
  53. package/src/commands/sync.js +75 -0
  54. package/src/commands/triage.js +7 -2
  55. package/src/commands/undo.js +1 -1
  56. package/src/config/defaults.js +213 -0
  57. package/src/config/loader.js +137 -0
  58. package/src/config/overrides.js +199 -0
  59. package/src/config/role-resolver.js +159 -0
  60. package/src/config/test-harness.js +79 -0
  61. package/src/config.js +30 -609
  62. package/src/git/automation.js +2 -2
  63. package/src/guards/output-guard.js +0 -2
  64. package/src/hu/acceptance-runner.js +52 -10
  65. package/src/hu/store.js +11 -0
  66. package/src/mcp/handlers/direct-handlers.js +2 -5
  67. package/src/mcp/handlers/management-handlers.js +48 -4
  68. package/src/mcp/handlers/run-handler.js +9 -8
  69. package/src/mcp/preflight.js +32 -13
  70. package/src/mcp/server-handlers.js +4 -3
  71. package/src/mcp/suggest-handler.js +3 -2
  72. package/src/mcp/tools.js +15 -0
  73. package/src/orchestrator/agent-fallback.js +1 -1
  74. package/src/orchestrator/ci-integration.js +10 -10
  75. package/src/orchestrator/config-init.js +25 -7
  76. package/src/orchestrator/direct-actions.js +6 -2
  77. package/src/orchestrator/drivers/error-recovery.js +287 -0
  78. package/src/orchestrator/drivers/init-context.js +222 -0
  79. package/src/orchestrator/drivers/iteration-loop.js +311 -0
  80. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  81. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  82. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  83. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  84. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  85. package/src/orchestrator/drivers/post-loop.js +370 -0
  86. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  87. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  88. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  89. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  90. package/src/orchestrator/drivers/pre-loop.js +434 -0
  91. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  92. package/src/orchestrator/flow-control.js +4 -3
  93. package/src/orchestrator/flow-runner.js +121 -2019
  94. package/src/orchestrator/fs-leak-detector.js +156 -0
  95. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  96. package/src/orchestrator/integrations.js +88 -0
  97. package/src/orchestrator/post-loop-stages.js +35 -8
  98. package/src/orchestrator/preflight-checks.js +30 -3
  99. package/src/orchestrator/repair/repair-detector.js +117 -0
  100. package/src/orchestrator/repair/repair-runner.js +142 -0
  101. package/src/orchestrator/reviewer-fallback.js +1 -1
  102. package/src/orchestrator/solomon-escalation.js +3 -2
  103. package/src/orchestrator/solomon-rules.js +7 -3
  104. package/src/orchestrator/stages/architect-stage.js +12 -11
  105. package/src/orchestrator/stages/coder-stage.js +56 -7
  106. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  107. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  108. package/src/orchestrator/stages/planner-stage.js +1 -1
  109. package/src/orchestrator/stages/research-stage.js +1 -1
  110. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  111. package/src/orchestrator/stages/sonar-stage.js +94 -10
  112. package/src/orchestrator/stages/stage-classes.js +86 -0
  113. package/src/orchestrator/stages/stage-executor.js +1 -1
  114. package/src/orchestrator/stages/triage-stage.js +1 -1
  115. package/src/orchestrator/standby.js +3 -2
  116. package/src/orchestrator/verification-gate.js +20 -12
  117. package/src/plan/adr-generator.js +170 -0
  118. package/src/plan/adr-loader.js +60 -0
  119. package/src/plan/plan-executor.js +19 -1
  120. package/src/plan/plan-hu-ops.js +170 -1
  121. package/src/plan/plan-reviewer.js +199 -0
  122. package/src/plan/plan-schema.js +69 -1
  123. package/src/plan/plan-store.js +40 -3
  124. package/src/plan/spec-detector.js +61 -0
  125. package/src/plan/tests-synthesizer.js +116 -0
  126. package/src/planning-game/client.js +3 -14
  127. package/src/planning-game/decomposition.js +1 -1
  128. package/src/planning-game/pipeline-adapter.js +36 -5
  129. package/src/prompts/coder.js +172 -1
  130. package/src/prompts/hu-reviewer.js +0 -1
  131. package/src/prompts/planner.js +81 -4
  132. package/src/review/diff-generator.js +35 -22
  133. package/src/review/snapshot-diff.js +1 -1
  134. package/src/roles/coder-role.js +25 -3
  135. package/src/roles/planner-role.js +42 -1
  136. package/src/roles/repairer-role.js +181 -0
  137. package/src/roles/tester-role.js +70 -4
  138. package/src/session/mutators.js +304 -0
  139. package/src/session/runtime-overrides.js +59 -0
  140. package/src/{session-store.js → session/store.js} +8 -2
  141. package/src/skills/semantic-detector.js +10 -5
  142. package/src/skills/skill-detector.js +1 -1
  143. package/src/sonar/cloud-scanner.js +0 -1
  144. package/src/sonar/credentials.js +14 -6
  145. package/src/sonar/discovery.js +123 -0
  146. package/src/sonar/manager.js +18 -0
  147. package/src/sync/detect-drift.js +113 -0
  148. package/src/tracker-bootstrap.js +44 -0
  149. package/src/utils/agent-detect.js +15 -11
  150. package/src/utils/board-prompt-bridge.js +109 -0
  151. package/src/utils/cli-progress.js +161 -0
  152. package/src/utils/cwd-confirm.js +39 -0
  153. package/src/utils/derive-project-name-from-cwd.js +119 -0
  154. package/src/utils/garbage-collector.js +396 -0
  155. package/src/utils/git.js +16 -4
  156. package/src/utils/locale.js +1 -1
  157. package/src/utils/paths.js +21 -1
  158. package/src/utils/process.js +1 -1
  159. package/src/utils/prompt-project-name.js +35 -0
  160. package/src/utils/run-context.js +75 -0
  161. package/src/utils/run-log.js +12 -0
  162. package/src/utils/task-file.js +1 -1
  163. package/src/utils/telemetry.js +13 -1
  164. package/src/webperf/devtools-detect.js +1 -1
  165. package/templates/coder-rules.md +13 -0
  166. package/templates/roles/coder.md +34 -0
  167. package/templates/roles/repairer.md +94 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Canvas → flat task string adapter.
3
+ *
4
+ * Renders a validated Canvas object as a single markdown blob the
5
+ * existing planner can consume verbatim. Preserves every section
6
+ * with its original heading so downstream steps (planner prompt,
7
+ * coder prompt, plan JSON `task` field) see the same shape.
8
+ *
9
+ * The point: Canvas users get planner-time validation (catching
10
+ * broken acceptance tests before they're persisted) AND benefit from
11
+ * every existing planner / coder pipeline without those pipelines
12
+ * needing Canvas awareness. Norms + Safeguards explicitly bracketed
13
+ * so prompts that scan for them can find them with a regex.
14
+ */
15
+
16
+ /**
17
+ * @param {object} canvas — output of validateCanvas (.canvas)
18
+ * @returns {string} markdown task ready for the planner
19
+ */
20
+ export function canvasToTask(canvas) {
21
+ const lines = [`# ${canvas.title}`, ""];
22
+
23
+ // Requirements
24
+ lines.push("## Requirements", canvas.requirements.problem);
25
+ if (canvas.requirements.done && canvas.requirements.done.length > 0) {
26
+ lines.push("", "Definition of done:");
27
+ for (const d of canvas.requirements.done) lines.push(`- ${d}`);
28
+ }
29
+ lines.push("");
30
+
31
+ // Entities (optional)
32
+ if (canvas.entities && canvas.entities.length > 0) {
33
+ lines.push("## Entities");
34
+ for (const e of canvas.entities) {
35
+ const fields = e.fields && e.fields.length > 0 ? ` — fields: ${e.fields.join(", ")}` : "";
36
+ lines.push(`- ${e.name}${fields}${e.notes ? `. ${e.notes}` : ""}`);
37
+ }
38
+ lines.push("");
39
+ }
40
+
41
+ // Approach
42
+ lines.push("## Approach", canvas.approach, "");
43
+
44
+ // Structure (optional content)
45
+ lines.push("## Structure");
46
+ if (canvas.structure?.touches?.length > 0) lines.push(`- touches: ${canvas.structure.touches.join(", ")}`);
47
+ if (canvas.structure?.depends_on?.length > 0) lines.push(`- depends_on: ${canvas.structure.depends_on.join(", ")}`);
48
+ if (canvas.structure?.contracts?.length > 0) lines.push(`- contracts: ${canvas.structure.contracts.join(", ")}`);
49
+ lines.push("");
50
+
51
+ // Operations — each becomes one HU. Acceptance tests inline so the
52
+ // planner sees the contract from turn 1.
53
+ lines.push("## Operations");
54
+ canvas.operations.forEach((op, i) => {
55
+ lines.push(`${i + 1}. ${op.title}`);
56
+ if (op.file_hint) lines.push(` file: ${op.file_hint}`);
57
+ if (op.blocked_by && op.blocked_by.length > 0) lines.push(` depends on: ${op.blocked_by.join(", ")}`);
58
+ lines.push(" Acceptance:");
59
+ for (const t of op.acceptance) {
60
+ // Render shell as one-liner, gherkin as fenced block so newlines survive.
61
+ if (t.type === "gherkin") {
62
+ lines.push(" - gherkin:", " ```gherkin", ...t.content.split("\n").map((l) => ` ${l}`), " ```");
63
+ } else {
64
+ lines.push(` - shell: \`${t.content}\``);
65
+ }
66
+ }
67
+ });
68
+ lines.push("");
69
+
70
+ // Norms (optional) — bracketed so prompts can find them.
71
+ if (canvas.norms && canvas.norms.length > 0) {
72
+ lines.push("## Norms (per-task engineering standards — coder MUST follow)");
73
+ for (const n of canvas.norms) lines.push(`- ${n}`);
74
+ lines.push("");
75
+ }
76
+
77
+ // Safeguards (optional) — bracketed similarly.
78
+ if (canvas.safeguards && canvas.safeguards.length > 0) {
79
+ lines.push("## Safeguards (non-negotiable invariants — code MUST hold these)");
80
+ for (const s of canvas.safeguards) lines.push(`- ${s}`);
81
+ lines.push("");
82
+ }
83
+
84
+ return lines.join("\n");
85
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * REASONS Canvas validator.
3
+ *
4
+ * Schema validation + planner-time sanity checks for acceptance tests.
5
+ * The schema is enforced via Valibot in canvas/schema.js. This module
6
+ * adds the bug-class fix: parse-check every shell acceptance test
7
+ * BEFORE it gets persisted into a plan and BEFORE any LLM is called.
8
+ *
9
+ * Catches the 2026-04-29 incident class:
10
+ * `length as $n | .filesOver300LOC == $n` → exits non-zero with a
11
+ * descriptive error pointing at the offending operation + test
12
+ * index, BEFORE the user has paid for any agent run.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { safeParseCanvas } from "./schema.js";
17
+
18
+ /**
19
+ * Run a single shell acceptance test through `bash -n` (parse-only,
20
+ * doesn't execute) to catch shell-level syntax errors. Then, if the
21
+ * test invokes `jq`, also probe the jq query through `jq -n <query>`
22
+ * with empty input — jq errors there iff the query has bad syntax.
23
+ *
24
+ * Trade-off accepted: bash -n won't catch every command-substitution
25
+ * pitfall (it parses but doesn't expand), and jq -n with empty input
26
+ * may pass queries that fail at runtime against real input. The point
27
+ * is to catch the OBVIOUS bugs cheaply, not to be airtight.
28
+ *
29
+ * @param {string} cmd
30
+ * @returns {{ ok: true } | { ok: false, kind: string, error: string }}
31
+ */
32
+ export function validateShellTest(cmd) {
33
+ if (typeof cmd !== "string" || cmd.trim().length === 0) {
34
+ return { ok: false, kind: "empty", error: "shell test is empty" };
35
+ }
36
+
37
+ // Layer 1: bash -n parse-check on the whole command.
38
+ const bash = spawnSync("bash", ["-n", "-c", cmd], { encoding: "utf8", timeout: 5000 });
39
+ if (bash.status !== 0) {
40
+ const err = (bash.stderr || "").trim() || "bash -n rejected the command";
41
+ return { ok: false, kind: "shell-syntax", error: err };
42
+ }
43
+
44
+ // Layer 2: every `jq '...'` invocation gets its query parse-checked.
45
+ // Permissive regex: catches `jq -e 'X'`, `jq "X"`, `| jq 'X'`. False
46
+ // negatives are fine — better to skip a real jq than misinterpret a
47
+ // shell-quoting edge case.
48
+ //
49
+ // We distinguish COMPILE errors (the bug we want to catch — invalid
50
+ // syntax, e.g. `length as $n | .field` over an array) from RUNTIME
51
+ // errors (the query is well-formed but fails on null input from
52
+ // `-n`, like `.foo[]` on null). Only compile errors fail validation;
53
+ // runtime errors against synthetic input are expected because real
54
+ // input isn't available at plan-time.
55
+ const jqRe = /\bjq\b\s+(?:-[a-zA-Z]+\s+)*(?:'([^']+)'|"([^"]+)")/g;
56
+ for (const m of cmd.matchAll(jqRe)) {
57
+ const query = m[1] || m[2];
58
+ if (!query) continue;
59
+
60
+ // Static pattern detector for the 2026-04-29 as-binding misuse:
61
+ // `[expr] | length as $X | .field` and similar. The `as $X`
62
+ // binds the value to a name but does NOT change the value
63
+ // flowing through the pipe. The next `.field` is therefore
64
+ // applied to the array tail (or whatever the previous stage
65
+ // returned), not to the original root. Always wrong.
66
+ //
67
+ // Detection: a pipeline whose tail is `length as $X | .X` (or
68
+ // any `<terminal-operator> as $X | .field`) is the smell.
69
+ const asBindingMisuse = /\b(?:length|keys|values|min|max|add|first|last)\s+as\s+\$[A-Za-z_][\w]*\s*\|\s*\.[A-Za-z_]/;
70
+ if (asBindingMisuse.test(query)) {
71
+ return {
72
+ ok: false,
73
+ kind: "jq-syntax",
74
+ error: "Likely as-binding misuse: `length as $n | .field` does not change the value flowing through; `.field` is applied to the previous stage's output, not the root. Use `. as $r | <transform> | $r.field` instead.",
75
+ };
76
+ }
77
+
78
+ const jq = spawnSync("jq", ["-n", query], { encoding: "utf8", timeout: 5000 });
79
+ if (jq.status !== 0) {
80
+ const err = (jq.stderr || "").trim() || "jq rejected the query";
81
+ // jq distinguishes compile errors with these tokens. Anything
82
+ // else (e.g. "Cannot index null with string") is a runtime error
83
+ // against the empty input, NOT a syntax bug.
84
+ const isCompileError = /\b(compile error|syntax error|unexpected|jq: error \(at <stdin>:\d+\): ?$)/i.test(err);
85
+ if (isCompileError) {
86
+ return { ok: false, kind: "jq-syntax", error: err };
87
+ }
88
+ // Special case: `length as $n | .field` returns "Cannot index
89
+ // array/number/string with string" on -n input because the as-
90
+ // binding misuse means `.field` is applied to the wrong thing.
91
+ // This is exactly the 2026-04-29 bug class. Treat it as syntax-
92
+ // adjacent: the query is "compilable" but semantically broken
93
+ // by misuse of as-binding.
94
+ if (/Cannot index (array|number|string) with string/i.test(err)) {
95
+ return { ok: false, kind: "jq-syntax", error: err };
96
+ }
97
+ // Otherwise let it pass — runtime error against synthetic input
98
+ // is fine. jq parsed the query successfully.
99
+ }
100
+ }
101
+
102
+ return { ok: true };
103
+ }
104
+
105
+ /**
106
+ * Validate every operation's acceptance tests in a parsed Canvas.
107
+ * Returns an array of issues; empty array means everything passed.
108
+ *
109
+ * @param {object} canvas
110
+ * @returns {Array<{ operationIndex: number, operationTitle: string, testIndex: number, type: string, content: string, kind: string, error: string }>}
111
+ */
112
+ export function validateAcceptanceTests(canvas) {
113
+ const issues = [];
114
+ if (!canvas || !Array.isArray(canvas.operations)) return issues;
115
+ canvas.operations.forEach((op, opIdx) => {
116
+ (op.acceptance || []).forEach((t, tIdx) => {
117
+ if (t.type === "shell") {
118
+ const r = validateShellTest(t.content);
119
+ if (!r.ok) {
120
+ issues.push({
121
+ operationIndex: opIdx,
122
+ operationTitle: op.title,
123
+ testIndex: tIdx,
124
+ type: t.type,
125
+ content: t.content,
126
+ kind: r.kind,
127
+ error: r.error,
128
+ });
129
+ }
130
+ } else if (t.type === "gherkin") {
131
+ // Minimal gherkin sanity: must contain at least one Given AND
132
+ // one Then. When/And/But are optional. This catches "blank
133
+ // gherkin" without trying to parse the language.
134
+ if (!/\bGiven\b/i.test(t.content) || !/\bThen\b/i.test(t.content)) {
135
+ issues.push({
136
+ operationIndex: opIdx,
137
+ operationTitle: op.title,
138
+ testIndex: tIdx,
139
+ type: t.type,
140
+ content: t.content,
141
+ kind: "gherkin-incomplete",
142
+ error: "Gherkin scenario must contain at least one Given and one Then.",
143
+ });
144
+ }
145
+ }
146
+ });
147
+ });
148
+ return issues;
149
+ }
150
+
151
+ /**
152
+ * Top-level validator. Runs schema + acceptance-test parse-checks.
153
+ *
154
+ * @param {unknown} canvas
155
+ * @returns {{ valid: true, canvas: object } | { valid: false, schemaIssues?: object[], testIssues?: object[] }}
156
+ */
157
+ export function validateCanvas(canvas) {
158
+ const schema = safeParseCanvas(canvas);
159
+ if (!schema.success) return { valid: false, schemaIssues: schema.issues };
160
+ const testIssues = validateAcceptanceTests(schema.output);
161
+ if (testIssues.length > 0) return { valid: false, testIssues };
162
+ return { valid: true, canvas: schema.output };
163
+ }
@@ -88,7 +88,7 @@ export function createSerenaCheck() {
88
88
  applies: (config) => config.serena?.enabled === true,
89
89
  describe: "Install Serena via: uvx --from git+https://github.com/oraios/serena serena",
90
90
  async detect() {
91
- let ok = false;
91
+ let ok;
92
92
  try {
93
93
  const res = await runCommand("serena", ["--version"]);
94
94
  ok = res.exitCode === 0;
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { isPortAvailable, findAvailablePort } from "../utils/port-check.js";
14
14
  import { getPortOccupant } from "../utils/port-occupant.js";
15
+ import { discoverRunningSonar } from "../sonar/discovery.js";
16
+ import { isSonarReachable } from "../sonar/manager.js";
15
17
  import { STRATEGY } from "./types.js";
16
18
 
17
19
  const DEFAULT_SONAR_PORT = 9000;
@@ -22,47 +24,110 @@ function extractPortFromHost(host, fallback) {
22
24
  return m ? Number(m[1]) : fallback;
23
25
  }
24
26
 
25
- /**
26
- * Whether the occupant looks like the Karajan-managed Sonar container.
27
- * Docker Desktop's proxy is typically `com.docker.backend` / `com.docker.vmnetd`
28
- * on macOS and `docker-proxy` on Linux. We treat those as OK.
29
- */
30
- function isKarajanSonarOccupant(occupant) {
31
- if (!occupant?.command) return false;
32
- const cmd = occupant.command.toLowerCase();
33
- return cmd.includes("docker") || cmd.includes("sonar");
34
- }
35
-
36
27
  export function createSonarPortCheck() {
37
28
  return {
38
29
  name: "port:sonar",
39
30
  label: "SonarQube port",
40
- strategy: STRATEGY.MANUAL,
41
- applies: (config) => config?.sonarqube?.enabled !== false,
31
+ // AUTO since v2.7.5 — Karajan now resolves SonarQube discovery on
32
+ // its own. If a Sonar container is running anywhere (any name, any
33
+ // image tag), point Karajan at it. If port 9000 is occupied by
34
+ // someone non-Sonar, rebind to the next free port. Asking the user
35
+ // to "stop the unknown process" was busywork.
36
+ strategy: STRATEGY.AUTO,
37
+ applies: (config) => {
38
+ if (config?.sonarqube?.external === true) {
39
+ return { applies: false, reason: "sonarqube.external is true — port managed outside Karajan" };
40
+ }
41
+ return true;
42
+ },
43
+ describe: "Discover or rebind SonarQube port (no user action needed)",
42
44
  async detect({ config }) {
43
- const port = extractPortFromHost(config?.sonarqube?.host, DEFAULT_SONAR_PORT);
44
- const free = await isPortAvailable(port);
45
+ const configuredPort = extractPortFromHost(config?.sonarqube?.host, DEFAULT_SONAR_PORT);
46
+
47
+ // Step 1 — is there ANY running SonarQube container we can reuse?
48
+ // Container name doesn't matter; we just need a published host port
49
+ // that responds to /api/system/status. This avoids "port 9000 is
50
+ // taken by an unknown process" errors when the unknown process is
51
+ // actually a perfectly good SonarQube from another project.
52
+ const discovered = await discoverRunningSonar();
53
+ if (discovered.found && discovered.reachable) {
54
+ // If the discovered host matches what's already configured,
55
+ // there's nothing to fix — happy path.
56
+ if (discovered.port === configuredPort) {
57
+ return {
58
+ ok: true,
59
+ severity: "info",
60
+ detail: `Reusing running SonarQube container "${discovered.containerName}" on port ${discovered.port}`,
61
+ };
62
+ }
63
+ // Otherwise we want to rewrite the host config at runtime.
64
+ return {
65
+ ok: false,
66
+ severity: "warn",
67
+ detail: `Found running SonarQube container "${discovered.containerName}" on port ${discovered.port} (config points at ${configuredPort})`,
68
+ extra: { remap: { host: discovered.host, port: discovered.port, container: discovered.containerName } },
69
+ };
70
+ }
71
+
72
+ // Step 2 — no running container. Is the configured port even free?
73
+ const free = await isPortAvailable(configuredPort);
45
74
  if (free) {
46
- return { ok: true, severity: "info", detail: `Port ${port} is free (Sonar container not yet running)` };
75
+ return {
76
+ ok: true,
77
+ severity: "info",
78
+ detail: `Port ${configuredPort} is free (Sonar container will be started on demand)`,
79
+ };
47
80
  }
48
- // Busy — is it our container?
49
- const occupant = await getPortOccupant(port);
50
- if (occupant && isKarajanSonarOccupant(occupant)) {
81
+
82
+ // Step 3 — port is busy. Maybe the occupant IS Sonar but not
83
+ // discovered by `docker ps` (rare: bare-metal Sonar, host-network
84
+ // container, etc.). HTTP-probe to find out.
85
+ const probeHost = `http://localhost:${configuredPort}`;
86
+ if (await isSonarReachable(probeHost)) {
51
87
  return {
52
88
  ok: true,
53
89
  severity: "info",
54
- detail: `Port ${port} held by Karajan-managed Sonar (${occupant.command} PID ${occupant.pid})`,
90
+ detail: `Port ${configuredPort} held by a Sonar instance (HTTP /api/system/status responds) — reusing it`,
55
91
  };
56
92
  }
57
- const who = occupant
58
- ? `${occupant.command || "unknown"} (PID ${occupant.pid ?? "?"})`
59
- : "unknown process";
93
+
94
+ // Step 4 — really not Sonar. Find an alternate free port and tell
95
+ // the runtime to use it. The compose template's published port is
96
+ // hardcoded at 9000 so we can't auto-rebind the container itself,
97
+ // but we CAN warn loudly with a clear, fixable message.
98
+ const occupant = await getPortOccupant(configuredPort);
99
+ const alt = await findAvailablePort(configuredPort + 1, 20);
100
+ if (alt) {
101
+ return {
102
+ ok: false,
103
+ severity: "warn",
104
+ detail: `Port ${configuredPort} occupied by ${occupant?.command || "unknown"} (PID ${occupant?.pid ?? "?"}). Will rebind Karajan-managed Sonar to ${alt}.`,
105
+ extra: { remap: { host: `http://localhost:${alt}`, port: alt, fromPort: configuredPort } },
106
+ };
107
+ }
108
+ // Last-resort failure (no free port in the next 20).
60
109
  return {
61
110
  ok: false,
62
111
  severity: "fail",
63
- detail: `Port ${port} occupied by ${who}`,
64
- fix: `Stop the process holding port ${port} or change sonarqube.host in kj.config.yml.`,
65
- extra: { port, occupant },
112
+ detail: `Port ${configuredPort} occupied by ${occupant?.command || "unknown"} (PID ${occupant?.pid ?? "?"}) and no free port in ${configuredPort + 1}..${configuredPort + 20}`,
113
+ fix: "Stop the conflicting process or set sonarqube.host in kj.config.yml.",
114
+ };
115
+ },
116
+ async remediate({ extra }) {
117
+ if (!extra?.remap) {
118
+ return { fixed: false, detail: "No remap available" };
119
+ }
120
+ const { host, container, fromPort } = extra.remap;
121
+ const detail = container
122
+ ? `Pointed Karajan at running SonarQube ${container} (${host})`
123
+ : `Rebound Karajan-managed Sonar from port ${fromPort} to ${host}`;
124
+ return {
125
+ fixed: true,
126
+ detail,
127
+ // The runner merges this into the runtime config so every
128
+ // downstream Sonar caller (scanner, manager, MCP handler) sees
129
+ // the new host without the user touching kj.config.yml.
130
+ changes: { sonarqube: { host } },
66
131
  };
67
132
  },
68
133
  };
@@ -73,7 +138,12 @@ export function createHuBoardPortCheck() {
73
138
  name: "port:hu-board",
74
139
  label: "HU Board port",
75
140
  strategy: STRATEGY.AUTO,
76
- applies: (config) => config?.hu_board?.enabled !== false,
141
+ applies: (config) => {
142
+ if (config?.hu_board?.enabled === false) {
143
+ return { applies: false, reason: "hu_board.enabled is false in kj.config.yml — HU Board dashboard skipped" };
144
+ }
145
+ return true;
146
+ },
77
147
  describe: "Rebind HU Board to the next free port",
78
148
  async detect({ config }) {
79
149
  const port = config?.hu_board?.port || DEFAULT_HU_BOARD_PORT;
@@ -55,19 +55,35 @@ export async function runChecks(checks, ctx, options = {}) {
55
55
  const overrides = {};
56
56
 
57
57
  // Build applicable entries (skipped checks noted up front).
58
+ // A check.applies(config) may return:
59
+ // - true / false — boolean form (back-compat).
60
+ // - an object { applies: boolean, reason?: string } — when false, `reason`
61
+ // is shown verbatim so users see WHY (e.g. "sonarqube.enabled is false
62
+ // in kj.config.yml") instead of the opaque "Not applicable" message
63
+ // that confused users debugging their config.
58
64
  for (const check of checks) {
59
- if (typeof check.applies === "function" && !check.applies(config)) {
60
- entries.push({
61
- check,
62
- status: STATUS.SKIPPED,
63
- detail: "Not applicable for current configuration",
64
- runMs: 0,
65
- detectResult: null,
66
- remediated: false,
67
- });
68
- } else {
65
+ if (typeof check.applies !== "function") {
66
+ entries.push({ check, status: null, detail: null, runMs: 0, detectResult: null, remediated: false });
67
+ continue;
68
+ }
69
+ const verdict = check.applies(config);
70
+ const isObj = verdict && typeof verdict === "object" && "applies" in verdict;
71
+ const applies = isObj ? Boolean(verdict.applies) : Boolean(verdict);
72
+ if (applies) {
69
73
  entries.push({ check, status: null, detail: null, runMs: 0, detectResult: null, remediated: false });
74
+ continue;
70
75
  }
76
+ const reason = (isObj && typeof verdict.reason === "string" && verdict.reason.trim())
77
+ ? verdict.reason.trim()
78
+ : "Not applicable for current configuration";
79
+ entries.push({
80
+ check,
81
+ status: STATUS.SKIPPED,
82
+ detail: reason,
83
+ runMs: 0,
84
+ detectResult: null,
85
+ remediated: false,
86
+ });
71
87
  }
72
88
 
73
89
  // Phase 1: detect in parallel with timeout.
@@ -36,7 +36,7 @@ export function createSonarStatusCheck() {
36
36
  if (!sonarEnabled(config)) {
37
37
  return { ok: true, severity: "info", detail: "Disabled in config" };
38
38
  }
39
- let reachable = false;
39
+ let reachable;
40
40
  try {
41
41
  reachable = await isSonarReachable(host);
42
42
  } catch {
@@ -32,7 +32,7 @@ export function createGitRepoCheck() {
32
32
  label: "Git repository",
33
33
  strategy: STRATEGY.MANUAL,
34
34
  async detect() {
35
- let gitOk = false;
35
+ let gitOk;
36
36
  try {
37
37
  gitOk = await ensureGitRepo();
38
38
  } catch {
@@ -1,41 +1,83 @@
1
1
  /**
2
- * API token availability checks.
2
+ * Provider CLI availability checks (preflight).
3
3
  *
4
- * Provider tokens (ANTHROPIC_API_KEY / OPENAI_API_KEY / ...) are LAZY:
5
- * we only check them for providers that are actually used by at least one
6
- * active role in the current config.
4
+ * IMPORTANT — Karajan does NOT call provider APIs directly. There is no
5
+ * `@anthropic-ai/sdk`, no `openai`, no `@google/generative-ai` in
6
+ * package.json. Every active role spawns its agent CLI as a subprocess
7
+ * (`claude`, `codex`, `gemini`, `opencode`, `aider`). The CLI handles its
8
+ * own auth, typically via OAuth + on-disk token (Claude Code, Codex CLI,
9
+ * Gemini CLI). The user does NOT need to set ANTHROPIC_API_KEY etc. for
10
+ * Karajan to work.
7
11
  *
8
- * Strategy:
9
- * - Required providers with no key → FAIL, strategy manual (we can't
10
- * fetch an API key for the user). Fix hint includes the how-to URL.
11
- * - Optional providers (opencode, aider) without key → WARN.
12
- * - GH_TOKEN is checked when git.auto_pr=true, strategy prompt (we can
13
- * launch `gh auth login` with user approval).
12
+ * Pre-v2.7.4 this module checked for ANTHROPIC_API_KEY / OPENAI_API_KEY /
13
+ * GEMINI_API_KEY env vars and FAILed preflight when missing — a false
14
+ * positive that blocked every user running Karajan as an MCP child of
15
+ * Claude Code (where `apiKeySource: "none"` is the norm). Replaced with a
16
+ * CLI-availability check that mirrors what the orchestrator actually does.
17
+ *
18
+ * GH_TOKEN is still checked when `git.auto_pr=true`, because that flow uses
19
+ * `git push` directly with the env var.
14
20
  */
15
21
 
16
22
  import { checkBinary } from "../utils/agent-detect.js";
17
- import { getRequiredProviderEnvs, hasEnvVar } from "../utils/provider-env.js";
23
+ import { getRequiredProviderEnvs, normalizeProvider } from "../utils/provider-env.js";
18
24
  import { STRATEGY } from "./types.js";
19
25
 
20
26
  /**
21
- * A single provider env check. One is created per active provider.
27
+ * Map canonical provider name → the CLI binary Karajan actually spawns.
28
+ * Keep in sync with src/agents/index.js.
22
29
  */
23
- function createProviderTokenCheck(entry) {
30
+ const PROVIDER_TO_CLI = Object.freeze({
31
+ anthropic: "claude",
32
+ openai: "codex",
33
+ google: "gemini",
34
+ opencode: "opencode",
35
+ aider: "aider",
36
+ });
37
+
38
+ /**
39
+ * Build a CLI-availability check for one provider.
40
+ *
41
+ * The check runs `<cli> --version`. If the binary is on PATH and exits 0,
42
+ * preflight passes — Karajan can spawn it. If the CLI is missing, preflight
43
+ * fails with the install URL we already track in PROVIDER_ENV.
44
+ *
45
+ * Note: we do NOT verify that the CLI is *authenticated*. Doing so would
46
+ * require provider-specific introspection (`claude doctor`, `codex login
47
+ * status`, ...), each with its own quirks and exit codes. If the CLI is
48
+ * present but not logged in, the agent will fail at the first request with
49
+ * a clear error — and the orchestrator surfaces that error already.
50
+ */
51
+ function createProviderCliCheck(entry) {
52
+ const cli = PROVIDER_TO_CLI[entry.provider];
24
53
  return {
25
- name: `token:${entry.provider}`,
26
- label: `API token: ${entry.provider}`,
54
+ name: `cli:${entry.provider}`,
55
+ label: `${entry.provider} CLI (${cli})`,
27
56
  strategy: STRATEGY.MANUAL,
28
57
  async detect() {
29
- const ok = hasEnvVar(entry);
30
- if (ok) {
31
- return { ok: true, severity: "info", detail: `${entry.envVars[0]} is set` };
58
+ if (!cli) {
59
+ return {
60
+ ok: false,
61
+ severity: "warn",
62
+ detail: `Unknown CLI for provider ${entry.provider}`,
63
+ extra: { provider: entry.provider },
64
+ };
65
+ }
66
+ const result = await checkBinary(cli);
67
+ if (result.ok) {
68
+ return {
69
+ ok: true,
70
+ severity: "info",
71
+ detail: `${cli} on PATH (${result.version || "version unknown"})`,
72
+ extra: { provider: entry.provider, cli, version: result.version, path: result.path },
73
+ };
32
74
  }
33
75
  return {
34
76
  ok: false,
35
77
  severity: entry.optional ? "warn" : "fail",
36
- detail: `${entry.envVars.join(" / ")} not set`,
37
- fix: `Get an API key at ${entry.howToGetUrl} and export ${entry.envVars[0]}=...`,
38
- extra: { provider: entry.provider, envVars: entry.envVars, howToGetUrl: entry.howToGetUrl },
78
+ detail: `${cli} not found on PATH`,
79
+ fix: `Install the ${cli} CLI: see ${entry.howToGetUrl}`,
80
+ extra: { provider: entry.provider, cli, howToGetUrl: entry.howToGetUrl },
39
81
  };
40
82
  },
41
83
  };
@@ -43,6 +85,8 @@ function createProviderTokenCheck(entry) {
43
85
 
44
86
  /**
45
87
  * GH token check + auto-remediation via `gh auth login` when it's installed.
88
+ * Kept from the previous version: this one IS legitimate because Karajan
89
+ * uses `gh` (or raw `git push`) directly when auto_pr is on.
46
90
  */
47
91
  function createGhTokenCheck() {
48
92
  return {
@@ -82,15 +126,24 @@ function createGhTokenCheck() {
82
126
  }
83
127
 
84
128
  /**
85
- * Build the list of token checks for the active config. Each active provider
86
- * gets one, plus conditional GH token when auto_pr is on.
129
+ * Build the list of CLI/token checks for the active config.
130
+ * - One CLI availability check per active provider (claude / codex / gemini / ...).
131
+ * - One GH token check, conditional on git.auto_pr.
132
+ *
133
+ * Function name kept as `getTokenChecks` for backward compat with
134
+ * preflight-checks.js, even though the bulk of the checks no longer touch
135
+ * tokens at all. Tracked: rename to `getProviderChecks` in a follow-up
136
+ * once consumers are updated.
87
137
  *
88
138
  * @param {Object} config
89
139
  * @returns {import("./types.js").Check[]}
90
140
  */
91
141
  export function getTokenChecks(config) {
92
142
  const required = getRequiredProviderEnvs(config);
93
- const checks = required.map(createProviderTokenCheck);
143
+ const checks = required.map(createProviderCliCheck);
94
144
  checks.push(createGhTokenCheck());
95
145
  return checks;
96
146
  }
147
+
148
+ // Re-export for tests that need to introspect the mapping.
149
+ export { PROVIDER_TO_CLI, normalizeProvider };
@@ -0,0 +1,30 @@
1
+ import { applyRunOverrides, loadConfig, validateConfig } from "../config.js";
2
+ import { createLogger } from "../utils/logger.js";
3
+
4
+ /**
5
+ * Wraps a CLI action so it loads + validates config, builds the logger,
6
+ * and fires the anonymous cli_command telemetry event before invoking
7
+ * the real command body. Shared across every register-*.js module so
8
+ * each one doesn't need to re-import the config plumbing.
9
+ *
10
+ * @param {string} pkgVersion Current kj version (used for telemetry).
11
+ * @param {string} commandName The CLI command name being executed.
12
+ * @param {object} flags Flags parsed by Commander.
13
+ * @param {(ctx: { config: object, logger: object, flags: object }) => Promise<any>} fn
14
+ * Body of the command — receives the merged config + logger.
15
+ */
16
+ export async function withConfig(pkgVersion, commandName, flags, fn) {
17
+ const { config } = await loadConfig();
18
+ const merged = applyRunOverrides(config, flags || {});
19
+ validateConfig(merged, commandName);
20
+ const logger = createLogger(merged.output.log_level);
21
+
22
+ // Telemetry: anonymous cli_command event (non-blocking).
23
+ import("../utils/telemetry.js")
24
+ .then(({ sendTelemetryEvent }) =>
25
+ sendTelemetryEvent("cli_command", { command: commandName, version: pkgVersion }, merged)
26
+ )
27
+ .catch(() => {});
28
+
29
+ return fn({ config: merged, logger, flags });
30
+ }