karajan-code 3.7.1 → 3.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.7.1",
3
+ "version": "3.7.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",
@@ -29,3 +29,24 @@ export async function assertAgentsAvailable(agentNames = []) {
29
29
  }
30
30
  throw new Error(lines.join("\n"));
31
31
  }
32
+
33
+ /**
34
+ * Non-throwing counterpart to assertAgentsAvailable: probe each agent CLI in
35
+ * parallel and return the subset whose `--version` succeeds. Used by the
36
+ * audit fallback (KJC-BUG-0094) to skip uninstalled providers instead of
37
+ * aborting the whole command when any one is missing.
38
+ * @param {string[]} agentNames
39
+ * @returns {Promise<string[]>}
40
+ */
41
+ export async function filterAvailableAgents(agentNames = []) {
42
+ const unique = [...new Set(agentNames.filter(Boolean))];
43
+ const probes = await Promise.all(
44
+ unique.map(async (name) => {
45
+ const meta = getAgentMeta(name);
46
+ if (!meta?.bin) return null;
47
+ const res = await runCommand(resolveBin(meta.bin), ["--version"]);
48
+ return res.exitCode === 0 ? name : null;
49
+ })
50
+ );
51
+ return probes.filter(Boolean);
52
+ }
@@ -0,0 +1,66 @@
1
+ // Resilient provider/model fallback for the `kj audit` CLI (KJC-BUG-0094).
2
+ // The CLI audit calls AuditRole.executeWithDeterministic directly, bypassing
3
+ // the orchestrator's recovery paths, so a dead configured model (e.g. an
4
+ // inherited "claude-fable-5") killed the command. The LLM phase now tries:
5
+ // configured provider+model → same provider default model → remaining known
6
+ // providers. First success wins; the deterministic context is reused.
7
+ import { resolveRole } from "../config.js";
8
+ import { AuditRole } from "../roles/audit-role.js";
9
+
10
+ const KNOWN_PROVIDERS = ["claude", "codex", "gemini"];
11
+
12
+ /** Ordered, de-duplicated fallback candidates for the audit role. */
13
+ export function buildAuditFallbackCandidates(config) {
14
+ const effective = resolveRole(config, "audit");
15
+ const primary = effective.provider || "claude";
16
+ const candidates = [{ provider: primary, model: effective.model ?? null }];
17
+ if (effective.model) candidates.push({ provider: primary, model: null });
18
+ for (const p of KNOWN_PROVIDERS) {
19
+ if (p !== primary) candidates.push({ provider: p, model: null });
20
+ }
21
+ const seen = new Set();
22
+ return candidates.filter((c) => {
23
+ const key = `${c.provider}::${c.model ?? ""}`;
24
+ return seen.has(key) ? false : (seen.add(key), true);
25
+ });
26
+ }
27
+
28
+ // Pin the audit role to a provider/model. Sets BOTH the per-role override and
29
+ // `coder_options.model` (the audit role inherits its model from the coder
30
+ // bucket); a null model neutralizes both so the provider default is used.
31
+ export function withAuditProvider(config, provider, model) {
32
+ return {
33
+ ...config,
34
+ roles: { ...(config?.roles || {}), audit: { ...(config?.roles?.audit || {}), provider, model: model ?? undefined } },
35
+ coder_options: { ...(config?.coder_options || {}), model: model ?? undefined },
36
+ };
37
+ }
38
+
39
+ /** Run the audit LLM phase with automatic provider/model fallback. */
40
+ export async function runAuditWithFallback({
41
+ config, logger, roleInput, deterministicCtx, available, candidates, onFallback, createRole,
42
+ }) {
43
+ const makeRole = createRole || ((cfg) => new AuditRole({ config: cfg, logger }));
44
+ const all = candidates || buildAuditFallbackCandidates(config);
45
+ const usable = available ? all.filter((c) => available.includes(c.provider)) : all;
46
+ if (usable.length === 0) {
47
+ throw new Error("No available audit provider — install claude, codex, or gemini, or set roles.audit.provider");
48
+ }
49
+
50
+ let last = null;
51
+ let attempts = 0;
52
+ for (const cand of usable) {
53
+ attempts += 1;
54
+ if (attempts > 1) onFallback?.(cand, attempts);
55
+ const role = makeRole(withAuditProvider(config, cand.provider, cand.model));
56
+ const meta = { provider: cand.provider, model: cand.model ?? null };
57
+ try {
58
+ const result = await role.executeWithDeterministic(roleInput, deterministicCtx);
59
+ if (result?.ok) return { ...result, ...meta, attempts };
60
+ last = { ...result, ...meta };
61
+ } catch (err) {
62
+ last = { ok: false, result: { error: err.message, provider: cand.provider }, summary: `Audit failed: ${err.message}`, ...meta };
63
+ }
64
+ }
65
+ return { ...last, attempts, exhausted: true };
66
+ }
@@ -3,10 +3,11 @@ import fs from "node:fs/promises";
3
3
  import readline from "node:readline";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { assertAgentsAvailable } from "../agents/availability.js";
6
+ import { assertAgentsAvailable, filterAvailableAgents } from "../agents/availability.js";
7
7
  import { resolveRole } from "../config.js";
