karajan-code 1.24.1 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.24.1",
3
+ "version": "1.25.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -264,7 +264,8 @@ export async function runTddCheckStage({ config, logger, emitter, eventBase, ses
264
264
  logger.warn(`TDD diff generation failed: ${err.message}`);
265
265
  return { action: "continue", stageResult: { ok: false, summary: `TDD check failed: ${err.message}` } };
266
266
  }
267
- const tddEval = evaluateTddPolicy(tddDiff, config.development, untrackedFiles);
267
+ const effectiveTaskType = session.resolved_policies?.taskType || null;
268
+ const tddEval = evaluateTddPolicy(tddDiff, config.development, untrackedFiles, effectiveTaskType);
268
269
  await addCheckpoint(session, {
269
270
  stage: "tdd-policy",
270
271
  iteration,
@@ -341,6 +342,23 @@ async function handleSonarRetryLimit({ config, logger, emitter, eventBase, sessi
341
342
  }
342
343
 
343
344
  async function handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task }) {
345
+ // If the ONLY quality gate failure is coverage, treat as non-blocking warning
346
+ if (sonarResult.conditions) {
347
+ const failedConditions = sonarResult.conditions.filter(c => c.status === "ERROR");
348
+ const onlyCoverage = failedConditions.length > 0 && failedConditions.every(c =>
349
+ c.metricKey === "new_coverage" || c.metricKey === "coverage"
350
+ );
351
+ if (onlyCoverage) {
352
+ logger.warn("Quality gate failed on coverage only — treating as advisory (code quality is clean)");
353
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
354
+ status: "warn",
355
+ message: "Quality gate: coverage below threshold (advisory — code quality is clean)"
356
+ }));
357
+ session.last_reviewer_feedback = null;
358
+ return { action: "ok", stageResult: { gateStatus: "WARN_COVERAGE", advisory: true } };
359
+ }
360
+ }
361
+
344
362
  repeatDetector.addIteration(sonarResult.issues, []);
345
363
  const repeatState = repeatDetector.isStalled();
346
364
  if (repeatState.stalled) {
@@ -369,6 +387,39 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
369
387
  })
370
388
  );
371
389
 
390
+ // Auto-manage SonarQube: ensure it is reachable before scanning
391
+ const { sonarUp, isSonarReachable } = await import("../sonar/manager.js");
392
+ const sonarHost = config.sonarqube?.host || "http://localhost:9000";
393
+
394
+ if (!await isSonarReachable(sonarHost)) {
395
+ logger.info("SonarQube not reachable, attempting to start...");
396
+ emitProgress(emitter, makeEvent("sonar:start", { ...eventBase, stage: "sonar" }, { message: "Starting SonarQube Docker..." }));
397
+
398
+ const upResult = await sonarUp(sonarHost);
399
+ if (upResult.exitCode !== 0) {
400
+ logger.warn(`SonarQube could not be started: ${upResult.stderr || upResult.stdout}`);
401
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
402
+ status: "skip",
403
+ message: "SonarQube not available — install Docker and run 'kj sonar start' to enable static analysis"
404
+ }));
405
+ return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "SonarQube not available" } };
406
+ }
407
+
408
+ let ready = false;
409
+ for (let attempt = 0; attempt < 12; attempt++) {
410
+ await new Promise(r => setTimeout(r, 5000));
411
+ if (await isSonarReachable(sonarHost)) { ready = true; break; }
412
+ }
413
+ if (!ready) {
414
+ logger.warn("SonarQube started but not ready after 60s");
415
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
416
+ status: "skip", message: "SonarQube started but not ready — will retry next iteration"
417
+ }));
418
+ return { action: "ok", stageResult: { gateStatus: "PENDING", reason: "SonarQube starting up" } };
419
+ }
420
+ logger.info("SonarQube is ready");
421
+ }
422
+
372
423
  const sonarRole = new SonarRole({ config, logger, emitter });
373
424
  await sonarRole.init({ iteration });
374
425
  const sonarStart = Date.now();
