karajan-code 4.54.0 → 4.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.54.0",
3
+ "version": "4.55.0",
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",
@@ -366,6 +366,9 @@ export class ClaudeAgent extends BaseAgent {
366
366
  async _runTaskExec(task, model, _role) {
367
367
  const { args, input } = buildPromptArgs(task);
368
368
  if (model) args.push("--model", model);
369
+ // KJC-TSK-1070 (MR-D2): the effort the HU's route gives the coder.
370
+ const effort = this.config?.roles?.[_role]?.effort;
371
+ if (effort) args.push("--effort", effort);
369
372
  // PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
370
373
  // si su harness corre hooks (claude -p), el tier A evalúa la policy con
371
374
  // el rol que ACTÚA. El rol del ORQUESTADOR es autoritativo: task.env no
@@ -9,7 +9,7 @@ import { promptProjectName } from "../../utils/prompt-project-name.js";
9
9
  import { deriveProjectNameFromCwd } from "../../utils/derive-project-name-from-cwd.js";
10
10
  import { applyReviewerFeedback, applyFixerPatch, patchChanged } from "../../plan/plan-fixer.js";
11
11
  import { runStructuralPass } from "../../plan/plan-structural-pass.js";
12
- import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
12
+ import { planHuModels } from "../../hu/model-router.js";
13
13
  import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
14
14
  import { buildStandbyState } from "../../brain/standby-store.js";
15
15
  import { printResumeHint } from "../../utils/display/resume-hint.js";
@@ -206,16 +206,15 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
206
206
  const symbolicId = typeof step === "object" && typeof step.id === "string" && step.id.trim()
207
207
  ? step.id.trim() : null;
208
208
  const taskType = classifyTaskType(desc);
209
- // KJC-TSK-0405 step 2: asigna coder_model + reviewer_model
210
- // automáticamente según complexity inferido del task_type. El
211
- // planner puede emitir `step.complexity` (trivial/simple/medium/complex
212
- // o score 1-5) para override más fino. Cross-provider reviewer
213
- // (claude↔codex) por defecto.
214
- const coderProvider = config?.roles?.coder?.provider || config?.coder || "claude";
215
- const complexity = (typeof step === "object" && step.complexity)
216
- ? step.complexity
217
- : complexityFromTaskType(taskType);
218
- const models = recommendModelsForHu({ complexity, coderProvider, config });
209
+ // KJC-TSK-1072 (MR-D4): coder_model comes from the HU's tier (MR-A), the
210
+ // criterion the run uses; the board can change it. Cross-provider reviewer.
211
+ const models = planHuModels({
212
+ title: desc, goal: desc,
213
+ acceptanceCriteria: tests.map((t) => (typeof t === "object" ? String(t.content ?? "") : String(t))),
214
+ tier: typeof step === "object" ? step.tier ?? null : null,
215
+ tierReason: typeof step === "object" ? step.tierReason ?? null : null,
216
+ config: config ?? {},
217
+ });
219
218
  const hu = addHu(plan, {
220
219
  title: desc.slice(0, 80),
221
220
  task_type: taskType,
@@ -232,6 +231,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
232
231
  // KJC-TSK-0661: provenance stamp (addHu builds its object explicitly,
233
232
  // so this goes on the returned reference, not in huData).
234
233
  hu.created_by = "planner";
234
+ // KJC-TSK-1072: the tier rides on the HU, so the run gives its coder the tier's effort.
235
+ hu.complexity = models.complexity;
235
236
  // KJC-TSK-1061: the classification travels to the HU; validatePlan judges it (KJC-TSK-0717).
236
237
  for (const key of ["change_class", "change_criterion", "hardens"]) {
237
238
  const value = typeof step === "object" ? step[key] : null;
@@ -134,6 +134,8 @@ const DEFAULTS = {
134
134
  },
135
135
  sonarqube: {
136
136
  enabled: true,
137
+ // KJC-BUG-0368 (#2216): sonar-scanner runs this many reviews at once per machine.
138
+ max_parallel_scans: 2,
137
139
  host: "http://localhost:9000",
138
140
  external: false,
139
141
  container_name: "karajan-sonarqube",
@@ -208,6 +208,8 @@ const Skills = v.optional(v.looseObject({
208
208
  * forzar Sonnet en todo el proyecto, por ejemplo.
209
209
  */
210
210
  const ModelRouting = v.optional(v.looseObject({
211
+ // KJC-TSK-0701 (MR-D): the coder of each HU runs on its tier's model; false turns it off.
212
+ enabled: v.optional(v.boolean("model_routing.enabled must be true or false")),
211
213
  by_provider: v.optional(v.record(
212
214
  v.string(),
213
215
  v.looseObject({
@@ -257,6 +259,10 @@ export const ConfigSchema = v.looseObject({
257
259
  every_commits: v.optional(PositiveInt),
258
260
  })),
259
261
  coder_rules: v.optional(v.string()),
262
+ // KJC-BUG-0368 (#2216): how many sonar-scanner reviews run at once per machine.
263
+ sonarqube: v.optional(v.looseObject({
264
+ max_parallel_scans: v.optional(PositiveInt),
265
+ })),
260
266
  base_branch: v.optional(v.string()),
261
267
  coder_options: CoderOptions,
262
268
  reviewer_options: ReviewerOptions,
@@ -145,10 +145,12 @@ export const stripInertQuotes = (cmd) => {
145
145
 
146
146
  // Options whose value is prose, never a path (gh, git, kj).
147
147
  // KJC-BUG-0306: --error and --command of kj report-issue, --scope and --tests of kj hu add.
148
- const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests"]);
148
+ // KJC-BUG-0366: --why of kj handoff add.
149
+ const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests", "--why"]);
149
150
  const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
150
151
  // KJC-BUG-0306: the title kj hu add and kj adr add take as their first argument, at the start of a segment.
151
- const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
152
+ // KJC-BUG-0366: and the command kj handoff add stores for the human.
153
+ const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr|handoff)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
152
154
 
153
155
  /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
154
156
  export const stripTextOptionValues = (cmd) => cmd
@@ -171,7 +173,7 @@ const isGitCommit = (words) => {
171
173
  return words[0] === "git" && words[i] === "commit";
172
174
  };
173
175
 
174
- const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
176
+ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
175
177
 
176
178
  /**
177
179
  * KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the
@@ -0,0 +1,66 @@
1
+ /**
2
+ * KJC-TSK-0701 (MR-D): the model and effort the coder of an HU runs on. Its
3
+ * tier (MR-A) picks them on the first iteration; after a failed one the coder
4
+ * climbs to the most capable model. A model the user chose always wins, the
5
+ * reviewer and Solomon are never routed, and model_routing.enabled: false turns
6
+ * it off; model_routing.by_provider picks other models per level.
7
+ */
8
+ import { getDefaultModelTiers, resolveModelForRole } from "../utils/model-selector.js";
9
+
10
+ const TIER_ROUTE = { trivial: { level: "trivial", effort: "low" }, standard: { level: "medium", effort: "medium" } };
11
+ const ESCALATED = { level: "complex", effort: "max" };
12
+ // Only claude's CLI takes --effort.
13
+ const EFFORT_PROVIDERS = new Set(["claude"]);
14
+
15
+ const userChoice = (config) => {
16
+ if (config.model_routing?.enabled === false) return "model_routing.enabled is false";
17
+ if (config.model_routing?.fixed?.coder) return `model_routing.fixed.coder is ${config.model_routing.fixed.coder}`;
18
+ if (config.roles?.coder?.model) return `the coder model was chosen (${config.roles.coder.model})`;
19
+ if (config.coder_options?.model) return `the coder model was chosen (${config.coder_options.model})`;
20
+ if (config.model_profile) return `the model profile ${config.model_profile} was chosen`;
21
+ return null;
22
+ };
23
+
24
+ const routed = ({ model, effort, escalated, why }) => ({ skip: false, model, effort, escalated, reason: `${why} -> ${model}${effort ? ` (effort ${effort})` : ""}` });
25
+
26
+ /**
27
+ * KJC-TSK-1070: huModel is the HU's coder_model (kj plan fills it from the
28
+ * tier, the user may change it on the board); it drives the first iteration.
29
+ * @param {{ tier?: string, huModel?: string|null, attempt: number, config: object }} args
30
+ * @returns {{ skip: true, reason: string } | { skip: false, model: string, effort: string|null, escalated: boolean, reason: string }}
31
+ */
32
+ export function routeCoder({ tier, huModel = null, attempt, config }) {
33
+ const chosen = userChoice(config);
34
+ if (chosen) return { skip: true, reason: chosen };
35
+ const provider = config.roles?.coder?.provider || config.coder;
36
+ const effortOf = (route) => (EFFORT_PROVIDERS.has(provider) ? route?.effort ?? null : null);
37
+ const escalated = attempt > 1;
38
+ if (!escalated && huModel) return routed({ model: huModel, effort: effortOf(TIER_ROUTE[tier]), escalated, why: "the HU's coder_model" });
39
+ const route = escalated ? ESCALATED : TIER_ROUTE[tier];
40
+ if (!route) return { skip: true, reason: tier === "deep" ? "deep HU: the usual model and effort" : `no tier to route by (${tier ?? "none"})` };
41
+ const tierMap = getDefaultModelTiers();
42
+ for (const [name, levels] of Object.entries(config.model_routing?.by_provider ?? {})) tierMap[name] = { ...tierMap[name], ...levels };
43
+ const model = resolveModelForRole({ role: "coder", provider, level: route.level, tierMap });
44
+ if (!model) return { skip: true, reason: `${provider} has no known model for the ${route.level} level` };
45
+ const why = escalated ? `iteration ${attempt} after a failed one: the most capable model` : `${tier} HU`;
46
+ return routed({ model, effort: effortOf(route), escalated, why });
47
+ }
48
+
49
+ /**
50
+ * KJC-TSK-1070: the route of an HU's coder for this attempt, on its lane
51
+ * config (coder_route, read by the coder stage); a skipped route clears it.
52
+ */
53
+ export function applyCoderRoute({ laneConfig, story, attempt, config, logger }) {
54
+ const route = routeCoder({ tier: story.complexity?.tier, huModel: story.coder_model || null, attempt, config });
55
+ laneConfig.coder_route = route.skip ? null : { model: route.model, effort: route.effort };
56
+ // KJC-TSK-1071 (MR-D3): the HU keeps its last decision, and whether it ever climbed.
57
+ story.coder_routing = {
58
+ tier: story.complexity?.tier ?? null,
59
+ model: route.skip ? null : route.model,
60
+ effort: route.skip ? null : route.effort,
61
+ escalated: Boolean(story.coder_routing?.escalated || route.escalated),
62
+ reason: route.reason,
63
+ };
64
+ logger.info(`HU ${story.id}: coder ${route.skip ? "not routed" : "routed"}: ${route.reason}`);
65
+ return route;
66
+ }
@@ -1,4 +1,6 @@
1
1
  import { getDefaultModelTiers } from "../utils/model-selector.js";
2
+ import { huComplexity } from "./complexity-tier.js";
3
+ import { routeCoder } from "./coder-routing.js";
2
4
 
3
5
  // KJC-TSK-0405: dado un HU con complexity score, recomienda coder_model y
4
6
  // reviewer_model. El reviewer por defecto usa el provider alternativo al
@@ -35,26 +37,6 @@ const CROSS_PROVIDER_REVIEWER = {
35
37
  aider: "claude",
36
38
  };
37
39
 
38
- // KJC-TSK-0405 step 2: heurística para inferir complexity desde
39
- // task_type cuando el planner no emite un score explícito. Permite
40
- // que el model-router asigne modelos razonables sin un triage explícito.
41
- const TASK_TYPE_COMPLEXITY = {
42
- sw: "medium",
43
- refactor: "medium",
44
- "add-tests": "simple",
45
- infra: "trivial",
46
- doc: "trivial",
47
- spike: "complex",
48
- research: "complex",
49
- audit: "complex",
50
- analysis: "medium",
51
- "no-code": "trivial",
52
- };
53
-
54
- export function complexityFromTaskType(taskType) {
55
- return TASK_TYPE_COMPLEXITY[taskType] || "medium";
56
- }
57
-
58
40
  export function complexityToLevel(complexity) {
59
41
  if (typeof complexity === "string" && LEVELS.includes(complexity)) return complexity;
60
42
  if (typeof complexity === "number" && SCORE_TO_LEVEL[complexity]) return SCORE_TO_LEVEL[complexity];
@@ -97,3 +79,19 @@ export function recommendModelsForHu({ complexity, coderProvider, config = {} })
97
79
  reviewer_provider,
98
80
  };
99
81
  }
82
+
83
+ const TIER_LEVEL = { trivial: "trivial", standard: "medium", deep: "complex" };
84
+
85
+ /**
86
+ * KJC-TSK-1072 (MR-D4): the models kj plan writes on an HU. coder_model comes
87
+ * from the HU's tier (MR-A) through routeCoder, the criterion the run uses;
88
+ * empty for a deep HU or a model the config already chose. The reviewer keeps
89
+ * its cross-provider recommendation. The tier travels as `complexity`.
90
+ */
91
+ export function planHuModels({ title = "", goal = "", acceptanceCriteria = [], tier = null, tierReason = null, config = {} }) {
92
+ const complexity = huComplexity({ title, goal, acceptanceCriteria, tier, tierReason });
93
+ const coderProvider = config.roles?.coder?.provider || config.coder || "claude";
94
+ const route = routeCoder({ tier: complexity.tier, attempt: 1, config });
95
+ const reviewer = recommendModelsForHu({ complexity: TIER_LEVEL[complexity.tier], coderProvider, config });
96
+ return { ...reviewer, coder_model: route.skip ? null : route.model, complexity };
97
+ }
@@ -4,7 +4,8 @@
4
4
  * fixed set of fields, so the measured ones are kept here, in the plan, where
5
5
  * kj report reads them. Unmeasured fields stay out rather than written as null.
6
6
  */
7
- export const ROUTING_FIELDS = ["cost_usd", "tokens_in", "tokens_out", "cached_tokens", "coder", "reviewer_rejections"];
7
+ // KJC-TSK-1071 (MR-D3): coder_routing is the coder's routing decision for the HU.
8
+ export const ROUTING_FIELDS = ["cost_usd", "tokens_in", "tokens_out", "cached_tokens", "coder", "reviewer_rejections", "coder_routing"];
8
9
 
9
10
  export function keepRoutingMetrics(plan, huId, outcome) {
10
11
  const hu = plan?.hus?.find((h) => h.id === huId);
@@ -210,6 +210,8 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
210
210
 
211
211
  ctx.eventBase.iteration = i;
212
212
  ctx.iteration = i;
213
+ // KJC-TSK-1070 (MR-D2): an HU lane picks its coder's route per iteration.
214
+ ctx.beforeIteration?.(i);
213
215
 
214
216
  let iterResult;
215
217
  try {
@@ -36,6 +36,7 @@ import { createHuSnapshot } from "../../git/hu-snapshot.js";
36
36
  import { generateDiff } from "../../review/diff-generator.js";
37
37
  import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
38
38
  import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
39
+ import { applyCoderRoute } from "../../hu/coder-routing.js";
39
40
 
40
41
  /**
41
42
  * @param {object} args
@@ -180,6 +181,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
180
181
 
181
182
  for (let attempt = 1; attempt <= laneConfig.max_iterations; attempt++) {
182
183
  logger.info(`HU ${story.id}: coder iteration ${attempt}/${laneConfig.max_iterations}`);
184
+ applyCoderRoute({ laneConfig, story, attempt, config: ctx.config, logger });
183
185
  emitProgress(emitter, makeEvent("iteration:start", { ...ctx.eventBase, stage: "iteration" }, {
184
186
  message: `Iteration ${attempt}/${laneConfig.max_iterations}`,
185
187
  detail: { iteration: attempt, maxIterations: laneConfig.max_iterations }
@@ -386,7 +388,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
386
388
  // Fallback: no acceptance_tests → standard pipeline (reviewer/tester).
387
389
  // The loop reads everything through ctx.* — handing it a lane view
388
390
  // keeps the shared ctx untouched (no more mutate-and-restore dance).
389
- const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask };
391
+ const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask,
392
+ beforeIteration: (attempt) => applyCoderRoute({ laneConfig, story, attempt, config: ctx.config, logger }) };
390
393
  const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
391
394
  if (result?.approved) {
392
395
  // KJC-BUG-0248: what the HU's push or PR said joins what the loop recorded.
@@ -156,7 +156,7 @@ function sumTokenField(entries, field) {
156
156
  * entries recorded during this HU. Cost D (KJC-TSK-0515).
157
157
  * @returns {object}
158
158
  */
159
- function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudgetEntries }) {
159
+ function buildHuOutcome({ story, iterResult, status, startedAt, huBudgetEntries }) {
160
160
  const r = iterResult || {};
161
161
  const git = r.git || {};
162
162
  // Iterations are tracked in the standard pipeline as
@@ -198,6 +198,8 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
198
198
  // KJC-TSK-0702 (MR-E): the baseline model routing is measured against.
199
199
  tokens_out: sumTokenField(huBudgetEntries, 'tokens_out'),
200
200
  ...routingBaseline(huBudgetEntries, status),
201
+ // KJC-TSK-1071 (MR-D3): the routing decision applyCoderRoute left on the HU.
202
+ coder_routing: story?.coder_routing ?? null,
201
203
  finishedAt: new Date().toISOString(),
202
204
  };
203
205
  }
@@ -79,6 +79,8 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
79
79
  // PAR-H (KJC-TSK-0631): lane env (KJ_LANE_SLOT / KJ_PORT_OFFSET)
80
80
  // reaches the coder subprocess so services it starts don't collide.
81
81
  env: config?.lane_env || null,
82
+ // KJC-TSK-1070 (MR-D2): the model and effort of this HU's coder.
83
+ route: config?.coder_route || null,
82
84
  onOutput: coderStall.onOutput,
83
85
  // Lets Brain Recovery persist a standby snapshot if the coder's
84
86
  // provider hits a quota cap mid-run (KJC hibernation wiring).
@@ -87,7 +89,8 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
87
89
  } finally {
88
90
  coderStall.stop();
89
91
  }
90
- trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
92
+ // KJC-TSK-1071 (MR-D3): the model the HU's route ran the coder on, so kj report measures the real one.
93
+ trackBudget({ role: "coder", provider: coderRole.provider, model: config?.coder_route?.model ?? coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
91
94
  applyQuotaSimulation(coderExecResult, { iteration, agent: coderRole.provider });
92
95
  // KJC-BUG-0285 (#1982): a commit the coder made on its own carried the error the
93
96
  // reviewer then rejected, and stayed on the branch. Undone softly: the changes
@@ -54,6 +54,8 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
54
54
  section(`You are a code reviewer in ${mode} mode.`, STABLE),
55
55
  section("CRITICAL SCOPE RULE: Only review changes that are part of the diff below. Do NOT flag issues in unchanged code, missing features planned for future tasks, or improvements outside the scope of this task. If the diff is correct for what the task asks, approve it — even if the broader codebase has other issues.", STABLE),
56
56
  section("Only block approval for issues IN THE DIFF that are bugs, security vulnerabilities, or clear violations of the review rules.", STABLE),
57
+ // KJC-BUG-0369 (#2196): a reviewer blocked GitHub's on.schedule timezone, which exists.
58
+ section("A claim that a tool, platform or API does not support something the diff uses is not a blocking issue on its own: your training data may predate the feature. Block only when the diff or the task context shows it fails; otherwise put it in non_blocking_suggestions.", STABLE),
57
59
  section("Return only one valid JSON object and nothing else.", STABLE),
58
60
  section("JSON schema:", STABLE),
59
61
  section('{"approved":boolean,"blocking_issues":[{"id":string,"severity":"critical|high|medium|low","category":"security|correctness|performance|style|other","file":string,"line":number,"description":string,"suggested_fix":string}],"non_blocking_suggestions":[string],"summary":string,"confidence":number}', STABLE),
@@ -23,11 +23,14 @@ export function modelTally(hus) {
23
23
  failure_rate: round(failed / rows.length, 4),
24
24
  };
25
25
  });
26
- return { models, unmeasured: (hus ?? []).length - measured.length };
26
+ // KJC-TSK-1071 (MR-D3): how many HUs routing moved off the usual model, and how many climbed.
27
+ const routed = (hus ?? []).filter((h) => h?.outcome?.coder_routing?.model);
28
+ const routing = { routed: routed.length, escalated: routed.filter((h) => h.outcome.coder_routing.escalated).length };
29
+ return { models, unmeasured: (hus ?? []).length - measured.length, routing };
27
30
  }
28
31
 
29
32
  /** The kj report block, or null when no HU was run at all. */
30
- export function formatModelTally({ models, unmeasured }) {
33
+ export function formatModelTally({ models, unmeasured, routing = null }) {
31
34
  if (models.length === 0 && unmeasured === 0) return null;
32
35
  const lines = ["Coder models (per HU, from the plans):"];
33
36
  for (const m of models) {
@@ -35,5 +38,6 @@ export function formatModelTally({ models, unmeasured }) {
35
38
  lines.push(` ${m.model.padEnd(24)} ${m.hus} HU · $${m.cost_usd.toFixed(4)} · ${m.tokens_in} in / ${m.tokens_out} out · ${iterations} iter · ${Math.round(m.failure_rate * 100)}% failed first time`);
36
39
  }
37
40
  if (unmeasured > 0) lines.push(` ${unmeasured} HU(s) before the measurement (baseline, unmeasured)`);
41
+ if (routing?.routed) lines.push(` Routing: ${routing.routed} HU routed, ${routing.escalated} climbed to the top model (model_routing.enabled: false turns it off)`);
38
42
  return lines.join("\n");
39
43
  }
@@ -14,6 +14,8 @@ import { dirname, join } from "node:path";
14
14
  import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
15
15
  import { getOpenIssues } from "../sonar/api.js";
16
16
  import { acquireToolLock } from "../utils/tool-governor.js";
17
+
18
+ const DEFAULT_SCAN_SLOTS = 2;
17
19
  import { sourceFilesOf } from "./tests-with-code.js";
18
20
 
19
21
  const BLOCKING_SEVERITIES = new Set(["BLOCKER", "CRITICAL"]);
@@ -84,9 +86,14 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
84
86
  if (config?.review_gate?.sonar === false) {
85
87
  return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
86
88
  }
89
+ // KJC-BUG-0368 (#2216): concurrent reviews on one machine share the scanner slots.
90
+ const slots = config?.sonarqube?.max_parallel_scans ?? DEFAULT_SCAN_SLOTS;
91
+ if (!Number.isInteger(slots) || slots < 1) {
92
+ return { available: false, reason: `sonarqube.max_parallel_scans must be a positive integer (got ${JSON.stringify(slots)})` };
93
+ }
87
94
  let lock = null;
88
95
  try {
89
- lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
96
+ lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000, slots });
90
97
  const groups = groupByScanRoot(stagedFiles, { exists });
91
98
  if (groups.size === 0) groups.set("", []);
92
99
  const indexed = new Set();
@@ -121,7 +121,7 @@ export class AgentRole extends BaseRole {
121
121
  async execute(input) {
122
122
  const extracted = this.extractInput(input);
123
123
  const provider = this.resolveProvider();
124
- const agent = this.createAgentInstance(provider);
124
+ const agent = this.createAgentInstance(provider, extracted);
125
125
 
126
126
  const promptResult = await this.buildPrompt(extracted);
127
127
  const { prompt, onOutput, stablePrompt, volatilePrompt } = typeof promptResult === "string"
@@ -16,7 +16,11 @@ export class CoderRole extends AgentRole {
16
16
  return this.config?.roles?.coder?.provider || this.config?.coder || "claude";
17
17
  }
18
18
 
19
- createAgentInstance(provider) {
19
+ createAgentInstance(provider, input = {}) {
20
+ // KJC-TSK-1070 (MR-D2): the HU's route rides on this call only; the shared
21
+ // role keeps its own config (lanes run in parallel).
22
+ const route = input?.route;
23
+ const config = route ? { ...this.config, roles: { ...this.config?.roles, coder: { ...this.config?.roles?.coder, model: route.model, effort: route.effort } } } : this.config;
20
24
  // KJC-BUG-0129 (issues #1301/#1303): running INSIDE an agent does not
21
25
  // make it "the host executor" — delegating sent the full instruction
22
26
  // prompt through elicitInput as a hanging board "question". A CLI
@@ -29,7 +33,7 @@ export class CoderRole extends AgentRole {
29
33
  }
30
34
  this.logger.info(`Running inside ${provider} — spawning it as a subprocess anyway (set roles.coder.host_delegation: true to delegate to the host)`);
31
35
  }
32
- return this._createAgent(provider, this.config, this.logger);
36
+ return this._createAgent(provider, config, this.logger);
33
37
  }
34
38
 
35
39
  extractInput(input) {
@@ -66,6 +70,8 @@ export class CoderRole extends AgentRole {
66
70
  projectDir: input?.projectDir || null,
67
71
  // PAR-H (KJC-TSK-0631): per-call subprocess env (lane slot vars).
68
72
  env: input?.env || null,
73
+ // KJC-TSK-1070: the model and effort this HU's coder runs on.
74
+ route: input?.route || null,
69
75
  onOutput: input?.onOutput || null
70
76
  };
71
77
  }
@@ -28,35 +28,50 @@ function isPidAlive(pid) {
28
28
  }
29
29
  }
30
30
 
31
- /** Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if the live holder outlasts `timeoutMs`. */
32
- export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500 } = {}) {
33
- fs.mkdirSync(locksDir, { recursive: true });
34
- const lockPath = path.join(locksDir, `${tool}.lock`);
35
- const deadline = Date.now() + timeoutMs;
31
+ /** One slot: {release} when taken, else the live holder; a dead or unreadable holder is stolen. */
32
+ function tryTakeSlot(lockPath) {
36
33
  for (;;) {
37
34
  try {
38
35
  // wx = atomic create-or-fail: the filesystem is the arbiter.
39
36
  const fd = fs.openSync(lockPath, "wx");
40
37
  fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
41
38
  fs.closeSync(fd);
42
- return { release: () => fs.rmSync(lockPath, { force: true }) };
39
+ return { lock: { release: () => fs.rmSync(lockPath, { force: true }) } };
43
40
  } catch (err) {
44
41
  if (err.code !== "EEXIST") throw err;
45
42
  let holder = null;
46
43
  try {
47
44
  holder = JSON.parse(fs.readFileSync(lockPath, "utf8"));
48
45
  } catch { /* unreadable → stale */ }
49
- if (!holder || !Number.isInteger(holder.pid) || !isPidAlive(holder.pid)) {
50
- fs.rmSync(lockPath, { force: true });
51
- continue;
52
- }
53
- if (Date.now() >= deadline) {
54
- const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}) — gave up waiting after ${timeoutMs}ms`);
55
- e.code = "ELOCKTIMEOUT";
56
- throw e;
57
- }
58
- await new Promise((r) => setTimeout(r, pollMs));
46
+ if (holder && Number.isInteger(holder.pid) && isPidAlive(holder.pid)) return { holder };
47
+ fs.rmSync(lockPath, { force: true });
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if every live holder
54
+ * outlasts `timeoutMs`. KJC-BUG-0368 (#2216): `slots` lets that many run at
55
+ * once (slot 0 keeps the single-lock path `<tool>.lock`).
56
+ */
57
+ export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500, slots = 1 } = {}) {
58
+ fs.mkdirSync(locksDir, { recursive: true });
59
+ const paths = Array.from({ length: Math.max(1, slots) }, (_, n) => path.join(locksDir, n === 0 ? `${tool}.lock` : `${tool}.${n}.lock`));
60
+ const deadline = Date.now() + timeoutMs;
61
+ for (;;) {
62
+ let holder = null;
63
+ for (const lockPath of paths) {
64
+ const slot = tryTakeSlot(lockPath);
65
+ if (slot.lock) return slot.lock;
66
+ holder ??= slot.holder;
67
+ }
68
+ if (Date.now() >= deadline) {
69
+ const busy = paths.length > 1 ? `, all ${paths.length} slots busy` : "";
70
+ const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}${busy}) — gave up waiting after ${timeoutMs}ms`);
71
+ e.code = "ELOCKTIMEOUT";
72
+ throw e;
59
73
  }
74
+ await new Promise((r) => setTimeout(r, pollMs));
60
75
  }
61
76
  }
62
77