karajan-code 1.10.1 → 1.11.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/bin/kj-tail +70 -0
- package/package.json +2 -1
- package/src/cli.js +3 -2
- package/src/commands/agents.js +45 -21
- package/src/config.js +37 -16
- package/src/mcp/preflight.js +28 -0
- package/src/mcp/server-handlers.js +106 -7
- package/src/mcp/tools.js +19 -1
- package/src/orchestrator/iteration-stages.js +30 -43
- package/src/orchestrator/solomon-rules.js +138 -0
- package/src/orchestrator/standby.js +70 -0
- package/src/orchestrator.js +107 -0
- package/src/prompts/triage.js +61 -0
- package/src/roles/triage-role.js +2 -26
- package/src/utils/display.js +21 -0
- package/src/utils/rate-limit-detector.js +65 -4
- package/src/utils/run-log.js +75 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solomon rules engine — detects anomalies during session execution.
|
|
3
|
+
* Each rule returns { triggered: boolean, severity: "warn"|"critical", message, detail }
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RULES = {
|
|
7
|
+
max_files_per_iteration: 10,
|
|
8
|
+
max_stale_iterations: 3,
|
|
9
|
+
no_new_dependencies_without_task: true,
|
|
10
|
+
scope_guard: true
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function evaluateRules(context, rulesConfig = {}) {
|
|
14
|
+
const rules = { ...DEFAULT_RULES, ...rulesConfig };
|
|
15
|
+
const alerts = [];
|
|
16
|
+
|
|
17
|
+
// Rule 1: Too many files modified
|
|
18
|
+
if (rules.max_files_per_iteration && context.filesChanged > rules.max_files_per_iteration) {
|
|
19
|
+
alerts.push({
|
|
20
|
+
rule: "max_files_per_iteration",
|
|
21
|
+
severity: "critical",
|
|
22
|
+
message: `Coder modified ${context.filesChanged} files (limit: ${rules.max_files_per_iteration}). Possible scope drift.`,
|
|
23
|
+
detail: { filesChanged: context.filesChanged, limit: rules.max_files_per_iteration }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Rule 2: Stale iterations (no progress)
|
|
28
|
+
if (rules.max_stale_iterations && context.staleIterations >= rules.max_stale_iterations) {
|
|
29
|
+
alerts.push({
|
|
30
|
+
rule: "max_stale_iterations",
|
|
31
|
+
severity: "critical",
|
|
32
|
+
message: `${context.staleIterations} iterations without progress. Same errors repeating.`,
|
|
33
|
+
detail: { staleIterations: context.staleIterations, limit: rules.max_stale_iterations }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Rule 3: New dependencies not in task
|
|
38
|
+
if (rules.no_new_dependencies_without_task && context.newDependencies?.length > 0) {
|
|
39
|
+
const depsNotInTask = context.newDependencies.filter(
|
|
40
|
+
dep => !context.task?.toLowerCase().includes(dep.toLowerCase())
|
|
41
|
+
);
|
|
42
|
+
if (depsNotInTask.length > 0) {
|
|
43
|
+
alerts.push({
|
|
44
|
+
rule: "no_new_dependencies_without_task",
|
|
45
|
+
severity: "warn",
|
|
46
|
+
message: `New dependencies added not mentioned in task: ${depsNotInTask.join(", ")}`,
|
|
47
|
+
detail: { dependencies: depsNotInTask }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Rule 4: Scope guard — files outside expected scope
|
|
53
|
+
if (rules.scope_guard && context.outOfScopeFiles?.length > 0) {
|
|
54
|
+
alerts.push({
|
|
55
|
+
rule: "scope_guard",
|
|
56
|
+
severity: "warn",
|
|
57
|
+
message: `Files modified outside expected scope: ${context.outOfScopeFiles.join(", ")}`,
|
|
58
|
+
detail: { files: context.outOfScopeFiles }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
alerts,
|
|
64
|
+
hasCritical: alerts.some(a => a.severity === "critical"),
|
|
65
|
+
hasWarnings: alerts.some(a => a.severity === "warn")
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build context for rules evaluation from git diff and session state.
|
|
71
|
+
*/
|
|
72
|
+
export async function buildRulesContext({ session, task, iteration }) {
|
|
73
|
+
const context = {
|
|
74
|
+
task,
|
|
75
|
+
iteration,
|
|
76
|
+
filesChanged: 0,
|
|
77
|
+
staleIterations: 0,
|
|
78
|
+
newDependencies: [],
|
|
79
|
+
outOfScopeFiles: []
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Count files changed via git
|
|
83
|
+
try {
|
|
84
|
+
const { execaCommand } = await import("execa");
|
|
85
|
+
const baseRef = session.session_start_sha || "HEAD~1";
|
|
86
|
+
|
|
87
|
+
// Files changed
|
|
88
|
+
const diffResult = await execaCommand(`git diff --name-only ${baseRef}`, { reject: false });
|
|
89
|
+
if (diffResult.stdout) {
|
|
90
|
+
const files = diffResult.stdout.split("\n").filter(Boolean);
|
|
91
|
+
context.filesChanged = files.length;
|
|
92
|
+
|
|
93
|
+
// Detect scope: config files, CI/CD, etc. that are often out of scope
|
|
94
|
+
const scopePatterns = [".github/", ".gitlab-ci", "docker-compose", ".env", "firebase.json", "firestore.rules"];
|
|
95
|
+
context.outOfScopeFiles = files.filter(f =>
|
|
96
|
+
scopePatterns.some(pattern => f.includes(pattern))
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Detect new dependencies
|
|
100
|
+
if (files.includes("package.json")) {
|
|
101
|
+
try {
|
|
102
|
+
const pkgDiff = await execaCommand(`git diff ${baseRef} -- package.json`, { reject: false });
|
|
103
|
+
const addedDeps = (pkgDiff.stdout || "").split("\n")
|
|
104
|
+
.filter(line => line.startsWith("+") && line.includes('"') && !line.startsWith("+++"))
|
|
105
|
+
.map(line => {
|
|
106
|
+
const match = line.match(/"([^"]+)":\s*"/);
|
|
107
|
+
return match ? match[1] : null;
|
|
108
|
+
})
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.filter(name => !["name", "version", "description", "main", "scripts", "type", "license", "author"].includes(name));
|
|
111
|
+
context.newDependencies = addedDeps;
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch { /* git not available */ }
|
|
116
|
+
|
|
117
|
+
// Count stale iterations from session checkpoints
|
|
118
|
+
const checkpoints = session.checkpoints || [];
|
|
119
|
+
const recentCoderCheckpoints = checkpoints
|
|
120
|
+
.filter(cp => cp.stage === "coder" || cp.stage === "reviewer")
|
|
121
|
+
.slice(-6); // Last 3 iterations (coder+reviewer each)
|
|
122
|
+
|
|
123
|
+
// Simple heuristic: if last N reviewer checkpoints all have the same note/feedback, it's stale
|
|
124
|
+
if (recentCoderCheckpoints.length >= 4) {
|
|
125
|
+
const lastFeedbacks = checkpoints
|
|
126
|
+
.filter(cp => cp.stage === "reviewer")
|
|
127
|
+
.slice(-3)
|
|
128
|
+
.map(cp => cp.note || "");
|
|
129
|
+
const uniqueFeedbacks = new Set(lastFeedbacks);
|
|
130
|
+
if (uniqueFeedbacks.size === 1 && lastFeedbacks.length >= 2) {
|
|
131
|
+
context.staleIterations = lastFeedbacks.length;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return context;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { DEFAULT_RULES };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
4
|
+
const MAX_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
|
|
5
|
+
const MAX_STANDBY_RETRIES = 5;
|
|
6
|
+
const HEARTBEAT_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wait for a rate limit cooldown, emitting heartbeat events.
|
|
10
|
+
* Returns when the cooldown expires.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {number|null} options.cooldownMs - Milliseconds to wait (null = use default)
|
|
14
|
+
* @param {string|null} options.cooldownUntil - ISO timestamp when cooldown expires
|
|
15
|
+
* @param {string} options.agent - Agent that was rate-limited
|
|
16
|
+
* @param {number} options.retryCount - Current retry attempt (for backoff)
|
|
17
|
+
* @param {object} options.emitter - Event emitter
|
|
18
|
+
* @param {object} options.eventBase - Base event fields
|
|
19
|
+
* @param {object} options.logger
|
|
20
|
+
* @param {object} options.session
|
|
21
|
+
*/
|
|
22
|
+
export async function waitForCooldown({ cooldownMs, cooldownUntil, agent, retryCount, emitter, eventBase, logger, session }) {
|
|
23
|
+
// Calculate wait time with exponential backoff for retries without known cooldown
|
|
24
|
+
let waitMs = cooldownMs || DEFAULT_COOLDOWN_MS;
|
|
25
|
+
if (!cooldownMs && retryCount > 0) {
|
|
26
|
+
waitMs = Math.min(DEFAULT_COOLDOWN_MS * Math.pow(2, retryCount), MAX_COOLDOWN_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const resumeAt = cooldownUntil || new Date(Date.now() + waitMs).toISOString();
|
|
30
|
+
|
|
31
|
+
logger.info(`Standby: waiting ${Math.round(waitMs / 1000)}s for ${agent} rate limit (retry ${retryCount + 1}/${MAX_STANDBY_RETRIES})`);
|
|
32
|
+
|
|
33
|
+
// Emit standby start event
|
|
34
|
+
emitProgress(emitter, makeEvent("coder:standby", { ...eventBase, stage: "standby" }, {
|
|
35
|
+
message: `Rate limited — standby until ${resumeAt} (attempt ${retryCount + 1}/${MAX_STANDBY_RETRIES})`,
|
|
36
|
+
detail: { agent, cooldownUntil: resumeAt, cooldownMs: waitMs, retryCount: retryCount + 1, maxRetries: MAX_STANDBY_RETRIES }
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Update session status
|
|
40
|
+
session.status = "standby";
|
|
41
|
+
|
|
42
|
+
// Wait with periodic heartbeats
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
const endTime = startTime + waitMs;
|
|
45
|
+
|
|
46
|
+
while (Date.now() < endTime) {
|
|
47
|
+
const remaining = endTime - Date.now();
|
|
48
|
+
const sleepTime = Math.min(HEARTBEAT_INTERVAL_MS, remaining);
|
|
49
|
+
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, sleepTime));
|
|
51
|
+
|
|
52
|
+
if (Date.now() < endTime) {
|
|
53
|
+
const remainingSec = Math.round((endTime - Date.now()) / 1000);
|
|
54
|
+
emitProgress(emitter, makeEvent("coder:standby_heartbeat", { ...eventBase, stage: "standby" }, {
|
|
55
|
+
message: `Standby: ${remainingSec}s remaining`,
|
|
56
|
+
detail: { agent, remainingMs: endTime - Date.now(), retryCount: retryCount + 1 }
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Emit resume event
|
|
62
|
+
emitProgress(emitter, makeEvent("coder:standby_resume", { ...eventBase, stage: "standby" }, {
|
|
63
|
+
message: `Cooldown expired — resuming with ${agent}`,
|
|
64
|
+
detail: { agent, retryCount: retryCount + 1 }
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
session.status = "running";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS, MAX_STANDBY_RETRIES, HEARTBEAT_INTERVAL_MS };
|
package/src/orchestrator.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createSession,
|
|
4
4
|
loadSession,
|
|
5
5
|
markSessionStatus,
|
|
6
|
+
pauseSession,
|
|
6
7
|
resumeSessionWithAnswer,
|
|
7
8
|
saveSession,
|
|
8
9
|
addCheckpoint
|
|
@@ -25,6 +26,7 @@ import { invokeSolomon } from "./orchestrator/solomon-escalation.js";
|
|
|
25
26
|
import { runTriageStage, runResearcherStage, runPlannerStage } from "./orchestrator/pre-loop-stages.js";
|
|
26
27
|
import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
|
|
27
28
|
import { runTesterStage, runSecurityStage } from "./orchestrator/post-loop-stages.js";
|
|
29
|
+
import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js";
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
|
|
@@ -146,6 +148,7 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
146
148
|
repeated_issue_count: 0,
|
|
147
149
|
sonar_retry_count: 0,
|
|
148
150
|
reviewer_retry_count: 0,
|
|
151
|
+
standby_retry_count: 0,
|
|
149
152
|
last_sonar_issue_signature: null,
|
|
150
153
|
sonar_repeat_count: 0,
|
|
151
154
|
last_reviewer_issue_signature: null,
|
|
@@ -406,6 +409,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
406
409
|
if (coderResult?.action === "pause") {
|
|
407
410
|
return coderResult.result;
|
|
408
411
|
}
|
|
412
|
+
if (coderResult?.action === "standby") {
|
|
413
|
+
const standbyRetries = session.standby_retry_count || 0;
|
|
414
|
+
if (standbyRetries >= MAX_STANDBY_RETRIES) {
|
|
415
|
+
await pauseSession(session, {
|
|
416
|
+
question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${coderResult.standbyInfo.agent}`,
|
|
417
|
+
context: { iteration: i, stage: "coder", reason: "standby_exhausted" }
|
|
418
|
+
});
|
|
419
|
+
emitProgress(emitter, makeEvent("coder:rate_limit", { ...eventBase, stage: "coder" }, {
|
|
420
|
+
status: "paused",
|
|
421
|
+
message: `Standby exhausted after ${standbyRetries} retries`,
|
|
422
|
+
detail: { agent: coderResult.standbyInfo.agent, sessionId: session.id }
|
|
423
|
+
}));
|
|
424
|
+
return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
|
|
425
|
+
}
|
|
426
|
+
session.standby_retry_count = standbyRetries + 1;
|
|
427
|
+
await saveSession(session);
|
|
428
|
+
await waitForCooldown({ ...coderResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
|
|
429
|
+
i -= 1; // Retry the same iteration
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
409
432
|
|
|
410
433
|
// --- Refactorer ---
|
|
411
434
|
if (refactorerEnabled) {
|
|
@@ -413,6 +436,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
413
436
|
if (refResult?.action === "pause") {
|
|
414
437
|
return refResult.result;
|
|
415
438
|
}
|
|
439
|
+
if (refResult?.action === "standby") {
|
|
440
|
+
const standbyRetries = session.standby_retry_count || 0;
|
|
441
|
+
if (standbyRetries >= MAX_STANDBY_RETRIES) {
|
|
442
|
+
await pauseSession(session, {
|
|
443
|
+
question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${refResult.standbyInfo.agent}`,
|
|
444
|
+
context: { iteration: i, stage: "refactorer", reason: "standby_exhausted" }
|
|
445
|
+
});
|
|
446
|
+
emitProgress(emitter, makeEvent("refactorer:rate_limit", { ...eventBase, stage: "refactorer" }, {
|
|
447
|
+
status: "paused",
|
|
448
|
+
message: `Standby exhausted after ${standbyRetries} retries`,
|
|
449
|
+
detail: { agent: refResult.standbyInfo.agent, sessionId: session.id }
|
|
450
|
+
}));
|
|
451
|
+
return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
|
|
452
|
+
}
|
|
453
|
+
session.standby_retry_count = standbyRetries + 1;
|
|
454
|
+
await saveSession(session);
|
|
455
|
+
await waitForCooldown({ ...refResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
|
|
456
|
+
i -= 1; // Retry the same iteration
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
416
459
|
}
|
|
417
460
|
|
|
418
461
|
// --- TDD Policy ---
|
|
@@ -458,6 +501,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
458
501
|
if (reviewerResult.action === "pause") {
|
|
459
502
|
return reviewerResult.result;
|
|
460
503
|
}
|
|
504
|
+
if (reviewerResult.action === "standby") {
|
|
505
|
+
const standbyRetries = session.standby_retry_count || 0;
|
|
506
|
+
if (standbyRetries >= MAX_STANDBY_RETRIES) {
|
|
507
|
+
await pauseSession(session, {
|
|
508
|
+
question: `Rate limit standby exhausted after ${standbyRetries} retries. Agent: ${reviewerResult.standbyInfo.agent}`,
|
|
509
|
+
context: { iteration: i, stage: "reviewer", reason: "standby_exhausted" }
|
|
510
|
+
});
|
|
511
|
+
emitProgress(emitter, makeEvent("reviewer:rate_limit", { ...eventBase, stage: "reviewer" }, {
|
|
512
|
+
status: "paused",
|
|
513
|
+
message: `Standby exhausted after ${standbyRetries} retries`,
|
|
514
|
+
detail: { agent: reviewerResult.standbyInfo.agent, sessionId: session.id }
|
|
515
|
+
}));
|
|
516
|
+
return { paused: true, sessionId: session.id, question: `Rate limit standby exhausted after ${standbyRetries} retries`, context: "standby_exhausted" };
|
|
517
|
+
}
|
|
518
|
+
session.standby_retry_count = standbyRetries + 1;
|
|
519
|
+
await saveSession(session);
|
|
520
|
+
await waitForCooldown({ ...reviewerResult.standbyInfo, retryCount: standbyRetries, emitter, eventBase, logger, session });
|
|
521
|
+
i -= 1; // Retry the same iteration
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
461
524
|
review = reviewerResult.review;
|
|
462
525
|
if (reviewerResult.stalled) {
|
|
463
526
|
return reviewerResult.stalledResult;
|
|
@@ -474,6 +537,49 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
474
537
|
})
|
|
475
538
|
);
|
|
476
539
|
|
|
540
|
+
// Reset standby counter after successful iteration
|
|
541
|
+
session.standby_retry_count = 0;
|
|
542
|
+
|
|
543
|
+
// --- Solomon supervisor: anomaly detection after each iteration ---
|
|
544
|
+
if (config.pipeline?.solomon?.enabled !== false) {
|
|
545
|
+
try {
|
|
546
|
+
const { evaluateRules, buildRulesContext } = await import("./orchestrator/solomon-rules.js");
|
|
547
|
+
const rulesContext = await buildRulesContext({ session, task, iteration: i });
|
|
548
|
+
const rulesResult = evaluateRules(rulesContext, config.solomon?.rules);
|
|
549
|
+
|
|
550
|
+
if (rulesResult.alerts.length > 0) {
|
|
551
|
+
for (const alert of rulesResult.alerts) {
|
|
552
|
+
emitProgress(emitter, makeEvent("solomon:alert", { ...eventBase, stage: "solomon" }, {
|
|
553
|
+
status: alert.severity === "critical" ? "fail" : "warn",
|
|
554
|
+
message: alert.message,
|
|
555
|
+
detail: alert.detail
|
|
556
|
+
}));
|
|
557
|
+
logger.warn(`Solomon alert [${alert.rule}]: ${alert.message}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (rulesResult.hasCritical && askQuestion) {
|
|
561
|
+
const alertSummary = rulesResult.alerts
|
|
562
|
+
.filter(a => a.severity === "critical")
|
|
563
|
+
.map(a => a.message)
|
|
564
|
+
.join("\n");
|
|
565
|
+
const answer = await askQuestion(
|
|
566
|
+
`Solomon detected critical issues:\n${alertSummary}\n\nShould I continue, pause, or revert?`,
|
|
567
|
+
{ iteration: i, stage: "solomon" }
|
|
568
|
+
);
|
|
569
|
+
if (!answer || answer.toLowerCase().includes("pause") || answer.toLowerCase().includes("stop")) {
|
|
570
|
+
await pauseSession(session, {
|
|
571
|
+
question: `Solomon supervisor paused: ${alertSummary}`,
|
|
572
|
+
context: { iteration: i, stage: "solomon", alerts: rulesResult.alerts }
|
|
573
|
+
});
|
|
574
|
+
return { paused: true, sessionId: session.id, reason: "solomon_alert" };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
logger.warn(`Solomon rules evaluation failed: ${err.message}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
477
583
|
if (review.approved) {
|
|
478
584
|
session.reviewer_retry_count = 0;
|
|
479
585
|
|
|
@@ -650,6 +756,7 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
|
|
|
650
756
|
session.repeated_issue_count = 0;
|
|
651
757
|
session.sonar_retry_count = 0;
|
|
652
758
|
session.reviewer_retry_count = 0;
|
|
759
|
+
session.standby_retry_count = 0;
|
|
653
760
|
session.tester_retry_count = 0;
|
|
654
761
|
session.security_retry_count = 0;
|
|
655
762
|
session.last_sonar_issue_signature = null;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const SUBAGENT_PREAMBLE = [
|
|
2
|
+
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
3
|
+
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
4
|
+
"Do NOT use any MCP tools. Focus only on task complexity triage."
|
|
5
|
+
].join(" ");
|
|
6
|
+
|
|
7
|
+
const ROLE_DESCRIPTIONS = [
|
|
8
|
+
{ role: "planner", description: "Generates an implementation plan before coding. Useful for complex multi-file tasks." },
|
|
9
|
+
{ role: "researcher", description: "Investigates the codebase for context before coding. Useful when understanding existing code is needed." },
|
|
10
|
+
{ role: "tester", description: "Runs dedicated testing pass after coding. Ensures tests exist and pass." },
|
|
11
|
+
{ role: "security", description: "Audits code for security vulnerabilities. Checks auth, input validation, injection risks." },
|
|
12
|
+
{ role: "refactorer", description: "Cleans up and refactors code after the main implementation." },
|
|
13
|
+
{ role: "reviewer", description: "Reviews the code diff for quality issues. Standard quality gate." }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function buildTriagePrompt({ task, instructions, availableRoles }) {
|
|
17
|
+
const sections = [SUBAGENT_PREAMBLE];
|
|
18
|
+
|
|
19
|
+
if (instructions) {
|
|
20
|
+
sections.push(instructions);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const roles = availableRoles || ROLE_DESCRIPTIONS;
|
|
24
|
+
|
|
25
|
+
sections.push(
|
|
26
|
+
"You are a task triage agent for Karajan Code, a multi-agent coding orchestrator.",
|
|
27
|
+
"Analyze the following task and determine which pipeline roles should be activated."
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
sections.push(
|
|
31
|
+
"## Available Roles",
|
|
32
|
+
roles.map((r) => `- **${r.role}**: ${r.description}`).join("\n")
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
sections.push(
|
|
36
|
+
"## Decision Guidelines",
|
|
37
|
+
[
|
|
38
|
+
"- **planner**: Enable for complex tasks (multi-file, architectural changes, data model changes). Disable for simple fixes.",
|
|
39
|
+
"- **researcher**: Enable when the task needs codebase context, API understanding, or investigation. Disable for standalone new files.",
|
|
40
|
+
"- **tester**: Enable for any task with logic, APIs, components, services. Disable ONLY for pure documentation, comments, or CSS-only changes.",
|
|
41
|
+
"- **security**: Enable for authentication, APIs, user input handling, data access, external integrations. Disable for UI-only or doc changes.",
|
|
42
|
+
"- **refactorer**: Enable only when explicitly requested or when the task is a refactoring task.",
|
|
43
|
+
"- **reviewer**: Enable for most tasks as a quality gate. Disable only for trivial, single-line changes.",
|
|
44
|
+
"",
|
|
45
|
+
"Note: coder is ALWAYS active — you don't need to decide on it."
|
|
46
|
+
].join("\n")
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
sections.push(
|
|
50
|
+
"Classify the task complexity, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
|
|
51
|
+
"Keep the reasoning short and practical.",
|
|
52
|
+
"Return a single valid JSON object and nothing else.",
|
|
53
|
+
'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
sections.push(`## Task\n${task}`);
|
|
57
|
+
|
|
58
|
+
return sections.join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { ROLE_DESCRIPTIONS };
|
package/src/roles/triage-role.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { BaseRole } from "./base-role.js";
|
|
2
2
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
3
|
-
|
|
4
|
-
const SUBAGENT_PREAMBLE = [
|
|
5
|
-
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
6
|
-
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
7
|
-
"Do NOT use any MCP tools. Focus only on task complexity triage."
|
|
8
|
-
].join(" ");
|
|
3
|
+
import { buildTriagePrompt } from "../prompts/triage.js";
|
|
9
4
|
|
|
10
5
|
const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
|
|
11
6
|
const VALID_ROLES = new Set(["planner", "researcher", "refactorer", "reviewer", "tester", "security"]);
|
|
@@ -18,25 +13,6 @@ function resolveProvider(config) {
|
|
|
18
13
|
);
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
function buildPrompt({ task, instructions }) {
|
|
22
|
-
const sections = [SUBAGENT_PREAMBLE];
|
|
23
|
-
|
|
24
|
-
if (instructions) {
|
|
25
|
-
sections.push(instructions);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
sections.push(
|
|
29
|
-
"Classify the task complexity, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
|
|
30
|
-
"Keep the reasoning short and practical.",
|
|
31
|
-
"Return a single valid JSON object and nothing else.",
|
|
32
|
-
'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security"],"reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}'
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
sections.push(`## Task\n${task}`);
|
|
36
|
-
|
|
37
|
-
return sections.join("\n\n");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
16
|
function parseTriageOutput(raw) {
|
|
41
17
|
const text = raw?.trim() || "";
|
|
42
18
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
@@ -72,7 +48,7 @@ export class TriageRole extends BaseRole {
|
|
|
72
48
|
const provider = resolveProvider(this.config);
|
|
73
49
|
const agent = this._createAgent(provider, this.config, this.logger);
|
|
74
50
|
|
|
75
|
-
const prompt =
|
|
51
|
+
const prompt = buildTriagePrompt({ task, instructions: this.instructions });
|
|
76
52
|
const runArgs = { prompt, role: "triage" };
|
|
77
53
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
78
54
|
const result = await agent.runTask(runArgs);
|
package/src/utils/display.js
CHANGED
|
@@ -41,6 +41,9 @@ const ICONS = {
|
|
|
41
41
|
"solomon:start": "\u2696\ufe0f",
|
|
42
42
|
"solomon:end": "\u2696\ufe0f",
|
|
43
43
|
"solomon:escalate": "\u26a0\ufe0f",
|
|
44
|
+
"coder:standby": "\u23f3",
|
|
45
|
+
"coder:standby_heartbeat": "\u23f3",
|
|
46
|
+
"coder:standby_resume": "\u25b6\ufe0f",
|
|
44
47
|
"budget:update": "\ud83d\udcb8",
|
|
45
48
|
"iteration:end": "\u23f1\ufe0f",
|
|
46
49
|
"session:end": "\ud83c\udfc1",
|
|
@@ -245,6 +248,24 @@ export function printEvent(event) {
|
|
|
245
248
|
break;
|
|
246
249
|
}
|
|
247
250
|
|
|
251
|
+
case "coder:standby": {
|
|
252
|
+
const until = event.detail?.cooldownUntil || "?";
|
|
253
|
+
const attempt = event.detail?.retryCount || "?";
|
|
254
|
+
const maxRetries = event.detail?.maxRetries || "?";
|
|
255
|
+
console.log(` \u251c\u2500 ${ANSI.yellow}${icon} Rate limited \u2014 standby until ${until} (attempt ${attempt}/${maxRetries})${ANSI.reset}`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case "coder:standby_heartbeat": {
|
|
260
|
+
const remaining = event.detail?.remainingMs !== undefined ? Math.round(event.detail.remainingMs / 1000) : "?";
|
|
261
|
+
console.log(` \u251c\u2500 ${ANSI.yellow}${icon} Standby: ${remaining}s remaining${ANSI.reset}`);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case "coder:standby_resume":
|
|
266
|
+
console.log(` \u251c\u2500 ${ANSI.green}${icon} Cooldown expired \u2014 resuming with ${event.detail?.coder || event.detail?.provider || "?"}${ANSI.reset}`);
|
|
267
|
+
break;
|
|
268
|
+
|
|
248
269
|
case "iteration:end":
|
|
249
270
|
console.log(` \u2514\u2500 ${icon} Duration: ${formatElapsed(event.detail?.duration)} ${elapsed}`);
|
|
250
271
|
break;
|
|
@@ -1,9 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detects rate limit / usage cap messages from CLI agent output.
|
|
3
|
-
* Returns { isRateLimit, agent, message
|
|
4
|
-
* of which CLI triggered it (or "unknown").
|
|
3
|
+
* Returns { isRateLimit, agent, message, cooldownUntil, cooldownMs }
|
|
4
|
+
* where agent is the best guess of which CLI triggered it (or "unknown").
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Extracts cooldown timing from a rate limit message string.
|
|
9
|
+
* Returns { cooldownUntil, cooldownMs } where cooldownUntil is an ISO string
|
|
10
|
+
* and cooldownMs is milliseconds to wait, or both null if not found.
|
|
11
|
+
*/
|
|
12
|
+
export function parseCooldown(message) {
|
|
13
|
+
if (!message || typeof message !== "string") {
|
|
14
|
+
return { cooldownUntil: null, cooldownMs: null };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 1. ISO timestamp: "try again after 2026-03-07T15:30:00Z"
|
|
18
|
+
// Also: "resets at 2026-03-07T15:30:00Z"
|
|
19
|
+
const isoMatch = message.match(
|
|
20
|
+
/(?:after|at)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)/i
|
|
21
|
+
);
|
|
22
|
+
if (isoMatch) {
|
|
23
|
+
const target = new Date(isoMatch[1]);
|
|
24
|
+
if (!isNaN(target.getTime())) {
|
|
25
|
+
const ms = Math.max(0, target.getTime() - Date.now());
|
|
26
|
+
return { cooldownUntil: target.toISOString(), cooldownMs: ms };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 4. Claude specific: "resets at 2026-03-07 15:30 UTC" (space-separated date/time)
|
|
31
|
+
const resetMatch = message.match(
|
|
32
|
+
/resets?\s+at\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s*UTC/i
|
|
33
|
+
);
|
|
34
|
+
if (resetMatch) {
|
|
35
|
+
const target = new Date(`${resetMatch[1]}T${resetMatch[2]}:00Z`);
|
|
36
|
+
if (!isNaN(target.getTime())) {
|
|
37
|
+
const ms = Math.max(0, target.getTime() - Date.now());
|
|
38
|
+
return { cooldownUntil: target.toISOString(), cooldownMs: ms };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Relative seconds: "retry after 120 seconds" / "retry in 120s" / "Retry-After: 120"
|
|
43
|
+
const secMatch = message.match(
|
|
44
|
+
/(?:retry[\s-]*after|retry\s+in|wait)\s*:?\s*(\d+)\s*(?:seconds?|secs?|s\b)/i
|
|
45
|
+
) || message.match(/Retry-After:\s*(\d+)/i);
|
|
46
|
+
if (secMatch) {
|
|
47
|
+
const seconds = parseInt(secMatch[1], 10);
|
|
48
|
+
const ms = seconds * 1000;
|
|
49
|
+
const target = new Date(Date.now() + ms);
|
|
50
|
+
return { cooldownUntil: target.toISOString(), cooldownMs: ms };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Relative minutes: "retry in 5 minutes" / "wait 5 min"
|
|
54
|
+
const minMatch = message.match(
|
|
55
|
+
/(?:retry\s+in|wait|after)\s+(\d+)\s*(?:minutes?|mins?)/i
|
|
56
|
+
);
|
|
57
|
+
if (minMatch) {
|
|
58
|
+
const minutes = parseInt(minMatch[1], 10);
|
|
59
|
+
const ms = minutes * 60 * 1000;
|
|
60
|
+
const target = new Date(Date.now() + ms);
|
|
61
|
+
return { cooldownUntil: target.toISOString(), cooldownMs: ms };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { cooldownUntil: null, cooldownMs: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
7
67
|
const RATE_LIMIT_PATTERNS = [
|
|
8
68
|
// Claude CLI
|
|
9
69
|
{ pattern: /usage limit/i, agent: "claude" },
|
|
@@ -34,10 +94,11 @@ export function detectRateLimit({ stderr = "", stdout = "" }) {
|
|
|
34
94
|
return {
|
|
35
95
|
isRateLimit: true,
|
|
36
96
|
agent,
|
|
37
|
-
message: matchedLine.trim()
|
|
97
|
+
message: matchedLine.trim(),
|
|
98
|
+
...parseCooldown(matchedLine)
|
|
38
99
|
};
|
|
39
100
|
}
|
|
40
101
|
}
|
|
41
102
|
|
|
42
|
-
return { isRateLimit: false, agent: "", message: "" };
|
|
103
|
+
return { isRateLimit: false, agent: "", message: "", cooldownUntil: null, cooldownMs: null };
|
|
43
104
|
}
|