karajan-code 4.53.1 → 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.53.1",
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
package/src/checks/qmd.js CHANGED
@@ -22,14 +22,15 @@ function createQmdCheck() {
22
22
  try {
23
23
  const res = await runCommand("qmd", ["--version"]);
24
24
  if (res.exitCode === 0) {
25
- return { ok: true, severity: "info", detail: `${res.stdout.trim()} — semantic wiki active` };
25
+ // KJC-TSK-1067: say what it is for, not only that it is there.
26
+ return { ok: true, severity: "info", detail: `${res.stdout.trim()} — answers about the project's docs, notes, ADRs and plans from the documents, with fewer tokens (kj env install wires its MCP)` };
26
27
  }
27
28
  } catch { /* not installed */ }
28
29
  const installCmd = getInstallCommand("qmd");
29
30
  return {
30
31
  ok: false,
31
32
  severity: "warn",
32
- detail: "Not found — semantic wiki via QMD available",
33
+ detail: "Not found — qmd answers questions about the project's docs, notes, ADRs and plans from the documents, with fewer tokens and a source",
33
34
  fix: withDocLink(`Install: ${installCmd}`, "qmd_install"),
34
35
  };
35
36
  },
@@ -197,12 +197,17 @@ export function registerMeta(program, { pkgVersion }) {
197
197
  // KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through kj.
198
198
  const handoffCmd = program.command("handoff").description("Handoffs: what a gate denied the agent, registered with the gate's why, for the human to read and run by id");
199
199
  const handoff = (action, flags) => withConfig(pkgVersion, `handoff-${action}`, flags, async ({ config }) => { process.exitCode = await handoffCommand({ action, config, flags }); });
200
- handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you)")
200
+ handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you), or with --why an action only the human can take")
201
201
  .argument("<command...>", "the denied command, as it was run").option("--cwd <dir>", "Directory the command runs in (default: the project)")
202
+ .option("--why <text>", "KJC-TSK-1066: what an action only the human can take is for (publish, seal); marked as the agent's")
202
203
  .action((parts, flags) => handoff("add", { ...flags, command: parts.join(" ") }));
203
204
  handoffCmd.command("list", { isDefault: true }).description("The handoffs registered in this project").action((flags) => handoff("list", flags));
204
205
  handoffCmd.command("show").description("A handoff: the exact command, its sha256 and why the gate denied it")
205
206
  .argument("<id>", "handoff id (8 hex)").option("--json", "Output the record as JSON").action((id, flags) => handoff("show", { ...flags, id }));
207
+ handoffCmd.command("done").description("The human did it: it leaves the pending handoffs and stays in the history")
208
+ .argument("<id>", "handoff id (8 hex)").action((id, flags) => handoff("done", { ...flags, id }));
209
+ handoffCmd.command("watch").description("Your task window: the pending handoffs, then each new one as it lands, with date and why (Ctrl+C to leave)")
210
+ .action((flags) => handoff("watch", flags));
206
211
 
207
212
  // KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
208
213
  identityCmd
@@ -2,18 +2,22 @@
2
2
  * kj handoff add|list|show (KJC-TSK-0968, HUM-E, ADR 0018): the agent registers
3
3
  * what a gate denied it, with the gate's why; the human reads it by id.
4
4
  */
5
- import { addHandoff, listHandoffs, readHandoff } from "../harden/handoff.js";
5
+ import { addHandoff, listHandoffs, markHandoffDone, readHandoff, watchHandoffs } from "../harden/handoff.js";
6
6
 
7
- const line = (h) => `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}`;
7
+ const line = (h) => {
8
+ const why = h.kind === "task" ? " (" + h.why + ")" : "";
9
+ return `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}${why}`;
10
+ };
8
11
 
9
12
  /** @returns {Promise<0|1>} */
