karajan-code 1.10.0 → 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.
@@ -93,21 +93,16 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
93
93
  }
94
94
  }
95
95
 
96
- // No fallback or fallback also failed — pause
97
- const question = `Agent ${coderRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
98
- await pauseSession(session, {
99
- question,
100
- context: { iteration, stage: "coder", reason: "rate_limit", agent: coderRole.provider, detail: rateLimitCheck.message }
101
- });
102
- emitProgress(
103
- emitter,
104
- makeEvent("coder:rate_limit", { ...eventBase, stage: "coder" }, {
105
- status: "paused",
106
- message: question,
107
- detail: { agent: coderRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
108
- })
109
- );
110
- return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
96
+ // No fallback or fallback also failed — enter standby
97
+ return {
98
+ action: "standby",
99
+ standbyInfo: {
100
+ agent: coderRole.provider,
101
+ cooldownMs: rateLimitCheck.cooldownMs,
102
+ cooldownUntil: rateLimitCheck.cooldownUntil,
103
+ message: rateLimitCheck.message
104
+ }
105
+ };
111
106
  }
112
107
 
113
108
  await markSessionStatus(session, "failed");
@@ -167,20 +162,16 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
167
162
  });
168
163
 
169
164
  if (rateLimitCheck.isRateLimit) {
170
- const question = `Agent ${refactorerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
171
- await pauseSession(session, {
172
- question,
173
- context: { iteration, stage: "refactorer", reason: "rate_limit", agent: refactorerRole.provider, detail: rateLimitCheck.message }
174
- });
175
- emitProgress(
176
- emitter,
177
- makeEvent("refactorer:rate_limit", { ...eventBase, stage: "refactorer" }, {
178
- status: "paused",
179
- message: question,
180
- detail: { agent: refactorerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
181
- })
182
- );
183
- return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
165
+ // Enter standby instead of pausing
166
+ return {
167
+ action: "standby",
168
+ standbyInfo: {
169
+ agent: refactorerRole.provider,
170
+ cooldownMs: rateLimitCheck.cooldownMs,
171
+ cooldownUntil: rateLimitCheck.cooldownUntil,
172
+ message: rateLimitCheck.message
173
+ }
174
+ };
184
175
  }
185
176
 
186
177
  await markSessionStatus(session, "failed");
@@ -451,20 +442,16 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
451
442
  });
452
443
 
453
444
  if (rateLimitCheck.isRateLimit) {
454
- const question = `Reviewer ${reviewerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
455
- await pauseSession(session, {
456
- question,
457
- context: { iteration, stage: "reviewer", reason: "rate_limit", agent: reviewerRole.provider, detail: rateLimitCheck.message }
458
- });
459
- emitProgress(
460
- emitter,
461
- makeEvent("reviewer:rate_limit", { ...eventBase, stage: "reviewer" }, {
462
- status: "paused",
463
- message: question,
464
- detail: { agent: reviewerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
465
- })
466
- );
467
- return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
445
+ // Enter standby instead of pausing
446
+ return {
447
+ action: "standby",
448
+ standbyInfo: {
449
+ agent: reviewerRole.provider,
450
+ cooldownMs: rateLimitCheck.cooldownMs,
451
+ cooldownUntil: rateLimitCheck.cooldownUntil,
452
+ message: rateLimitCheck.message
453
+ }
454
+ };
468
455
  }
469
456
 
470
457
  await markSessionStatus(session, "failed");
@@ -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 };
@@ -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,
@@ -157,6 +160,32 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
157
160
 
158
161
  eventBase.sessionId = session.id;
159
162
 
163
+ // --- Planning Game: mark card as In Progress ---
164
+ let pgCard = null;
165
+ if (pgTaskId && pgProject && config.planning_game?.enabled !== false) {
166
+ try {
167
+ const { fetchCard, updateCard } = await import("./planning-game/client.js");
168
+ pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
169
+ if (pgCard && pgCard.status !== "In Progress") {
170
+ await updateCard({
171
+ projectId: pgProject,
172
+ cardId: pgTaskId,
173
+ firebaseId: pgCard.firebaseId,
174
+ updates: {
175
+ status: "In Progress",
176
+ startDate: new Date().toISOString(),
177
+ developer: "dev_016",
178
+ codeveloper: config.planning_game?.codeveloper || null
179
+ }
180
+ });
181
+ logger.info(`Planning Game: ${pgTaskId} → In Progress`);
182
+ }
183
+ } catch (err) {
184
+ logger.warn(`Planning Game: could not update ${pgTaskId}: ${err.message}`);
185
+ }
186
+ }
187
+ session.pg_card = pgCard || null;
188
+
160
189
  emitProgress(
161
190
  emitter,
162
191
  makeEvent("session:start", eventBase, {
@@ -380,6 +409,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
380
409
  if (coderResult?.action === "pause") {
381
410
  return coderResult.result;
382
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
+ }
383
432
 
384
433
  // --- Refactorer ---
385
434
  if (refactorerEnabled) {
@@ -387,6 +436,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
387
436
  if (refResult?.action === "pause") {
388
437
  return refResult.result;
389
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
+ }
390
459
  }
391
460
 
392
461
  // --- TDD Policy ---
@@ -432,6 +501,26 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
432
501
  if (reviewerResult.action === "pause") {
433
502
  return reviewerResult.result;
434
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
+ }
435
524
  review = reviewerResult.review;
436
525
  if (reviewerResult.stalled) {
437
526
  return reviewerResult.stalledResult;
@@ -448,6 +537,49 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
448
537
  })
449
538
  );
450
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
+
451
583
  if (review.approved) {
452
584
  session.reviewer_retry_count = 0;
453
585
 
@@ -493,6 +625,30 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
493
625
  }
494
626
  session.budget = budgetSummary();
495
627
  await markSessionStatus(session, "approved");
628
+
629
+ // --- Planning Game: mark card as To Validate ---
630
+ if (pgCard && pgProject) {
631
+ try {
632
+ const { updateCard } = await import("./planning-game/client.js");
633
+ const { buildCompletionUpdates } = await import("./planning-game/adapter.js");
634
+ const pgUpdates = buildCompletionUpdates({
635
+ approved: true,
636
+ commits: gitResult?.commits || [],
637
+ startDate: session.pg_card?.startDate || session.created_at,
638
+ codeveloper: config.planning_game?.codeveloper || null
639
+ });
640
+ await updateCard({
641
+ projectId: pgProject,
642
+ cardId: session.pg_task_id,
643
+ firebaseId: pgCard.firebaseId,
644
+ updates: pgUpdates
645
+ });
646
+ logger.info(`Planning Game: ${session.pg_task_id} → To Validate`);
647
+ } catch (err) {
648
+ logger.warn(`Planning Game: could not update ${session.pg_task_id} on completion: ${err.message}`);
649
+ }
650
+ }
651
+
496
652
  emitProgress(
497
653
  emitter,
498
654
  makeEvent("session:end", { ...eventBase, stage: "done" }, {
@@ -600,6 +756,7 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
600
756
  session.repeated_issue_count = 0;
601
757
  session.sonar_retry_count = 0;
602
758
  session.reviewer_retry_count = 0;
759
+ session.standby_retry_count = 0;
603
760
  session.tester_retry_count = 0;
604
761
  session.security_retry_count = 0;
605
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 };
@@ -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 = buildPrompt({ task, instructions: this.instructions });
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);
@@ -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;