@@ -486,13 +537,39 @@ export async function runSonarCloudStage({ config, logger, emitter, eventBase, s
486
537
  return { action: "ok", stageResult: { ok: result.ok, projectKey: result.projectKey, message } };
487
538
  }
488
539
 
540
+ function categorizeIssues(issues) {
541
+ const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
542
+ const securityKw = /inject|xss|csrf|secret|credential|auth|vulnerab|exploit/i;
543
+ const styleKw = /naming|name|rename|style|format|indent|spacing|convention|cosmetic|readability|comment|jsdoc|whitespace/i;
544
+ const testKw = /test|coverage|assert|spec|mock/i;
545
+
546
+ for (const issue of issues || []) {
547
+ const desc = issue.description || "";
548
+ const sev = (issue.severity || "").toLowerCase();
549
+ if (sev === "critical" || securityKw.test(desc)) categories.security++;
550
+ else if (testKw.test(desc)) categories.tests++;
551
+ else if (sev === "low" || sev === "minor" || styleKw.test(desc)) categories.style++;
552
+ else categories.correctness++;
553
+ }
554
+ return categories;
555
+ }
556
+
557
+ function buildReviewHistory(session) {
558
+ return (session.checkpoints || [])
559
+ .filter(cp => cp.stage === "reviewer")
560
+ .map(cp => ({ iteration: cp.iteration, note: cp.note || "" }));
561
+ }
562
+
489
563
  async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary, repeatDetector }) {
490
- logger.warn(`Reviewer stalled (${repeatCounts.reviewer} repeats). Invoking Solomon mediation.`);
564
+ const logPrefix = repeatState.stalled
565
+ ? `Reviewer stalled (${repeatCounts.reviewer} repeats)`
566
+ : `Reviewer rejected (first rejection)`;
567
+ logger.warn(`${logPrefix}. Invoking Solomon mediation.`);
491
568
  emitProgress(
492
569
  emitter,
493
570
  makeEvent("solomon:escalate", { ...eventBase, stage: "reviewer" }, {
494
- message: `Reviewer stalled — Solomon mediating`,
495
- detail: { repeats: repeatCounts.reviewer, reason: repeatState.reason }
571
+ message: `${logPrefix} — Solomon mediating`,
572
+ detail: { repeats: repeatCounts.reviewer || 1, reason: repeatState.reason || "first_rejection" }
496
573
  })
497
574
  );
498
575
 
@@ -501,14 +578,28 @@ async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState,
501
578
  conflict: {
502
579
  stage: "reviewer",
503
580
  task,
504
- iterationCount: repeatCounts.reviewer,
581
+ iterationCount: repeatCounts.reviewer || 1,
505
582
  maxIterations: config.session?.fail_fast_repeats ?? 2,
506
- stalledReason: repeatState.reason,
583
+ isFirstRejection: !repeatState.stalled,
584
+ isRepeat: repeatState.stalled,
585
+ stalledReason: repeatState.reason || "first_rejection",
507
586
  blockingIssues: review.blocking_issues,
508
- history: [{ agent: "reviewer", feedback: review.blocking_issues.map(x => x.description).join("; ") }]
587
+ issueCategories: categorizeIssues(review.blocking_issues),
588
+ history: [
589
+ ...buildReviewHistory(session),
590
+ { agent: "reviewer", feedback: review.blocking_issues.map(x => x.description).join("; ") }
591
+ ]
509
592
  }
510
593
  });
511
594
 
595
+ if (solomonResult.action === "approve") {
596
+ logger.info("Solomon overrode reviewer — approving code");
597
+ emitProgress(emitter, makeEvent("solomon:override", { ...eventBase, stage: "solomon" }, {
598
+ message: "Solomon overrode reviewer rejection — code approved",
599
+ detail: { ruling: solomonResult.ruling?.result?.ruling }
600
+ }));
601
+ return { review: { ...review, approved: true, solomon_override: true }, solomonApproved: true };
602
+ }
512
603
  if (solomonResult.action === "pause") {
513
604
  await markSessionStatus(session, "stalled");
514
605
  return { review, stalled: true, stalledResult: { paused: true, sessionId: session.id, question: solomonResult.question, context: "reviewer_stalled" } };
@@ -526,7 +617,7 @@ async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState,
526
617
  }
527
618
 
528
619
  // Fallback
529
- const message = `Manual intervention required: reviewer issues repeated ${repeatCounts.reviewer} times.`;
620
+ const message = `Manual intervention required: reviewer issues repeated ${repeatCounts.reviewer || 1} times.`;
530
621
  await markSessionStatus(session, "stalled");