10
13
  export async function handoffCommand({ action = "list", config, flags = {}, log = console.log }) {
11
14
  const projectDir = config?.projectDir || process.cwd();
12
15
  try {
13
16
  if (action === "add") {
14
- const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir });
17
+ const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir, why: flags.why ?? null });
15
18
  log(`encargo ${record.id} registrado: ${record.command}`);
16
- log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
19
+ if (record.kind === "task") log(`para qué (propuesto por el agente): ${record.why}`);
20
+ else log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
17
21
  log(`tu usuario lo revisa con kj handoff show ${record.id}; nadie copia y pega el comando.`);
18
22
  return 0;
19
23
  }
@@ -24,7 +28,21 @@ export async function handoffCommand({ action = "list", config, flags = {}, log
24
28
  log(`comando ${h.command}`);
25
29
  log(`sha256 ${h.sha256}`);
26
30
  log(`cwd ${h.cwd}`);
27
- log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
31
+ if (h.kind === "task") log(`para qué ${h.why}\n (propuesto por el agente, no lo negó ningún gate)`);
32
+ else log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
33
+ if (h.doneAt) log(`hecho ${h.doneAt}`);
34
+ return 0;
35
+ }
36
+ if (action === "done") {
37
+ const h = markHandoffDone({ projectDir, id: flags.id });
38
+ log(`encargo ${h.id} hecho: ${h.command}`);
39
+ return 0;
40
+ }
41
+ if (action === "watch") {
42
+ log("kj handoff watch: lo que te toca hacer a ti, según llega (Ctrl+C para salir)");
43
+ const ctl = new AbortController();
44
+ process.once("SIGINT", () => ctl.abort());
45
+ await watchHandoffs({ projectDir, log, signal: ctl.signal });
28
46
  return 0;
29
47
  }
30
48
  const all = listHandoffs({ projectDir });
@@ -11,6 +11,7 @@ import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
11
11
  import { detectHostAgent } from "../utils/agent-detect.js";
12
12
  import { readIdentity } from "../identity/store.js";
13
13
  import { effectiveGitEmail } from "../identity/detect.js";
14
+ import { roleSkills } from "../skills/role-skills.js";
14
15
 
15
16
  // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
16
17
  // board must never show work nobody can trace. Host agent env → that agent;
@@ -238,6 +239,13 @@ export async function huCommand({ config = null, action, args = [], flags = {},
238
239
  if (starting || takenOver || unclaimedRunning) Object.assign(hu, { claimed_by: who ?? null, claimed_at: who ? new Date().toISOString() : null });
239
240
  if (starting && !who) console.warn(`⚠ ${label} is not reserved: kj cannot tell who works this clone — declare it with kj identity set`);
240
241
  await savePlan(projectDir, plan);
242
+ // KJC-TSK-1065: the host session starts the card here; it learns which skills apply and where to read them.
243
+ if (starting && !flags.json) {
244
+ const text = [hu.title, hu.scope, ...(Array.isArray(hu.acceptance_criteria) ? hu.acceptance_criteria : [])].filter(Boolean).join(" ");
245
+ const { skills } = await (deps.skills ?? roleSkills)({ role: "coder", task: text, projectDir });
246
+ const where = (s) => (s.path ? ": read " + s.path : " (native)");
247
+ if (skills.length) console.log([`skills for ${label} (read them before you start):`, ...skills.map((s) => "- " + s.name + where(s))].join("\n"));
248
+ }
241
249
  return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
242
250
  }
243
251
 
@@ -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,
@@ -8,10 +8,28 @@
8
8
  * entries; invalid JSON is left untouched (preserve, never clobber).
9
9
  */
10
10
 
11
+ import { spawnSync } from "node:child_process";
11
12
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
12
14
  import { join } from "node:path";
13
15
 
14
- export function installProjectMcp({ projectDir = process.cwd(), logger = console } = {}) {
16
+ // KJC-TSK-1067: a server spec that launches `qmd mcp` (command or args, any path).
17
+ const launchesQmd = (s) => Boolean(s) && typeof s === "object"
18
+ && [s.command, ...(Array.isArray(s.args) ? s.args : [])].some((a) => typeof a === "string" && /(^|\/)qmd$/.test(a))
19
+ && Array.isArray(s.args) && s.args.includes("mcp");
20
+
21
+ const defaultQmd = {
22
+ installed: () => spawnSync("qmd", ["--version"], { stdio: "ignore" }).status === 0,
23
+ // The user's own Claude Code config: global servers and every project's.
24
+ wiredGlobally: () => {
25
+ try {
26
+ const c = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
27
+ return [c.mcpServers, ...Object.values(c.projects || {}).map((p) => p?.mcpServers)].some((m) => Object.values(m || {}).some(launchesQmd));
28
+ } catch { return false; }
29
+ },
30
+ };
31
+
32
+ export function installProjectMcp({ projectDir = process.cwd(), logger = console, qmd = defaultQmd } = {}) {
15
33
  const mcpPath = join(projectDir, ".mcp.json");
16
34
  let cfg = {};
17
35
  if (existsSync(mcpPath)) {
@@ -37,8 +55,17 @@ export function installProjectMcp({ projectDir = process.cwd(), logger = console
37
55
  const present = Object.values(cfg.mcpServers).some(launchesRagMcp);
38
56
  if (!present) {
39
57
  cfg.mcpServers["kj-rag"] = { command: "kj-rag-mcp", args: [] };
40
- writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
41
58
  logger.info?.("✓ kj_rag_query wired as a NATIVE tool (.mcp.json → kj-rag-mcp): querying the RAG is now the agent's cheapest path");
42
59
  }
43
- return { wired: true, added: !present };
60
+ // KJC-TSK-1067: qmd answers about the project's documents with fewer tokens and a source.
61
+ let qmdState = "absent";
62
+ if (Object.values(cfg.mcpServers).some(launchesQmd)) qmdState = "present";
63
+ else if (qmd.installed() && qmd.wiredGlobally()) qmdState = "global";
64
+ else if (qmd.installed()) {
65
+ cfg.mcpServers.qmd = { command: "qmd", args: ["mcp"] };
66
+ qmdState = "added";
67
+ logger.info?.("✓ qmd wired as a NATIVE tool (.mcp.json → qmd mcp): questions about docs, notes, ADRs and plans answer from the documents, with fewer tokens");
68
+ }
69
+ if (!present || qmdState === "added") writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
70
+ return { wired: true, added: !present, qmd: qmdState };
44
71
  }
@@ -61,7 +61,8 @@ Invariants (the git gates enforce these — they are not suggestions):
61
61
 
62
62
  - The project RAG answers before you assume: \`kj rag query\` — never guess
63
63
  what the codebase does. In MCP hosts the same index is the native
64
- \`kj_rag_query\` tool: reach for it before grepping by hand.
64
+ \`kj_rag_query\` tool: reach for it before grepping by hand. Questions about
65
+ the project's docs, notes, ADRs or plans: ask qmd (its MCP) first.
65
66
  - ${trackingLine(stateBackend, boardName)}
66
67
  - Tests prove behavior: the failing test exists first (TDD), and the suite
67
68
  is never left red. Green is not proof: with \`method_gates.mutation\`
@@ -103,6 +104,8 @@ Hit a kj bug or friction? Diagnose it and file it upstream with
103
104
  \`kj report-issue\` (sanitized; ask your user before \`--publish\`).
104
105
  kj announces a newer version? Tell your user what it brings and ask —
105
106
  never run \`kj update\` on your own.
107
+ Something only your user can do (publish, seal, run a script)? Register it:
108
+ \`kj handoff add <command> --why "<what for>"\`; they follow \`kj handoff watch\`.
106
109
  `;
107
110
 
108
111
  export function renderPlaybook({ stateBackend = "hu-board", boardName = null, config = null } = {}) {
@@ -30,19 +30,69 @@ export function findDenial({ projectDir, command }) {
30
30
  * exact bytes there is nothing to hand off: the agent does not write the why.
31
31
  * @returns {{id: string, command: string, sha256: string, why: string}}
32
32
  */
33
- export function addHandoff({ projectDir, command, cwd = projectDir }) {
33
+ export function addHandoff({ projectDir, command, cwd = projectDir, why = null }) {
34
34
  if (typeof command !== "string" || !command.trim()) throw new Error("kj handoff add: falta el comando exacto que el gate negó");
35
35
  const denial = findDenial({ projectDir, command });
36
- if (!denial) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018)");
36
+ // KJC-TSK-1066: an action only the human can take (publish, seal) is a TASK, with
37
+ // the agent's why marked as the agent's. A command a gate denied keeps the gate's.
38
+ const task = !denial && typeof why === "string" && why.trim();
39
+ if (!denial && !task) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018). Para una acción que solo puede hacer el humano, di para qué con --why");
37
40
  const createdAt = new Date().toISOString();
38
41
  const digest = sha256(command);
39
42
  const id = sha256(`${digest}:${createdAt}`).slice(0, 8);
40
- const record = { id, command, sha256: digest, cwd, createdAt, status: "pending", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
43
+ const base = { id, command, sha256: digest, cwd, createdAt, status: "pending" };
44
+ const record = task ? { ...base, kind: "task", why: why.trim(), denial: null } : { ...base, kind: "denial", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
45
+ writeRecord(projectDir, record);
46
+ return record;
47
+ }
48
+
49
+ const writeRecord = (projectDir, record) => {
41
50
  mkdirSync(join(projectDir, HANDOFFS_DIR), { recursive: true });
42
- writeFileSync(join(projectDir, HANDOFFS_DIR, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
51
+ writeFileSync(join(projectDir, HANDOFFS_DIR, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
52
+ };
53
+
54
+ /** KJC-TSK-1066: the human did it; it leaves the pending ones and stays in the history. */
55
+ export function markHandoffDone({ projectDir, id }) {
56
+ const record = { ...readHandoff({ projectDir, id }), status: "done", doneAt: new Date().toISOString() };
57
+ writeRecord(projectDir, record);
43
58
  return record;
44
59
  }
45
60
 
61
+ /** The handoff as the watch window shows it: local date and time, the command, and why. */
62
+ export function formatHandoff(h) {
63
+ const at = new Date(h.createdAt);
64
+ const pad = (n) => String(n).padStart(2, "0");
65
+ const when = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
66
+ const why = h.kind === "task" ? `${h.why} (propuesto por el agente)` : `lo negó el gate: ${String(h.denial?.why ?? "").split("\n")[0]}`;
67
+ return `[${when}] ${h.id} ${h.command}\n ${why}`;
68
+ }
69
+
70
+ /**
71
+ * KJC-TSK-1066: kj handoff watch. Shows the pending handoffs, then each new or
72
+ * changed one as it lands, until the signal aborts (Ctrl+C in the CLI).
73
+ */
74
+ export async function watchHandoffs({ projectDir, log, intervalMs = 2000, signal }) {
75
+ const seen = new Map();
76
+ const tick = () => {
77
+ for (const h of listHandoffs({ projectDir })) {
78
+ if (seen.get(h.id) === h.status) continue;
79
+ const first = !seen.has(h.id);
80
+ seen.set(h.id, h.status);
81
+ if (h.status === "pending") log(formatHandoff(h));
82
+ else if (!first) log(`[hecho] ${h.id} ${h.command}`);
83
+ }
84
+ };
85
+ tick();
86
+ // One abort promise for the whole watch, raced against each pause: no listener per tick.
87
+ const aborted = new Promise((resolve) => signal?.addEventListener("abort", resolve, { once: true }));
88
+ while (!signal?.aborted) {
89
+ let timer;
90
+ await Promise.race([aborted, new Promise((resolve) => { timer = setTimeout(resolve, intervalMs); })]);
91
+ clearTimeout(timer);
92
+ if (!signal?.aborted) tick();
93
+ }
94
+ }
95
+
46
96
  export function readHandoff({ projectDir, id }) {
47
97
  if (!/^[0-9a-f]{8}$/.test(id ?? "")) throw new Error(`kj handoff: "${id}" no es un identificador de encargo (8 hex)`);
48
98
  const path = join(projectDir, HANDOFFS_DIR, `${id}.json`);
@@ -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
@@ -216,7 +216,11 @@ process.stdin.on("end", () => {
216
216
  // KJC-BUG-0293: the host may hand the answer as {content:[parts]}, as the
217
217
  // bare array of parts, or as the text itself; all three are the same answer.
218
218
  const parts = Array.isArray(response?.content) ? response.content : Array.isArray(response) ? response : [{ text: typeof response === "string" ? response : "" }];
219
- recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
219
+ let found = parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || ""))));
220
+ // KJC-BUG-0365 (#2208): an answer cut short (whole code chunks) is no
221
+ // longer valid JSON, but every "source" it carries still names a hit.
222
+ if (found.length === 0) found = [...text.matchAll(/\\\\?"source\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/g)].map((m) => m[1]);
223
+ recordRag(input.text, found, parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
220
224
  process.exit(0);
221
225
  }
222
226
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
@@ -812,7 +816,9 @@ process.stdin.on("end", () => {
812
816
  // it need no verifying when nothing after it writes. One mutating segment
813
817
  // and the whole command is judged below, as before.
814
818
  const CD_LITERAL = /^[ \\t]*(cd|pushd)([ \\t]+[^ \\t;|&<>$\`(){}\\n\\r]*)?[ \\t]*$/;
815
- const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg) || CD_LITERAL.test(seg));
819
+ // KJC-BUG-0365: 2>&1 and 2>/dev/null only fold stderr, they write nothing.
820
+ const QUIET = /[ \\t]2>(?:&1|\\/dev\\/null)(?=[ \\t]|$)/g;
821
+ const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg.replace(QUIET, "")) || CD_LITERAL.test(seg.replace(QUIET, "")));
816
822
  // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
817
823
  if (!readsOnly(stripInertQuotes(cmd))) {
818
824
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { buildRtkInstructions } from "./rtk-snippet.js";
2
- import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
+ import { roleSkills, buildRoleSkillSection } from "../skills/role-skills.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
5
5
 
@@ -215,10 +215,10 @@ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, so
215
215
 
216
216
  const stackRule = buildStackSection({ stack, testFramework });
217
217
  const rtkSnippet = buildRtkInstructions({ rtkAvailable });
218
+ // KJC-TSK-1065: the project's skills and the catalog's for this task, up to 3.
218
219
  let skillSection = null;
219
220
  if (projectDir) {
220
- const skills = await loadAvailableSkills(projectDir);
221
- skillSection = buildSkillSection(skills, { provider });
221
+ skillSection = buildRoleSkillSection(await roleSkills({ role: "coder", task, projectDir }), { provider }) || null;
222
222
  }
223
223
 
224
224
  // Volatile sections keep the legacy relative order — order matters:
@@ -1,5 +1,5 @@
1
1
  import { buildRtkInstructions } from "./rtk-snippet.js";
2
- import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
+ import { roleSkills, buildRoleSkillSection } from "../skills/role-skills.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
5
5
  import { clipDiff } from "./diff-clip.js";
@@ -42,10 +42,10 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
42
42
 
43
43
  const langInstruction = getLanguageInstruction(language);
44
44
  const rtkSnippet = buildRtkInstructions({ rtkAvailable });
45
+ // KJC-TSK-1065: the reviewer gets the skills of its role and of the task, up to 3.
45
46
  let skillSection = null;
46
47
  if (projectDir) {
47
- const skills = await loadAvailableSkills(projectDir);
48
- skillSection = buildSkillSection(skills, { provider, role: "reviewer" });
48
+ skillSection = buildRoleSkillSection(await roleSkills({ role: "reviewer", task, projectDir }), { provider }) || null;
49
49
  }
50
50
 
51
51
  return buildPromptLayout([
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * KJC-TSK-1065: the skills kj picks for a role and a task reach the prompt.
3
+ * Before, the addyosmani catalog was resolved and only written to the journal.
4
+ * Order: the project's own skills (.agent/skills and friends), then the local
5
+ * catalog by TASK (a UI task gets frontend-ui-engineering) and by ROLE, up to
6
+ * MAX_SKILLS; the rest are named, never silently lost. The maintainer's
7
+ * choices (2026-10-17): local catalog + project skills, 3 per role.
8
+ */
9
+ import { loadAvailableSkills, filterSkillsForRole } from "./skill-loader.js";
10
+ import { listAvailableSlugs, loadSkillsBySlugs } from "./addyosmani-catalog.js";
11
+ import { resolveAddyosmaniSlugs } from "./addyosmani-role-map.js";
12
+
13
+ export const MAX_SKILLS = 3;
14
+
15
+ const defaultDeps = {
16
+ loadLocal: async (projectDir, role) => {
17
+ const all = projectDir ? await loadAvailableSkills(projectDir) : [];
18
+ return role === "coder" ? all : filterSkillsForRole(all, role);
19
+ },
20
+ listCatalog: () => listAvailableSlugs(),
21
+ loadCatalog: (slugs) => loadSkillsBySlugs(slugs),
22
+ };
23
+
24
+ /** @returns {Promise<{skills: Array<{name: string, content: string, path?: string, catalog?: true}>, dropped: string[]}>} */
25
+ export async function roleSkills({ role, task, projectDir, max = MAX_SKILLS, deps = defaultDeps }) {
26
+ const local = await deps.loadLocal(projectDir, role);
27
+ const available = new Set(await deps.listCatalog());
28
+ const ordered = [...new Set([...resolveAddyosmaniSlugs({ task }), ...resolveAddyosmaniSlugs({ role })])].filter((s) => available.has(s));
29
+ const room = Math.max(0, max - local.length);
30
+ const catalog = (await deps.loadCatalog(ordered.slice(0, room))).map((s) => ({ name: s.name, content: s.content, path: s.path, catalog: true }));
31
+ const dropped = [...local.slice(max).map((s) => s.name), ...ordered.slice(room)];
32
+ return { skills: [...local.slice(0, max), ...catalog], dropped };
33
+ }
34
+
35
+ /**
36
+ * The prompt section. For claude, the project's skills are native to its
37
+ * runtime, but a catalog skill is not: it is named with the path to read.
38
+ * Other providers get the content inline.
39
+ */
40
+ export function buildRoleSkillSection({ skills, dropped }, { provider } = {}) {
41
+ if (skills.length === 0) return "";
42
+ const lines = ["## Domain Skills", ""];
43
+ if (["claude", "anthropic"].includes(String(provider).toLowerCase())) {
44
+ const native = skills.filter((s) => !s.catalog).map((s) => s.name);
45
+ if (native.length) lines.push(`Skills available natively via your Agent Skills capability: ${native.join(", ")}.`);
46
+ const toRead = skills.filter((s) => s.catalog);
47
+ if (toRead.length) lines.push("Read these skills before you start, and follow them:", ...toRead.map((s) => `- ${s.name}: read ${s.path}`));
48
+ } else {
49
+ lines.push("The following domain-specific knowledge is available for this task:", ...skills.map((s) => `### ${s.name}\n${s.content}`));
50
+ }
51
+ if (dropped.length) lines.push("", `Left out (limit ${MAX_SKILLS}): ${dropped.join(", ")}`);
52
+ return lines.join("\n");
53
+ }
@@ -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