karajan-code 4.54.0 → 4.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/claude-agent.js +3 -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/harden/sentinel/sentinel-shell.mjs +5 -3
- 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/reviewer.js +2 -0
- 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/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
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -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/reviewer.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
|