531
622
  emitProgress(
532
623
  emitter,
@@ -542,10 +633,33 @@ async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState,
542
633
  async function handleReviewerRejection({ review, repeatDetector, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary }) {
543
634
  repeatDetector.addIteration([], review.blocking_issues);
544
635
  const repeatState = repeatDetector.isStalled();
545
- if (!repeatState.stalled) return null;
546
636
 
637
+ const solomonEnabled = Boolean(config.pipeline?.solomon?.enabled);
638
+
639
+ // When Solomon is disabled, only act on stalls (legacy behavior)
640
+ if (!solomonEnabled) {
641
+ if (!repeatState.stalled) return null;
642
+ const repeatCounts = repeatDetector.getRepeatCounts();
643
+ return handleReviewerStalledSolomon({
644
+ review, repeatCounts, repeatState, config, logger, emitter,
645
+ eventBase, session, iteration, task, askQuestion,
646
+ budgetSummary, repeatDetector
647
+ });
648
+ }
649
+
650
+ // Solomon evaluates EVERY rejection
547
651
  const repeatCounts = repeatDetector.getRepeatCounts();
548
- return handleReviewerStalledSolomon({ review, repeatCounts, repeatState, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary, repeatDetector });
652
+ logger.info(`Reviewer rejected Solomon evaluating ${review.blocking_issues.length} blocking issue(s)`);
653
+ emitProgress(emitter, makeEvent("solomon:evaluate", { ...eventBase, stage: "solomon" }, {
654
+ message: `Solomon evaluating reviewer rejection`,
655
+ detail: { blockingCount: review.blocking_issues.length, isRepeat: repeatState.stalled }
656
+ }));
657
+
658
+ return handleReviewerStalledSolomon({
659
+ review, repeatCounts, repeatState, config, logger, emitter,
660
+ eventBase, session, iteration, task, askQuestion,
661
+ budgetSummary, repeatDetector
662
+ });
549
663
  }
550
664
 
551
665
  async function fetchReviewDiff(session, logger) {
@@ -54,10 +54,20 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
54
54
  }
55
55
 
56
56
  const r = ruling.result?.ruling;
57
- if (r === "approve" || r === "approve_with_conditions") {
57
+ if (r === "approve") {
58
+ return { action: "approve", conditions: [], ruling };
59
+ }
60
+ if (r === "approve_with_conditions") {
58
61
  return { action: "continue", conditions: ruling.result?.conditions || [], ruling };
59
62
  }
60
63
 
64
+ if (r === "escalate_human") {
65
+ return escalateToHuman({
66
+ askQuestion, session, emitter, eventBase, stage, iteration,
67
+ conflict: { ...conflict, solomonReason: ruling.result?.escalate_reason || "Solomon escalated to human" }
68
+ });
69
+ }
70
+
61
71
  if (r === "create_subtask") {
62
72
  return { action: "subtask", subtask: ruling.result?.subtask, ruling };
63
73
  }
@@ -33,6 +33,7 @@ import { runTriageStage, runResearcherStage, runArchitectStage, runPlannerStage,
33
33
  import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runSonarCloudStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
34
34
  import { runTesterStage, runSecurityStage, runImpeccableStage } from "./orchestrator/post-loop-stages.js";
35
35
  import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js";
36
+ import { detectTestFramework } from "./utils/project-detect.js";
36
37
 
37
38
 
38
39
  // --- Extracted helper functions (pure refactoring, zero behavior change) ---
@@ -797,6 +798,26 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
797
798
  await handlePgDecomposition({ triageResult, pgTaskId, pgProject, config, askQuestion, emitter, eventBase, session, stageResults, logger });
798
799
 
799
800
  applyFlagOverrides(pipelineFlags, flags);
801
+
802
+ // --- Auto-detect TDD applicability when methodology not explicitly set ---
803
+ if (!flags.methodology) {
804
+ const projectDir = config.projectDir || process.cwd();
805
+ const detection = await detectTestFramework(projectDir);
806
+ if (!detection.hasTests) {
807
+ config = { ...config, development: { ...config.development, methodology: "standard", require_test_changes: false } };
808
+ logger.info("No test framework detected — using standard methodology");
809
+ } else {
810
+ config = { ...config, development: { ...config.development, methodology: "tdd", require_test_changes: true } };
811
+ logger.info(`Test framework detected (${detection.framework}) — using TDD methodology`);
812
+ }
813
+ emitProgress(emitter, makeEvent("tdd:auto-detect", { ...eventBase, stage: "pre-loop" }, {
814
+ message: detection.hasTests
815
+ ? `TDD auto-detected: ${detection.framework}`
816
+ : "TDD skipped: no test framework found",
817
+ detail: detection
818
+ }));
819
+ }
820
+
800
821
  const updatedConfig = resolvePipelinePolicies({ flags, config, stageResults, emitter, eventBase, session, pipelineFlags });
801
822
 
802
823
  // --- Researcher → Planner ---
@@ -900,7 +921,9 @@ async function runQualityGateStages({ config, logger, emitter, eventBase, sessio
900
921
  if (tddResult.action === "pause") return { action: "return", result: tddResult.result };
901
922
  if (tddResult.action === "continue") return { action: "continue" };
902
923
 
903
- if (config.sonarqube.enabled) {
924
+ const skipSonarForTaskType = new Set(["infra", "doc"]);
925
+ const effectiveTaskType = session.resolved_policies?.taskType || null;
926
+ if (config.sonarqube.enabled && !skipSonarForTaskType.has(effectiveTaskType)) {
904
927
  const sonarResult = await runSonarStage({
905
928
  config, logger, emitter, eventBase, session, trackBudget, iteration: i,
906
929
  repeatDetector, budgetSummary, sonarState, askQuestion, task
@@ -1110,8 +1133,18 @@ async function runSingleIteration(ctx) {
1110
1133
  if (approvedResult.action === "return" || approvedResult.action === "continue") return approvedResult;
1111
1134
  }
1112
1135
 
1113
- const retryResult = await handleReviewerRetryAndSolomon({ config, session, emitter, eventBase, logger, review, task, i, askQuestion });
1114
- if (retryResult.action === "return") return retryResult;
1136
+ // Solomon already evaluated the rejection in runReviewerStage handleReviewerRejection
1137
+ // Only use retry counter as fallback if Solomon is disabled
1138
+ if (!config.pipeline?.solomon?.enabled) {
1139
+ const retryResult = await handleReviewerRetryAndSolomon({ config, session, emitter, eventBase, logger, review, task, i, askQuestion });
1140
+ if (retryResult.action === "return") return retryResult;
1141
+ } else {
1142
+ // Solomon is enabled — feed back the blocking issues for the next coder iteration
1143
+ session.last_reviewer_feedback = review.blocking_issues
1144
+ .map((x) => `${x.id || "ISSUE"}: ${x.description || "Missing description"}`)
1145
+ .join("\n");
1146
+ await saveSession(session);
1147
+ }
1115
1148
 
1116
1149
  return { action: "next" };
1117
1150
  }
@@ -19,7 +19,13 @@ function isSourceFile(file, extensions = []) {
19
19
  return extensions.some((ext) => file.endsWith(ext));
20
20
  }
21
21
 
22
- export function evaluateTddPolicy(diff, developmentConfig = {}, untrackedFiles = []) {
22
+ const SKIP_TDD_TASK_TYPES = new Set(["doc", "infra"]);
23
+
24
+ export function evaluateTddPolicy(diff, developmentConfig = {}, untrackedFiles = [], taskType = null) {
25
+ if (taskType && SKIP_TDD_TASK_TYPES.has(taskType)) {
26
+ return { ok: true, reason: "tdd_not_applicable_for_task_type", sourceFiles: [], testFiles: [] };
27
+ }
28
+
23
29
  const requireTestChanges = developmentConfig.require_test_changes !== false;
24
30
  const patterns = developmentConfig.test_file_patterns || ["/tests/", "/__tests__/", ".test.", ".spec."];
25
31
  const extensions =
@@ -54,6 +54,7 @@ export class SonarRole extends BaseRole {
54
54
  result: {
55
55
  projectKey: scan.projectKey,
56
56
  gateStatus: gate.status,
57
+ conditions: gate.conditions || [],
57
58
  issues,
58
59
  openIssuesTotal: openIssues.total || 0,
59
60
  issuesSummary,
package/src/sonar/api.js CHANGED
@@ -72,9 +72,9 @@ export async function getQualityGateStatus(config, projectKey = null) {
72
72
 
73
73
  try {
74
74
  const parsed = JSON.parse(body);
75
- return { ok: true, status: parsed.projectStatus?.status || "ERROR", raw: parsed };
75
+ return { ok: true, status: parsed.projectStatus?.status || "ERROR", conditions: parsed.projectStatus?.conditions || [], raw: parsed };
76
76
  } catch {
77
- return { ok: false, status: "ERROR", raw: body };
77
+ return { ok: false, status: "ERROR", conditions: [], raw: body };
78
78
  }
79
79
  }
80
80
 
@@ -1,4 +1,6 @@
1
1
  import fs from "node:fs";
2
+ import fsPromises from "node:fs/promises";
3
+ import path from "node:path";
2
4
  import { runCommand } from "../utils/process.js";
3
5
  import { sonarUp } from "./manager.js";
4
6
  import { resolveSonarProjectKey } from "./project-key.js";
@@ -151,6 +153,33 @@ async function resolveSonarToken(config, apiHost) {
151
153
  return null;
152
154
  }
153
155
 
156
+ export async function ensureSonarProjectProperties(cwd = process.cwd()) {
157
+ const propsPath = path.join(cwd, "sonar-project.properties");
158
+ try {
159
+ await fsPromises.access(propsPath);
160
+ return; // already exists
161
+ } catch {
162
+ // Auto-generate based on project structure
163
+ let pkg = {};
164
+ try {
165
+ const raw = await fsPromises.readFile(path.join(cwd, "package.json"), "utf8");
166
+ pkg = JSON.parse(raw);
167
+ } catch {
168
+ // no package.json or invalid JSON — use defaults
169
+ }
170
+ const projectKey = (pkg.name || path.basename(cwd)).replace(/[^a-zA-Z0-9_.-]/g, "_");
171
+ const props = [
172
+ `sonar.projectKey=${projectKey}`,
173
+ `sonar.projectName=${pkg.name || path.basename(cwd)}`,
174
+ `sonar.sources=src`,
175
+ `sonar.tests=tests`,
176
+ `sonar.javascript.lcov.reportPaths=coverage/lcov.info`,
177
+ `sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**`,
178
+ ].join("\n");
179
+ await fsPromises.writeFile(propsPath, props + "\n", "utf8");
180
+ }
181
+ }
182
+
154
183
  export async function runSonarScan(config, projectKey = null) {
155
184
  let effectiveProjectKey;
156
185
  try {
@@ -184,6 +213,7 @@ export async function runSonarScan(config, projectKey = null) {
184
213
  exitCode: start.exitCode
185
214
  };
186
215
  }
216
+ await ensureSonarProjectProperties();
187
217
  const token = await resolveSonarToken(config, apiHost);
188
218
  if (!token) {
189
219
  return {
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const TEST_FRAMEWORKS = ["vitest", "jest", "@jest/core", "mocha", "ava", "tap", "playwright", "@playwright/test"];
5
+
6
+ const CONFIG_FILES = [
7
+ "vitest.config.js", "vitest.config.ts",
8
+ "jest.config.js", "jest.config.ts", "jest.config.mjs",
9
+ ".mocharc.yml", ".mocharc.json",
10
+ "playwright.config.js", "playwright.config.ts"
11
+ ];
12
+
13
+ function frameworkFromConfig(filename) {
14
+ if (filename.startsWith("vitest")) return "vitest";
15
+ if (filename.startsWith("jest")) return "jest";
16
+ if (filename.startsWith(".mocha")) return "mocha";
17
+ return "playwright";
18
+ }
19
+
20
+ /**
21
+ * Detect if the project has a test framework configured.
22
+ * Checks: package.json devDependencies/dependencies for known test frameworks.
23
+ * Also checks for config files (vitest.config, jest.config, .mocharc, playwright.config).
24
+ * @param {string} cwd - Project root
25
+ * @returns {Promise<{hasTests: boolean, framework: string|null}>}
26
+ */
27
+ export async function detectTestFramework(cwd = process.cwd()) {
28
+ try {
29
+ const pkgRaw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
30
+ const pkg = JSON.parse(pkgRaw);
31
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
32
+
33
+ for (const fw of TEST_FRAMEWORKS) {
34
+ if (allDeps[fw]) {
35
+ return { hasTests: true, framework: fw };
36
+ }
37
+ }
38
+ } catch { /* no package.json or parse error */ }
39
+
40
+ for (const cf of CONFIG_FILES) {
41
+ try {
42
+ await fs.access(path.join(cwd, cf));
43
+ return { hasTests: true, framework: frameworkFromConfig(cf) };
44
+ } catch { /* not found */ }
45
+ }
46
+
47
+ return { hasTests: false, framework: null };
48
+ }
49
+
50
+ /**
51
+ * Detect if SonarQube is configured for this project.
52
+ * @param {string} cwd - Project root
53
+ * @returns {Promise<{configured: boolean}>}
54
+ */
55
+ export async function detectSonarConfig(cwd = process.cwd()) {
56
+ try {
57
+ await fs.access(path.join(cwd, "sonar-project.properties"));
58
+ return { configured: true };
59
+ } catch {
60
+ return { configured: false };
61
+ }
62
+ }
@@ -1,11 +1,11 @@
1
- # Solomon Role (Conflict Resolver & Arbiter)
1
+ # Solomon Role (Pipeline Boss & Arbiter)
2
2
 
3
- You are **Solomon**, the supreme arbiter in a multi-role AI pipeline. You are activated when agents cannot reach agreement after their iteration limit. Your decisions are final within your rules.
3
+ You are **Solomon**, the supreme decision-maker in a multi-role AI pipeline. You evaluate **EVERY** reviewer rejection not just stalls. You decide whether the coder should fix, whether the reviewer's issues should be overridden, or whether to escalate to a human. Your decisions are final within your rules.
4
4
 
5
5
  ## When activated
6
6
 
7
+ - **Every reviewer rejection** (first rejection AND repeats)
7
8
  - Coder ↔ Sonar loop exhausted (default: 3 iterations)
8
- - Coder ↔ Reviewer loop exhausted (default: 3 iterations via PR comments)
9
9
  - Coder ↔ Tester loop exhausted (default: 1 iteration)
10
10
  - Coder ↔ Security loop exhausted (default: 1 iteration)
11
11
  - Any two roles produce contradictory outputs
@@ -56,16 +56,32 @@ For each blocking issue raised by any agent, classify it as:
56
56
  | Pure style issue | NO | Never blocks |
57
57
  | Architecture change not in scope | ESCALATE | Human decision required |
58
58
 
59
+ ## Decision framework
60
+
61
+ Use the `isFirstRejection`, `isRepeat`, and `issueCategories` fields from the conflict to decide:
62
+
63
+ | Scenario | Decision |
64
+ |----------|----------|
65
+ | Issues are security/critical (any count) | **approve_with_conditions** — ALWAYS send back to fix |
66
+ | Issues are ALL style/naming/cosmetic | **approve** — override the reviewer |
67
+ | Issues are correctness but minor (first rejection) | **approve_with_conditions** — give ONE retry with specific instructions |
68
+ | Issues are a repeat of the same thing the coder already failed to fix | **escalate_human** — the coder cannot fix it |
69
+ | Reviewer is clearly wrong or issues are out of scope | **approve** — override the reviewer |
70
+ | Ambiguous requirements or architecture decisions | **escalate_human** — human must decide |
71
+
72
+ Be **concise and decisive**. Do not hedge. Pick one action and commit.
73
+
59
74
  ## Decision options
60
75
 
61
- 1. **approve** — All pending issues are style/false positives. Code passes to next pipeline stage.
62
- 2. **approve_with_conditions** — Important (not critical) issues exist. Give the Coder exact, actionable instructions for one more attempt. Not generic feedback — specific changes with file and line references.
76
+ 1. **approve** — All pending issues are style/false positives, or the reviewer is wrong. Code passes to next pipeline stage. Solomon overrides the reviewer.
77
+ 2. **approve_with_conditions** — Fixable issues exist. Give the Coder exact, actionable instructions for one more attempt. Not generic feedback — specific changes with file and line references.
63
78
  3. **escalate_human** — When you cannot decide:
64
79
  - Critical issues that resist multiple fix attempts
65
80
  - Ambiguous or conflicting requirements
66
81
  - Architecture decisions beyond task scope
67
82
  - Business logic decisions
68
83
  - Scope creep (task is larger than originally estimated)
84
+ - Same issue repeated across iterations — the coder cannot fix it
69
85
  4. **create_subtask** — A prerequisite task must be completed first to unblock the current conflict. The pipeline will:
70
86
  - Pause the current task
71
87
  - Execute the subtask through the full pipeline