8
8
  import { AUDIT_DIMENSIONS } from "../prompts/audit.js";
9
9
  import { AuditRole } from "../roles/audit-role.js";
10
+ import { buildAuditFallbackCandidates, runAuditWithFallback } from "../audit/audit-fallback.js";
10
11
  import { withCliRunLog } from "../utils/cli-run-log.js";
11
12
  import { createCliProgressReporter } from "../utils/cli-progress.js";
12
13
  import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
@@ -260,7 +261,11 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
260
261
 
261
262
  return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
262
263
  const auditRoleConfig = resolveRole(config, "audit");
263
- await assertAgentsAvailable([auditRoleConfig.provider]);
264
+ // KJC-BUG-0094: proceed if any fallback candidate is installed; throw only when NONE are.
265
+ const fallbackCandidates = buildAuditFallbackCandidates(config);
266
+ const candidateProviders = [...new Set(fallbackCandidates.map((c) => c.provider))];
267
+ const available = await filterAvailableAgents(candidateProviders);
268
+ if (available.length === 0) await assertAgentsAvailable([auditRoleConfig.provider]);
264
269
  logger.info(`Audit (${auditRoleConfig.provider}) starting...`);
265
270
  runLog.logText(`[audit] provider=${auditRoleConfig.provider} dimensions=${dimensions || "all"} deterministicOnly=${deterministicOnly}`);
266
271
 
@@ -347,7 +352,18 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
347
352
  const progress = createCliProgressReporter({ role: "auditor" });
348
353
  let roleResult;
349
354
  try {
350
- roleResult = await role.executeWithDeterministic({ ...roleInput, onOutput: progress.onOutput }, deterministicCtx);
355
+ // KJC-BUG-0094: resilient fallback — if the configured provider/model
356
+ // is down (e.g. an inherited model like "claude-fable-5" is offline),
357
+ // degrade to the provider default, then to the next installed provider,
358
+ // reusing the deterministic context so no static analysis is re-run.
359
+ roleResult = await runAuditWithFallback({
360
+ config, logger,
361
+ roleInput: { ...roleInput, onOutput: progress.onOutput },
362
+ deterministicCtx,
363
+ available,
364
+ candidates: fallbackCandidates,
365
+ onFallback: (cand) => logger.warn(`Audit provider fallback → ${cand.provider}${cand.model ? ` (${cand.model})` : " (default model)"}`),
366
+ });
351
367
  progress.finish(roleResult.ok ? "done" : "failed");
352
368
  } catch (err) { progress.finish("failed"); throw err; }
353
369
 
@@ -77,15 +77,18 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
77
77
 
78
78
  /**
79
79
  * Build the board URL. When `projectSlug` is provided, the URL points at
80
- * the per-project view (`/p/<slug>`) so the caller's project shows up
81
- * pre-filtered instead of "All projects".
80
+ * the per-project view via the SPA hash route (`#board/<slug>`) so the
81
+ * caller's project shows up pre-filtered instead of "All projects". The
82
+ * frontend router is hash-based (see public/utils/init-listeners.js); a
83
+ * real `/p/<slug>` path is not a route and silently falls back to the
84
+ * global dashboard. KJC-BUG-0093.
82
85
  * @param {number} port
83
86
  * @param {string|null} [projectSlug]
84
87
  * @returns {string}
85
88
  */
86
- function buildBoardUrl(port, projectSlug) {
89
+ export function buildBoardUrl(port, projectSlug) {
87
90
  const base = `http://localhost:${port}`;
88
- return projectSlug ? `${base}/p/${projectSlug}` : base;
91
+ return projectSlug ? `${base}/#board/${projectSlug}` : base;
89
92
  }
90
93
 
91
94
  /**
@@ -513,8 +513,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
513
513
  const { projectSlug: slugFor } = await import("../../plan/plan-store.js");
514
514
  const boardPort = config?.hu_board?.port ?? 4000;
515
515
  // `projectSlug` scopes the board URL to this project's view
516
- // (`/p/<slug>`) instead of the global "All projects" dashboard. The
517
- // board UI hides multi-project chrome when it sees that prefix.
516
+ // (`#board/<slug>`) instead of the global "All projects" dashboard.
517
+ // The hash route opens the board pre-filtered to that project.
518
518
  const boardResult = await startBoard(boardPort, { projectSlug: slugFor(projectDir) });
519
519
  const status = boardResult.alreadyRunning ? "already running" : "started";
520
520
  console.log(renderBoardBanner({ url: boardResult.url, status, projectName: plan.name }));
@@ -347,8 +347,8 @@ export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
347
347
  try {
348
348
  const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
349
349
  const boardPort = config.hu_board.port || 4000;
350
- // Scope the board URL to the current run's project (`/p/<slug>`) so
351
- // the user lands on a filtered view, not the global dashboard.
350
+ // Scope the board URL to the current run's project (`#board/<slug>`)
351
+ // so the user lands on a filtered view, not the global dashboard.
352
352
  const slug = config.projectDir ? slugFor(config.projectDir) : null;
353
353
  const boardResult = await startBoard(boardPort, { projectSlug: slug });
354
354
  const status = boardResult.alreadyRunning ? "already running" : "started";