karajan-code 1.49.0 → 1.50.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.
@@ -1,890 +1,9 @@
1
- import { createAgent } from "../agents/index.js";
2
- import { CoderRole } from "../roles/coder-role.js";
3
- import { RefactorerRole } from "../roles/refactorer-role.js";
4
- import { SonarRole } from "../roles/sonar-role.js";
5
- import { addCheckpoint, markSessionStatus, saveSession } from "../session-store.js";
6
- import { generateDiff, getUntrackedFiles } from "../review/diff-generator.js";
7
- import { evaluateTddPolicy } from "../review/tdd-policy.js";
8
- import { validateReviewResult } from "../review/schema.js";
9
- import { filterReviewScope, buildDeferredContext } from "../review/scope-filter.js";
10
- import { emitProgress, makeEvent } from "../utils/events.js";
11
- import { runReviewerWithFallback } from "./reviewer-fallback.js";
12
- import { runCoderWithFallback } from "./agent-fallback.js";
13
- import { invokeSolomon } from "./solomon-escalation.js";
14
- import { detectRateLimit } from "../utils/rate-limit-detector.js";
15
- import { sonarUp, isSonarReachable } from "../sonar/manager.js";
16
- import { createStallDetector } from "../utils/stall-detector.js";
17
-
18
- export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
19
- logger.setContext({ iteration, stage: "coder" });
20
- emitProgress(
21
- emitter,
22
- makeEvent("coder:start", { ...eventBase, stage: "coder" }, {
23
- message: `Coder (${coderRole.provider}) running`,
24
- detail: { coder: coderRole.provider, provider: coderRole.provider, executorType: "agent" }
25
- })
26
- );
27
-
28
- const coderOnOutput = ({ stream, line }) => {
29
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "coder" }, {
30
- message: line,
31
- detail: { stream, agent: coderRole.provider }
32
- }));
33
- };
34
- const coderStall = createStallDetector({
35
- onOutput: coderOnOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
36
- });
37
- const coderStart = Date.now();
38
- let coderExecResult;
39
- try {
40
- coderExecResult = await coderRoleInstance.execute({
41
- task: plannedTask,
42
- reviewerFeedback: session.last_reviewer_feedback,
43
- sonarSummary: session.last_sonar_summary,
44
- deferredContext: buildDeferredContext(session.deferred_issues),
45
- onOutput: coderStall.onOutput
46
- });
47
- } finally {
48
- coderStall.stop();
49
- }
50
- trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
51
-
52
- if (!coderExecResult.ok) {
53
- const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
54
- const rateLimitCheck = detectRateLimit({
55
- stderr: coderExecResult.result?.error || "",
56
- stdout: coderExecResult.result?.output || ""
57
- });
58
-
59
- if (rateLimitCheck.isRateLimit) {
60
- // Try fallback agent if configured
61
- const fallbackCoder = config.coder_options?.fallback_coder;
62
- if (fallbackCoder && fallbackCoder !== coderRole.provider) {
63
- logger.warn(`Coder ${coderRole.provider} hit rate limit, falling back to ${fallbackCoder}`);
64
- emitProgress(
65
- emitter,
66
- makeEvent("coder:fallback", { ...eventBase, stage: "coder" }, {
67
- message: `Coder ${coderRole.provider} rate-limited, switching to ${fallbackCoder}`,
68
- detail: { primary: coderRole.provider, fallback: fallbackCoder }
69
- })
70
- );
71
-
72
- const fallbackResult = await runCoderWithFallback({
73
- coderName: fallbackCoder,
74
- fallbackCoder: null,
75
- config,
76
- logger,
77
- emitter,
78
- RoleClass: CoderRole,
79
- roleInput: { task: plannedTask, reviewerFeedback: session.last_reviewer_feedback, sonarSummary: session.last_sonar_summary, onOutput: coderOnOutput },
80
- session,
81
- iteration,
82
- onAttemptResult: ({ coder, result }) => {
83
- trackBudget({ role: "coder", provider: coder, model: coderRole.model, result, duration_ms: Date.now() - coderStart });
84
- }
85
- });
86
-
87
- if (fallbackResult.execResult?.ok) {
88
- await addCheckpoint(session, { stage: "coder", iteration, note: `Coder completed via fallback (${fallbackCoder})`, provider: fallbackCoder, model: null });
89
- emitProgress(
90
- emitter,
91
- makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
92
- message: `Coder completed (fallback: ${fallbackCoder})`,
93
- detail: { provider: fallbackCoder, executorType: "agent" }
94
- })
95
- );
96
- return;
97
- }
98
- }
99
-
100
- // No fallback or fallback also failed — enter standby
101
- return {
102
- action: "standby",
103
- standbyInfo: {
104
- agent: coderRole.provider,
105
- cooldownMs: rateLimitCheck.cooldownMs || (rateLimitCheck.isProviderOutage ? 30000 : null),
106
- cooldownUntil: rateLimitCheck.cooldownUntil,
107
- message: rateLimitCheck.message,
108
- isProviderOutage: rateLimitCheck.isProviderOutage || false
109
- }
110
- };
111
- }
112
-
113
- await markSessionStatus(session, "failed");
114
- emitProgress(
115
- emitter,
116
- makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
117
- status: "fail",
118
- message: `Coder failed: ${details}`,
119
- detail: { provider: coderRole.provider, executorType: "agent" }
120
- })
121
- );
122
- throw new Error(`Coder failed: ${details}`);
123
- }
124
-
125
- await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes", provider: coderRole.provider, model: coderRole.model || null });
126
- emitProgress(
127
- emitter,
128
- makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
129
- message: "Coder completed",
130
- detail: { provider: coderRole.provider, executorType: "agent" }
131
- })
132
- );
133
- }
134
-
135
- export async function runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
136
- logger.setContext({ iteration, stage: "refactorer" });
137
- emitProgress(
138
- emitter,
139
- makeEvent("refactorer:start", { ...eventBase, stage: "refactorer" }, {
140
- message: `Refactorer (${refactorerRole.provider}) running`,
141
- detail: { refactorer: refactorerRole.provider, provider: refactorerRole.provider, executorType: "agent" }
142
- })
143
- );
144
- const refactorerOnOutput = ({ stream, line }) => {
145
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "refactorer" }, {
146
- message: line,
147
- detail: { stream, agent: refactorerRole.provider }
148
- }));
149
- };
150
- const refactorerStall = createStallDetector({
151
- onOutput: refactorerOnOutput, emitter, eventBase, stage: "refactorer", provider: refactorerRole.provider
152
- });
153
-
154
- const refRole = new RefactorerRole({ config, logger, emitter, createAgentFn: createAgent });
155
- await refRole.init();
156
- const refactorerStart = Date.now();
157
- let refResult;
158
- try {
159
- refResult = await refRole.execute({ task: plannedTask, onOutput: refactorerStall.onOutput });
160
- } finally {
161
- refactorerStall.stop();
162
- }
163
- trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
164
- if (!refResult.ok) {
165
- const details = refResult.result?.error || refResult.summary || "unknown error";
166
- const rateLimitCheck = detectRateLimit({
167
- stderr: refResult.result?.error || "",
168
- stdout: refResult.result?.output || ""
169
- });
170
-
171
- if (rateLimitCheck.isRateLimit) {
172
- // Enter standby instead of pausing
173
- return {
174
- action: "standby",
175
- standbyInfo: {
176
- agent: refactorerRole.provider,
177
- cooldownMs: rateLimitCheck.cooldownMs || (rateLimitCheck.isProviderOutage ? 30000 : null),
178
- cooldownUntil: rateLimitCheck.cooldownUntil,
179
- message: rateLimitCheck.message,
180
- isProviderOutage: rateLimitCheck.isProviderOutage || false
181
- }
182
- };
183
- }
184
-
185
- await markSessionStatus(session, "failed");
186
- emitProgress(
187
- emitter,
188
- makeEvent("refactorer:end", { ...eventBase, stage: "refactorer" }, {
189
- status: "fail",
190
- message: `Refactorer failed: ${details}`,
191
- detail: { provider: refactorerRole.provider, executorType: "agent" }
192
- })
193
- );
194
- throw new Error(`Refactorer failed: ${details}`);
195
- }
196
- await addCheckpoint(session, { stage: "refactorer", iteration, note: "Refactorer applied cleanups", provider: refactorerRole.provider, model: refactorerRole.model || null });
197
- emitProgress(
198
- emitter,
199
- makeEvent("refactorer:end", { ...eventBase, stage: "refactorer" }, {
200
- message: "Refactorer completed",
201
- detail: { provider: refactorerRole.provider, executorType: "agent" }
202
- })
203
- );
204
- }
205
-
206
- function handleSolomonAction(solomonResult, session, contextPrefix) {
207
- if (solomonResult.action === "pause") {
208
- return { action: "pause", result: { paused: true, sessionId: session.id, question: solomonResult.question, context: `${contextPrefix}_fail_fast` } };
209
- }
210
- if (solomonResult.action === "subtask") {
211
- return { action: "pause", result: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: `${contextPrefix}_subtask` } };
212
- }
213
- return null;
214
- }
215
-
216
- async function handleSolomonContinue(solomonResult, session, counterField) {
217
- if (solomonResult.action !== "continue") return false;
218
- if (solomonResult.humanGuidance) {
219
- session.last_reviewer_feedback += `\nUser guidance: ${solomonResult.humanGuidance}`;
220
- }
221
- session[counterField] = 0;
222
- await saveSession(session);
223
- return true;
224
- }
225
-
226
- async function handleTddFailure({ tddEval, config, logger, emitter, eventBase, session, iteration, askQuestion }) {
227
- session.last_reviewer_feedback = tddEval.message;
228
- session.repeated_issue_count += 1;
229
- await saveSession(session);
230
-
231
- if (session.repeated_issue_count < config.session.fail_fast_repeats) {
232
- return { action: "continue" };
233
- }
234
-
235
- emitProgress(
236
- emitter,
237
- makeEvent("solomon:escalate", { ...eventBase, stage: "tdd" }, {
238
- message: `TDD sub-loop limit reached (${session.repeated_issue_count}/${config.session.fail_fast_repeats})`,
239
- detail: { subloop: "tdd", retryCount: session.repeated_issue_count, reason: tddEval.reason }
240
- })
241
- );
242
-
243
- const solomonResult = await invokeSolomon({
244
- config, logger, emitter, eventBase, stage: "tdd", askQuestion, session, iteration,
245
- conflict: {
246
- stage: "tdd",
247
- task: session.task,
248
- iterationCount: session.repeated_issue_count,
249
- maxIterations: config.session.fail_fast_repeats,
250
- reason: tddEval.reason,
251
- sourceFiles: tddEval.sourceFiles,
252
- testFiles: tddEval.testFiles,
253
- history: [{ agent: "tdd-policy", feedback: tddEval.message }]
254
- }
255
- });
256
-
257
- const actionResult = handleSolomonAction(solomonResult, session, "tdd");
258
- if (actionResult) return actionResult;
259
- const continued = await handleSolomonContinue(solomonResult, session, "repeated_issue_count");
260
- if (continued) return { action: "continue" };
261
-
262
- return { action: "continue" };
263
- }
264
-
265
- export async function runTddCheckStage({ config, logger, emitter, eventBase, session, trackBudget, iteration, askQuestion }) {
266
- logger.setContext({ iteration, stage: "tdd" });
267
- let tddDiff, untrackedFiles;
268
- try {
269
- tddDiff = await generateDiff({ baseRef: session.session_start_sha });
270
- untrackedFiles = await getUntrackedFiles();
271
- } catch (err) {
272
- logger.warn(`TDD diff generation failed: ${err.message}`);
273
- return { action: "continue", stageResult: { ok: false, summary: `TDD check failed: ${err.message}` } };
274
- }
275
- const effectiveTaskType = session.resolved_policies?.taskType || null;
276
- const tddEval = evaluateTddPolicy(tddDiff, config.development, untrackedFiles, effectiveTaskType);
277
- await addCheckpoint(session, {
278
- stage: "tdd-policy",
279
- iteration,
280
- ok: tddEval.ok,
281
- reason: tddEval.reason,
282
- source_files: tddEval.sourceFiles?.length || 0,
283
- test_files: tddEval.testFiles?.length || 0
284
- });
285
-
286
- emitProgress(
287
- emitter,
288
- makeEvent("tdd:result", { ...eventBase, stage: "tdd" }, {
289
- status: tddEval.ok ? "ok" : "fail",
290
- message: tddEval.ok ? "TDD policy passed" : `TDD policy failed: ${tddEval.reason}`,
291
- detail: {
292
- ok: tddEval.ok,
293
- reason: tddEval.reason,
294
- sourceFiles: tddEval.sourceFiles?.length || 0,
295
- testFiles: tddEval.testFiles?.length || 0,
296
- executorType: "local"
297
- }
298
- })
299
- );
300
-
301
- if (!tddEval.ok) {
302
- return handleTddFailure({ tddEval, config, logger, emitter, eventBase, session, iteration, askQuestion });
303
- }
304
-
305
- return { action: "ok" };
306
- }
307
-
308
- async function handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary }) {
309
- const repeatCounts = repeatDetector.getRepeatCounts();
310
- const message = `No progress: SonarQube issues repeated ${repeatCounts.sonar} times.`;
311
- logger.warn(message);
312
- await markSessionStatus(session, "stalled");
313
- const repeatState = repeatDetector.isStalled();
314
- emitProgress(
315
- emitter,
316
- makeEvent("session:end", { ...eventBase, stage: "sonar" }, {
317
- status: "stalled",
318
- message,
319
- detail: { reason: repeatState.reason, repeats: repeatCounts.sonar, budget: budgetSummary() }
320
- })
321
- );
322
- return { action: "stalled", result: { approved: false, sessionId: session.id, reason: "stalled" } };
323
- }
324
-
325
- async function handleSonarRetryLimit({ config, logger, emitter, eventBase, session, iteration, askQuestion, task, maxSonarRetries, sonarResult }) {
326
- emitProgress(
327
- emitter,
328
- makeEvent("solomon:escalate", { ...eventBase, stage: "sonar" }, {
329
- message: `Sonar sub-loop limit reached (${session.sonar_retry_count}/${maxSonarRetries})`,
330
- detail: { subloop: "sonar", retryCount: session.sonar_retry_count, limit: maxSonarRetries, gateStatus: sonarResult.gateStatus }
331
- })
332
- );
333
-
334
- const solomonResult = await invokeSolomon({
335
- config, logger, emitter, eventBase, stage: "sonar", askQuestion, session, iteration,
336
- conflict: {
337
- stage: "sonar",
338
- task,
339
- iterationCount: session.sonar_retry_count,
340
- maxIterations: maxSonarRetries,
341
- history: [{ agent: "sonar", feedback: session.last_sonar_summary }]
342
- }
343
- });
344
-
345
- const actionResult = handleSolomonAction(solomonResult, session, "sonar");
346
- if (actionResult) return actionResult;
347
- const continued = await handleSolomonContinue(solomonResult, session, "sonar_retry_count");
348
- if (continued) return { action: "continue" };
349
-
350
- return null;
351
- }
352
-
353
- async function handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task }) {
354
- // If the ONLY quality gate failure is coverage, treat as non-blocking warning
355
- if (sonarResult.conditions) {
356
- const failedConditions = sonarResult.conditions.filter(c => c.status === "ERROR");
357
- const onlyCoverage = failedConditions.length > 0 && failedConditions.every(c =>
358
- c.metricKey === "new_coverage" || c.metricKey === "coverage"
359
- );
360
- if (onlyCoverage) {
361
- logger.warn("Quality gate failed on coverage only — treating as advisory (code quality is clean)");
362
- emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
363
- status: "warn",
364
- message: "Quality gate: coverage below threshold (advisory — code quality is clean)"
365
- }));
366
- session.last_reviewer_feedback = null;
367
- return { action: "ok", stageResult: { gateStatus: "WARN_COVERAGE", advisory: true } };
368
- }
369
- }
370
-
371
- repeatDetector.addIteration(sonarResult.issues, []);
372
- const repeatState = repeatDetector.isStalled();
373
- if (repeatState.stalled) {
374
- return handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary });
375
- }
376
-
377
- session.last_reviewer_feedback = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first.`;
378
- session.sonar_retry_count = (session.sonar_retry_count || 0) + 1;
379
- await saveSession(session);
380
- const maxSonarRetries = config.session.max_sonar_retries ?? config.session.fail_fast_repeats;
381
-
382
- if (session.sonar_retry_count >= maxSonarRetries) {
383
- const result = await handleSonarRetryLimit({ config, logger, emitter, eventBase, session, iteration, askQuestion, task, maxSonarRetries, sonarResult });
384
- if (result) return result;
385
- }
386
-
387
- return { action: "continue" };
388
- }
389
-
390
- export async function runSonarStage({ config, logger, emitter, eventBase, session, trackBudget, iteration, repeatDetector, budgetSummary, sonarState, askQuestion, task }) {
391
- logger.setContext({ iteration, stage: "sonar" });
392
- emitProgress(
393
- emitter,
394
- makeEvent("sonar:start", { ...eventBase, stage: "sonar" }, {
395
- message: "SonarQube scanning",
396
- detail: { provider: "sonarqube", executorType: "local" }
397
- })
398
- );
399
-
400
- // Auto-manage SonarQube: ensure it is reachable before scanning
401
- // sonarUp, isSonarReachable imported at top level for testability
402
- const sonarHost = config.sonarqube?.host || "http://localhost:9000";
403
-
404
- if (!await isSonarReachable(sonarHost)) {
405
- logger.info("SonarQube not reachable, attempting to start...");
406
- emitProgress(emitter, makeEvent("sonar:start", { ...eventBase, stage: "sonar" }, { message: "Starting SonarQube Docker..." }));
407
-
408
- const upResult = await sonarUp(sonarHost);
409
- if (upResult.exitCode !== 0) {
410
- logger.warn(`SonarQube could not be started: ${upResult.stderr || upResult.stdout}`);
411
- emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
412
- status: "skip",
413
- message: "SonarQube not available — install Docker and run 'kj sonar start' to enable static analysis"
414
- }));
415
- return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "SonarQube not available" } };
416
- }
417
-
418
- let ready = false;
419
- for (let attempt = 0; attempt < 12; attempt++) {
420
- await new Promise(r => setTimeout(r, 5000));
421
- if (await isSonarReachable(sonarHost)) { ready = true; break; }
422
- }
423
- if (!ready) {
424
- logger.warn("SonarQube started but not ready after 60s");
425
- emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
426
- status: "skip", message: "SonarQube started but not ready — will retry next iteration"
427
- }));
428
- return { action: "ok", stageResult: { gateStatus: "PENDING", reason: "SonarQube starting up" } };
429
- }
430
- logger.info("SonarQube is ready");
431
- }
432
-
433
- const sonarRole = new SonarRole({ config, logger, emitter });
434
- await sonarRole.init({ iteration });
435
- const sonarStart = Date.now();
436
- let sonarOutput;
437
- try {
438
- sonarOutput = await sonarRole.run();
439
- } catch (err) {
440
- logger.warn(`Sonar threw: ${err.message}`);
441
- sonarOutput = { ok: false, result: { error: err.message }, summary: `Sonar error: ${err.message}` };
442
- }
443
- trackBudget({ role: "sonar", provider: "sonar", result: sonarOutput, duration_ms: Date.now() - sonarStart });
444
- const sonarResult = sonarOutput.result;
445
-
446
- if (!sonarResult.gateStatus && sonarResult.error) {
447
- const isTokenError = /unable to resolve sonar token/i.test(sonarResult.error);
448
- const errorMessage = isTokenError
449
- ? "SonarQube is running but no authentication token is configured. Fix: run 'kj init' to configure it, or set KJ_SONAR_TOKEN env var, or add sonarqube.token to ~/.karajan/kj.config.yml."
450
- : `Sonar scan failed: ${sonarResult.error}`;
451
-
452
- await markSessionStatus(session, "failed");
453
- emitProgress(
454
- emitter,
455
- makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
456
- status: "fail",
457
- message: errorMessage
458
- })
459
- );
460
- throw new Error(errorMessage);
461
- }
462
-
463
- session.last_sonar_summary = sonarOutput.summary;
464
- if (typeof sonarResult.openIssuesTotal === "number") {
465
- if (sonarState.issuesInitial === null) {
466
- sonarState.issuesInitial = sonarResult.openIssuesTotal;
467
- }
468
- sonarState.issuesFinal = sonarResult.openIssuesTotal;
469
- }
470
- await addCheckpoint(session, {
471
- stage: "sonar",
472
- iteration,
473
- project_key: sonarResult.projectKey,
474
- quality_gate: sonarResult.gateStatus,
475
- open_issues: sonarResult.openIssuesTotal,
476
- provider: "sonar",
477
- model: null
478
- });
479
-
480
- emitProgress(
481
- emitter,
482
- makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
483
- status: sonarResult.blocking ? "fail" : "ok",
484
- message: `Quality gate: ${sonarResult.gateStatus}`,
485
- detail: { projectKey: sonarResult.projectKey, gateStatus: sonarResult.gateStatus, openIssues: sonarResult.openIssuesTotal, provider: "sonarqube", executorType: "local" }
486
- })
487
- );
488
-
489
- if (sonarResult.blocking) {
490
- return handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task });
491
- }
492
-
493
- // Sonar passed — reset retry counter
494
- session.sonar_retry_count = 0;
495
- const issuesInitial = sonarState.issuesInitial ?? sonarResult.openIssuesTotal ?? 0;
496
- const issuesFinal = sonarState.issuesFinal ?? sonarResult.openIssuesTotal ?? 0;
497
- const stageResult = {
498
- gateStatus: sonarResult.gateStatus,
499
- openIssues: sonarResult.openIssuesTotal,
500
- issuesInitial,
501
- issuesFinal,
502
- issuesResolved: Math.max(issuesInitial - issuesFinal, 0)
503
- };
504
-
505
- return { action: "ok", stageResult };
506
- }
507
-
508
- export async function runSonarCloudStage({ config, logger, emitter, eventBase, session, trackBudget, iteration }) {
509
- logger.setContext({ iteration, stage: "sonarcloud" });
510
- emitProgress(
511
- emitter,
512
- makeEvent("sonarcloud:start", { ...eventBase, stage: "sonarcloud" }, {
513
- message: "SonarCloud scanning",
514
- detail: { provider: "sonarcloud", executorType: "local" }
515
- })
516
- );
517
-
518
- const { runSonarCloudScan } = await import("../sonar/cloud-scanner.js");
519
- const scanStart = Date.now();
520
- let result;
521
- try {
522
- result = await runSonarCloudScan(config);
523
- } catch (err) {
524
- logger.warn(`SonarCloud threw: ${err.message}`);
525
- result = { ok: false, error: err.message };
526
- }
527
- trackBudget({ role: "sonarcloud", provider: "sonarcloud", result: { ok: result.ok }, duration_ms: Date.now() - scanStart });
528
-
529
- await addCheckpoint(session, {
530
- stage: "sonarcloud",
531
- iteration,
532
- project_key: result.projectKey,
533
- exitCode: result.exitCode,
534
- provider: "sonarcloud",
535
- model: null
536
- });
537
-
538
- const status = result.ok ? "ok" : "warn";
539
- const message = result.ok
540
- ? `SonarCloud scan passed (project: ${result.projectKey})`
541
- : `SonarCloud scan issue: ${(result.stderr || "").slice(0, 200)}`;
542
-
543
- emitProgress(
544
- emitter,
545
- makeEvent("sonarcloud:end", { ...eventBase, stage: "sonarcloud" }, {
546
- status,
547
- message,
548
- detail: { projectKey: result.projectKey, exitCode: result.exitCode, provider: "sonarcloud", executorType: "local" }
549
- })
550
- );
551
-
552
- // SonarCloud is advisory — never blocks the pipeline
553
- return { action: "ok", stageResult: { ok: result.ok, projectKey: result.projectKey, message } };
554
- }
555
-
556
- function categorizeIssues(issues) {
557
- const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
558
- const securityKw = /inject|xss|csrf|secret|credential|auth|vulnerab|exploit/i;
559
- const styleKw = /naming|name|rename|style|format|indent|spacing|convention|cosmetic|readability|comment|jsdoc|whitespace/i;
560
- const testKw = /test|coverage|assert|spec|mock/i;
561
-
562
- for (const issue of issues || []) {
563
- const desc = issue.description || "";
564
- const sev = (issue.severity || "").toLowerCase();
565
- if (sev === "critical" || securityKw.test(desc)) categories.security++;
566
- else if (testKw.test(desc)) categories.tests++;
567
- else if (sev === "low" || sev === "minor" || styleKw.test(desc)) categories.style++;
568
- else categories.correctness++;
569
- }
570
- return categories;
571
- }
572
-
573
- function buildReviewHistory(session) {
574
- return (session.checkpoints || [])
575
- .filter(cp => cp.stage === "reviewer")
576
- .map(cp => ({ iteration: cp.iteration, note: cp.note || "" }));
577
- }
578
-
579
- async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary, repeatDetector }) {
580
- const logPrefix = repeatState.stalled
581
- ? `Reviewer stalled (${repeatCounts.reviewer} repeats)`
582
- : `Reviewer rejected (first rejection)`;
583
- logger.warn(`${logPrefix}. Invoking Solomon mediation.`);
584
- emitProgress(
585
- emitter,
586
- makeEvent("solomon:escalate", { ...eventBase, stage: "reviewer" }, {
587
- message: `${logPrefix} — Solomon mediating`,
588
- detail: { repeats: repeatCounts.reviewer || 1, reason: repeatState.reason || "first_rejection" }
589
- })
590
- );
591
-
592
- const solomonResult = await invokeSolomon({
593
- config, logger, emitter, eventBase, stage: "reviewer", askQuestion, session, iteration,
594
- conflict: {
595
- stage: "reviewer",
596
- task,
597
- iterationCount: repeatCounts.reviewer || 1,
598
- maxIterations: config.session?.fail_fast_repeats ?? 2,
599
- isFirstRejection: !repeatState.stalled,
600
- isRepeat: repeatState.stalled,
601
- stalledReason: repeatState.reason || "first_rejection",
602
- blockingIssues: review.blocking_issues,
603
- issueCategories: categorizeIssues(review.blocking_issues),
604
- history: [
605
- ...buildReviewHistory(session),
606
- { agent: "reviewer", feedback: review.blocking_issues.map(x => x.description).join("; ") }
607
- ]
608
- }
609
- });
610
-
611
- if (solomonResult.action === "approve") {
612
- logger.info("Solomon overrode reviewer — approving code");
613
- emitProgress(emitter, makeEvent("solomon:override", { ...eventBase, stage: "solomon" }, {
614
- message: "Solomon overrode reviewer rejection — code approved",
615
- detail: { ruling: solomonResult.ruling?.result?.ruling }
616
- }));
617
- return { review: { ...review, approved: true, solomon_override: true }, solomonApproved: true };
618
- }
619
- if (solomonResult.action === "pause") {
620
- await markSessionStatus(session, "stalled");
621
- return { review, stalled: true, stalledResult: { paused: true, sessionId: session.id, question: solomonResult.question, context: "reviewer_stalled" } };
622
- }
623
- if (solomonResult.action === "continue") {
624
- repeatDetector.reviewer = { lastHash: null, repeatCount: 0 };
625
- if (solomonResult.humanGuidance) {
626
- session.last_reviewer_feedback = `Solomon/user guidance: ${solomonResult.humanGuidance}`;
627
- await saveSession(session);
628
- }
629
- return { review };
630
- }
631
- if (solomonResult.action === "subtask") {
632
- return { review, stalled: true, stalledResult: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: "reviewer_subtask" } };
633
- }
634
-
635
- // Fallback
636
- const message = `Manual intervention required: reviewer issues repeated ${repeatCounts.reviewer || 1} times.`;
637
- await markSessionStatus(session, "stalled");
638
- emitProgress(
639
- emitter,
640
- makeEvent("session:end", { ...eventBase, stage: "reviewer" }, {
641
- status: "stalled",
642
- message,
643
- detail: { reason: repeatState.reason, repeats: repeatCounts.reviewer, budget: budgetSummary() }
644
- })
645
- );
646
- return { review, stalled: true, stalledResult: { approved: false, sessionId: session.id, reason: "stalled" } };
647
- }
648
-
649
- async function handleReviewerRejection({ review, repeatDetector, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary }) {
650
- repeatDetector.addIteration([], review.blocking_issues);
651
- const repeatState = repeatDetector.isStalled();
652
-
653
- const solomonEnabled = Boolean(config.pipeline?.solomon?.enabled);
654
-
655
- // When Solomon is disabled, only act on stalls (legacy behavior)
656
- if (!solomonEnabled) {
657
- if (!repeatState.stalled) return null;
658
- const repeatCounts = repeatDetector.getRepeatCounts();
659
- return handleReviewerStalledSolomon({
660
- review, repeatCounts, repeatState, config, logger, emitter,
661
- eventBase, session, iteration, task, askQuestion,
662
- budgetSummary, repeatDetector
663
- });
664
- }
665
-
666
- // Solomon evaluates EVERY rejection
667
- const repeatCounts = repeatDetector.getRepeatCounts();
668
- logger.info(`Reviewer rejected — Solomon evaluating ${review.blocking_issues.length} blocking issue(s)`);
669
- emitProgress(emitter, makeEvent("solomon:evaluate", { ...eventBase, stage: "solomon" }, {
670
- message: `Solomon evaluating reviewer rejection`,
671
- detail: { blockingCount: review.blocking_issues.length, isRepeat: repeatState.stalled }
672
- }));
673
-
674
- return handleReviewerStalledSolomon({
675
- review, repeatCounts, repeatState, config, logger, emitter,
676
- eventBase, session, iteration, task, askQuestion,
677
- budgetSummary, repeatDetector
678
- });
679
- }
680
-
681
- async function fetchReviewDiff(session, logger) {
682
- if (session.becaria_pr_number) {
683
- const { getPrDiff } = await import("../becaria/pr-diff.js");
684
- const diff = await getPrDiff(session.becaria_pr_number);
685
- logger.info(`Reviewer reading PR diff #${session.becaria_pr_number}`);
686
- return diff;
687
- }
688
- return generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
689
- }
690
-
691
- export async function runReviewerStage({ reviewerRole, config, logger, emitter, eventBase, session, trackBudget, iteration, reviewRules, task, repeatDetector, budgetSummary, askQuestion }) {
692
- logger.setContext({ iteration, stage: "reviewer" });
693
- emitProgress(
694
- emitter,
695
- makeEvent("reviewer:start", { ...eventBase, stage: "reviewer" }, {
696
- message: `Reviewer (${reviewerRole.provider}) running`,
697
- detail: { reviewer: reviewerRole.provider, provider: reviewerRole.provider, executorType: "agent" }
698
- })
699
- );
700
-
701
- let diff;
702
- try {
703
- diff = await fetchReviewDiff(session, logger);
704
- } catch (err) {
705
- logger.warn(`Review diff generation failed: ${err.message}`);
706
- return { approved: false, blocking_issues: [{ description: `Diff generation failed: ${err.message}` }], non_blocking_suggestions: [], summary: `Reviewer failed: cannot generate diff — ${err.message}`, confidence: 0 };
707
- }
708
-
709
- // Injection guard: scan diff before sending to AI reviewer
710
- const { scanDiff } = await import("../utils/injection-guard.js");
711
- const guardResult = scanDiff(diff);
712
- if (!guardResult.clean) {
713
- logger.warn(`Injection guard: ${guardResult.summary}`);
714
- emitProgress(emitter, makeEvent("guard:injection", { ...eventBase, stage: "reviewer" }, {
715
- message: `Injection guard blocked review: ${guardResult.summary}`,
716
- detail: { findings: guardResult.findings, summary: guardResult.summary }
717
- }));
718
- return {
719
- approved: false,
720
- blocking_issues: guardResult.findings.map((f) => ({
721
- id: `INJECTION_${f.type.toUpperCase()}`,
722
- severity: "critical",
723
- description: `Potential prompt injection (${f.type}): ${f.snippet}`,
724
- line: f.line,
725
- })),
726
- non_blocking_suggestions: [],
727
- summary: `Review blocked by injection guard: ${guardResult.summary}`,
728
- confidence: 1,
729
- };
730
- }
731
-
732
- const reviewerOnOutput = ({ stream, line }) => {
733
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "reviewer" }, {
734
- message: line,
735
- detail: { stream, agent: reviewerRole.provider }
736
- }));
737
- };
738
- const reviewerStall = createStallDetector({
739
- onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
740
- });
741
- const reviewerStart = Date.now();
742
- let reviewerExec;
743
- try {
744
- reviewerExec = await runReviewerWithFallback({
745
- reviewerName: reviewerRole.provider,
746
- config,
747
- logger,
748
- emitter,
749
- reviewInput: { task, diff, reviewRules, onOutput: reviewerStall.onOutput },
750
- session,
751
- iteration,
752
- onAttemptResult: ({ reviewer, result }) => {
753
- trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
754
- }
755
- });
756
- } catch (err) {
757
- logger.warn(`Reviewer threw: ${err.message}`);
758
- reviewerExec = { execResult: { ok: false, error: err.message }, attempts: [{ reviewer: reviewerRole.provider, result: { ok: false, error: err.message } }] };
759
- } finally {
760
- reviewerStall.stop();
761
- }
762
-
763
- if (!reviewerExec.execResult?.ok) {
764
- const lastAttempt = reviewerExec.attempts.at(-1);
765
- const details =
766
- lastAttempt?.result?.error ||
767
- lastAttempt?.execResult?.summary ||
768
- `reviewer=${lastAttempt?.reviewer || "unknown"}`;
769
-
770
- const rateLimitCheck = detectRateLimit({
771
- stderr: lastAttempt?.result?.error || "",
772
- stdout: lastAttempt?.result?.output || ""
773
- });
774
-
775
- if (rateLimitCheck.isRateLimit) {
776
- // Enter standby instead of pausing
777
- return {
778
- action: "standby",
779
- standbyInfo: {
780
- agent: reviewerRole.provider,
781
- cooldownMs: rateLimitCheck.cooldownMs || (rateLimitCheck.isProviderOutage ? 30000 : null),
782
- cooldownUntil: rateLimitCheck.cooldownUntil,
783
- message: rateLimitCheck.message,
784
- isProviderOutage: rateLimitCheck.isProviderOutage || false
785
- }
786
- };
787
- }
788
-
789
- await markSessionStatus(session, "failed");
790
- emitProgress(
791
- emitter,
792
- makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
793
- status: "fail",
794
- message: `Reviewer failed: ${details}`,
795
- detail: { provider: reviewerRole.provider, executorType: "agent" }
796
- })
797
- );
798
- throw new Error(`Reviewer failed: ${details}`);
799
- }
800
-
801
- const reviewResult = reviewerExec.execResult.result;
802
- let review;
803
- try {
804
- review = validateReviewResult({
805
- approved: reviewResult.approved,
806
- blocking_issues: reviewResult.blocking_issues || [],
807
- non_blocking_suggestions: reviewResult.non_blocking_suggestions || [],
808
- summary: reviewResult.raw_summary || "",
809
- confidence: reviewResult.confidence ?? 0
810
- });
811
- } catch (error_) {
812
- logger.warn(`Reviewer output validation failed: ${error_.message}`);
813
- review = {
814
- approved: false,
815
- blocking_issues: [{
816
- id: "PARSE_ERROR",
817
- severity: "high",
818
- description: `Reviewer output could not be parsed: ${error_.message}`
819
- }],
820
- non_blocking_suggestions: [],
821
- summary: `Parse error: ${error_.message}`,
822
- confidence: 0
823
- };
824
- }
825
- // --- Scope filter: auto-defer out-of-scope blocking issues ---
826
- const { review: filteredReview, demoted, deferred, allDemoted } = filterReviewScope(review, diff);
827
- review = filteredReview;
828
-
829
- if (demoted.length > 0) {
830
- logger.info(`Scope filter: deferred ${demoted.length} out-of-scope issue(s)${allDemoted ? " — auto-approved" : ""}`);
831
-
832
- // Accumulate deferred issues in session for tracking
833
- if (!session.deferred_issues) session.deferred_issues = [];
834
- session.deferred_issues.push(...deferred);
835
- await saveSession(session);
836
-
837
- emitProgress(
838
- emitter,
839
- makeEvent("reviewer:scope_filter", { ...eventBase, stage: "reviewer" }, {
840
- message: `Scope filter deferred ${demoted.length} out-of-scope issue(s)`,
841
- detail: {
842
- demotedCount: demoted.length,
843
- autoApproved: allDemoted,
844
- totalDeferred: session.deferred_issues.length,
845
- deferred: deferred.map(d => ({ file: d.file, id: d.id, description: d.description }))
846
- }
847
- })
848
- );
849
- await addCheckpoint(session, {
850
- stage: "reviewer-scope-filter",
851
- iteration,
852
- demoted_count: demoted.length,
853
- auto_approved: allDemoted,
854
- total_deferred: session.deferred_issues.length
855
- });
856
- }
857
-
858
- await addCheckpoint(session, {
859
- stage: "reviewer",
860
- iteration,
861
- approved: review.approved,
862
- blocking_issues: review.blocking_issues.length,
863
- provider: reviewerRole.provider,
864
- model: reviewerRole.model || null
865
- });
866
-
867
- emitProgress(
868
- emitter,
869
- makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
870
- status: review.approved ? "ok" : "fail",
871
- message: review.approved ? "Review approved" : `Review rejected (${review.blocking_issues.length} blocking)`,
872
- detail: {
873
- approved: review.approved,
874
- blockingCount: review.blocking_issues.length,
875
- issues: review.blocking_issues.map(
876
- (x) => `${x.id || "ISSUE"}: ${x.description || "Missing description"}`
877
- ),
878
- provider: reviewerRole.provider,
879
- executorType: "agent"
880
- }
881
- })
882
- );
883
-
884
- if (!review.approved) {
885
- const rejectionResult = await handleReviewerRejection({ review, repeatDetector, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary });
886
- if (rejectionResult) return rejectionResult;
887
- }
888
-
889
- return { review };
890
- }
1
+ /**
2
+ * Iteration stage functions.
3
+ * Implementations have been extracted into ./stages/ sub-modules.
4
+ * This file re-exports everything for backward compatibility.
5
+ */
6
+
7
+ export { runCoderStage, runRefactorerStage, runTddCheckStage } from "./stages/coder-stage.js";
8
+ export { runSonarStage, runSonarCloudStage } from "./stages/sonar-stage.js";
9
+ export { runReviewerStage, fetchReviewDiff } from "./stages/reviewer-stage.js";