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 +1 -1
- package/src/agents/claude-agent.js +3 -0
- package/src/checks/qmd.js +3 -2
- package/src/cli/register-meta.js +6 -1
- package/src/commands/handoff.js +23 -5
- package/src/commands/hu.js +8 -0
- package/src/commands/plan/generate.js +12 -11
- package/src/config/defaults.js +2 -0
- package/src/config/schema.js +6 -0
- package/src/environment/mcp-wiring.js +30 -3
- package/src/environment/playbook.js +4 -1
- package/src/harden/handoff.js +54 -4
- package/src/harden/sentinel/sentinel-shell.mjs +5 -3
- package/src/harden/sentinel-hooks.js +8 -2
- package/src/hu/coder-routing.js +66 -0
- package/src/hu/model-router.js +18 -20
- package/src/hu/routing-metrics.js +2 -1
- package/src/orchestrator/drivers/iteration-loop.js +2 -0
- package/src/orchestrator/drivers/run-hu-batch.js +4 -1
- package/src/orchestrator/hu-sub-pipeline.js +3 -1
- package/src/orchestrator/stages/coder-stage.js +4 -1
- package/src/prompts/coder.js +3 -3
- package/src/prompts/reviewer.js +5 -3
- package/src/report/model-tally.js +6 -2
- package/src/review/sonar-pregate.js +8 -1
- package/src/roles/agent-role.js +1 -1
- package/src/roles/coder-role.js +8 -2
- package/src/skills/role-skills.js +53 -0
- package/src/utils/tool-governor.js +31 -16
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
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
|
},
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
package/src/commands/handoff.js
CHANGED
|
@@ -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) =>
|
|
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(`
|
|
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(`
|
|
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 });
|
package/src/commands/hu.js
CHANGED
|
@@ -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 {
|
|
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-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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;
|
package/src/config/defaults.js
CHANGED
|
@@ -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",
|
package/src/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 } = {}) {
|
package/src/harden/handoff.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/hu/model-router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/prompts/coder.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildRtkInstructions } from "./rtk-snippet.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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:
|
package/src/prompts/reviewer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildRtkInstructions } from "./rtk-snippet.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/roles/agent-role.js
CHANGED
|
@@ -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"
|
package/src/roles/coder-role.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
32
|
-
|
|
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 (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|