ai-control-center 1.15.2

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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Discussion Threads — Asynchronous agent-to-agent communication.
3
+ *
4
+ * A "thread" is a structured conversation between agents about a specific topic:
5
+ * - Bug found by QA → thread for root cause discussion
6
+ * - Feature suggested → thread for feasibility discussion
7
+ * - Review blocked → thread for resolution
8
+ *
9
+ * Storage: .ai-workflow/discussion-threads/{threadId}.json
10
+ *
11
+ * Thread lifecycle: OPEN → RESOLVED | ESCALATED
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'fs';
15
+ import { resolve } from 'path';
16
+ import { randomUUID } from 'crypto';
17
+ import { getWorkflowDir, atomicWriteSync } from '../utils/pipeline.js';
18
+ import { logActivity } from '../utils/activity-log.js';
19
+ import { bus } from '../shared/event-bus.js';
20
+
21
+ const THREADS_DIR = () => resolve(getWorkflowDir(), 'discussion-threads');
22
+
23
+ function ensureDir() {
24
+ const dir = THREADS_DIR();
25
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
26
+ return dir;
27
+ }
28
+
29
+ /**
30
+ * Create a new discussion thread.
31
+ * @param {string|object} topicOrOptions - Topic string (V1 compat) or options object (V2)
32
+ * @param {string} [title] - Thread title (V1 compat)
33
+ * @param {object|string} [context] - Relevant context
34
+ * @param {string} [openedBy] - Agent who opened the thread (V1 compat)
35
+ * @returns {object|string} The created thread object (V2) or threadId (V1)
36
+ */
37
+ export function openThread(topicOrOptions, title, context, openedBy = 'pm') {
38
+ const dir = ensureDir();
39
+
40
+ // V2 API: openThread({ title, initiator, type, context, participants })
41
+ if (typeof topicOrOptions === 'object' && topicOrOptions !== null) {
42
+ const opts = topicOrOptions;
43
+ const threadId = `THREAD-${Date.now()}-${randomUUID().slice(0, 8)}`;
44
+ const thread = {
45
+ id: threadId,
46
+ title: opts.title,
47
+ type: opts.type || 'general',
48
+ topic: opts.type || 'general',
49
+ status: 'open',
50
+ initiator: opts.initiator || 'pm',
51
+ openedBy: opts.initiator || 'pm',
52
+ participants: [opts.initiator || 'pm', ...(opts.participants || [])],
53
+ createdAt: new Date().toISOString(),
54
+ updatedAt: new Date().toISOString(),
55
+ resolvedAt: null,
56
+ resolvedBy: null,
57
+ resolution: null,
58
+ messages: [
59
+ {
60
+ from: opts.initiator || 'pm',
61
+ agent: opts.initiator || 'pm',
62
+ timestamp: new Date().toISOString(),
63
+ content: opts.context || '',
64
+ message: opts.context || '',
65
+ type: 'context',
66
+ },
67
+ ],
68
+ };
69
+
70
+ atomicWriteSync(resolve(dir, `${threadId}.json`), JSON.stringify(thread, null, 2));
71
+ logActivity('THREAD', `Opened thread: ${opts.title} (${threadId})`, 'info');
72
+ bus.emit('thread_opened', { threadId, title: opts.title, type: opts.type, initiator: opts.initiator });
73
+
74
+ return thread;
75
+ }
76
+
77
+ // V1 API: openThread(topic, title, context, openedBy)
78
+ const topic = topicOrOptions;
79
+ const threadId = `${topic}_${Date.now()}`;
80
+ const thread = {
81
+ id: threadId,
82
+ topic,
83
+ title,
84
+ type: topic,
85
+ context,
86
+ openedBy,
87
+ initiator: openedBy,
88
+ status: 'open',
89
+ participants: [openedBy],
90
+ messages: [],
91
+ createdAt: new Date().toISOString(),
92
+ updatedAt: new Date().toISOString(),
93
+ resolvedAt: null,
94
+ resolvedBy: null,
95
+ resolution: null,
96
+ };
97
+
98
+ atomicWriteSync(resolve(dir, `${threadId}.json`), JSON.stringify(thread, null, 2));
99
+ bus.emitEvent('room:thread_opened', thread);
100
+
101
+ return threadId;
102
+ }
103
+
104
+ /**
105
+ * Post a message to an existing thread.
106
+ * Supports both V1 (threadId, agent, message, options) and V2 (threadId, { from, content, type }) API.
107
+ */
108
+ export function postToThread(threadId, agentOrOptions, message, options = {}) {
109
+ const dir = ensureDir();
110
+ const filePath = resolve(dir, `${threadId}.json`);
111
+
112
+ if (!existsSync(filePath)) {
113
+ throw new Error(`Thread not found: ${threadId}`);
114
+ }
115
+
116
+ const thread = JSON.parse(readFileSync(filePath, 'utf8'));
117
+
118
+ if (thread.status !== 'open') {
119
+ throw new Error(`Thread ${threadId} is ${thread.status}, cannot post`);
120
+ }
121
+
122
+ // V2 API: postToThread(threadId, { from, content, type })
123
+ let entry;
124
+ if (typeof agentOrOptions === 'object') {
125
+ entry = {
126
+ from: agentOrOptions.from,
127
+ agent: agentOrOptions.from,
128
+ timestamp: new Date().toISOString(),
129
+ content: agentOrOptions.content,
130
+ message: agentOrOptions.content,
131
+ type: agentOrOptions.type || 'message',
132
+ };
133
+ } else {
134
+ // V1 API: postToThread(threadId, agent, message, options)
135
+ entry = {
136
+ from: agentOrOptions,
137
+ agent: agentOrOptions,
138
+ timestamp: new Date().toISOString(),
139
+ content: message,
140
+ message,
141
+ type: options.type || 'text',
142
+ };
143
+ }
144
+
145
+ thread.messages.push(entry);
146
+ thread.updatedAt = new Date().toISOString();
147
+ const from = entry.from || entry.agent;
148
+ if (from && !thread.participants.includes(from)) {
149
+ thread.participants.push(from);
150
+ }
151
+
152
+ atomicWriteSync(filePath, JSON.stringify(thread, null, 2));
153
+ bus.emit('thread_message', { threadId, from, content: (entry.content || '').slice(0, 100) });
154
+
155
+ return thread;
156
+ }
157
+
158
+ /**
159
+ * Resolve a thread with a decision.
160
+ * Supports both V1 (threadId, resolution, resolvedBy) and V2 (threadId, { by, resolution }) API.
161
+ */
162
+ export function resolveThread(threadId, resolutionOrOpts, resolvedByV1 = 'pm') {
163
+ const dir = ensureDir();
164
+ const filePath = resolve(dir, `${threadId}.json`);
165
+ if (!existsSync(filePath)) return null;
166
+
167
+ const thread = JSON.parse(readFileSync(filePath, 'utf8'));
168
+
169
+ let by, resolution;
170
+ if (typeof resolutionOrOpts === 'object') {
171
+ by = resolutionOrOpts.by || 'pm';
172
+ resolution = resolutionOrOpts.resolution;
173
+ } else {
174
+ by = resolvedByV1;
175
+ resolution = resolutionOrOpts;
176
+ }
177
+
178
+ thread.status = 'resolved';
179
+ thread.resolvedAt = new Date().toISOString();
180
+ thread.resolvedBy = by;
181
+ thread.resolution = resolution;
182
+ thread.updatedAt = new Date().toISOString();
183
+
184
+ thread.messages.push({
185
+ from: by,
186
+ agent: by,
187
+ timestamp: new Date().toISOString(),
188
+ content: `RESOLVED: ${resolution}`,
189
+ message: `RESOLVED: ${resolution}`,
190
+ type: 'decision',
191
+ });
192
+
193
+ atomicWriteSync(filePath, JSON.stringify(thread, null, 2));
194
+ logActivity('THREAD', `Resolved thread ${threadId}: ${(resolution || '').slice(0, 80)}`, 'success');
195
+ bus.emit('thread_resolved', { threadId, by, resolution });
196
+
197
+ return thread;
198
+ }
199
+
200
+ /**
201
+ * Escalate a thread to CEO.
202
+ */
203
+ export function escalateThread(threadId, reasonOrOpts) {
204
+ const dir = ensureDir();
205
+ const filePath = resolve(dir, `${threadId}.json`);
206
+ if (!existsSync(filePath)) return null;
207
+
208
+ const thread = JSON.parse(readFileSync(filePath, 'utf8'));
209
+
210
+ let by, reason;
211
+ if (typeof reasonOrOpts === 'object') {
212
+ by = reasonOrOpts.by || 'PM';
213
+ reason = reasonOrOpts.reason;
214
+ } else {
215
+ by = 'PM';
216
+ reason = reasonOrOpts;
217
+ }
218
+
219
+ thread.status = 'escalated';
220
+ thread.updatedAt = new Date().toISOString();
221
+ thread.escalated = { reason, at: new Date().toISOString() };
222
+
223
+ thread.messages.push({
224
+ from: by,
225
+ agent: by,
226
+ timestamp: new Date().toISOString(),
227
+ content: `ESCALATED TO CEO: ${reason}`,
228
+ message: `ESCALATED TO CEO: ${reason}`,
229
+ type: 'escalation',
230
+ });
231
+
232
+ atomicWriteSync(filePath, JSON.stringify(thread, null, 2));
233
+ logActivity('THREAD', `Escalated thread ${threadId} to CEO: ${(reason || '').slice(0, 80)}`, 'warn');
234
+ bus.emit('thread_escalated', { threadId, by, reason });
235
+
236
+ return thread;
237
+ }
238
+
239
+ /**
240
+ * List threads (filterable).
241
+ */
242
+ export function listThreads({ status = null, type = null, limit = 20 } = {}) {
243
+ const dir = ensureDir();
244
+ const files = readdirSync(dir)
245
+ .filter(f => f.endsWith('.json'))
246
+ .sort()
247
+ .reverse();
248
+
249
+ const threads = files.slice(0, limit * 2).map(f => {
250
+ try { return JSON.parse(readFileSync(resolve(dir, f), 'utf8')); }
251
+ catch { return null; }
252
+ }).filter(Boolean);
253
+
254
+ let result = threads;
255
+ if (status) result = result.filter(t => t.status === status);
256
+ if (type) result = result.filter(t => t.type === type);
257
+
258
+ return result.slice(0, limit);
259
+ }
260
+
261
+ /**
262
+ * Get a single thread by ID.
263
+ */
264
+ export function getThread(threadId) {
265
+ const filePath = resolve(ensureDir(), `${threadId}.json`);
266
+ if (!existsSync(filePath)) return null;
267
+ try { return JSON.parse(readFileSync(filePath, 'utf8')); } catch { return null; }
268
+ }
269
+
270
+ /**
271
+ * Get all open threads.
272
+ */
273
+ export function getOpenThreads() {
274
+ return listThreads({ status: 'open' });
275
+ }
276
+
277
+ /**
278
+ * Get all threads (any status).
279
+ */
280
+ export function getAllThreads() {
281
+ return listThreads({ limit: 100 });
282
+ }
283
+
284
+ /**
285
+ * Get thread summary (for Telegram / room digest).
286
+ */
287
+ export function getThreadSummary(threadId) {
288
+ const thread = getThread(threadId);
289
+ if (!thread) return null;
290
+
291
+ const msgCount = thread.messages.length;
292
+ const agents = [...new Set(thread.messages.map(m => m.from || m.agent))];
293
+ const last = thread.messages[thread.messages.length - 1];
294
+
295
+ return {
296
+ id: threadId,
297
+ title: thread.title,
298
+ status: thread.status,
299
+ messages: msgCount,
300
+ agents: agents.join(', '),
301
+ lastPost: (last?.content || last?.message || '').slice(0, 100),
302
+ };
303
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { getConfig } from '../config.js';
3
+ import { ROOT_DIR } from '../shared/event-bus.js';
4
+ import { resolve } from 'path';
5
+
6
+ let _monitorInterval = null;
7
+ let _bus = null;
8
+
9
+ // Stage expected durations (ms) — used to detect slow vs stalled stages
10
+ const STAGE_EXPECTED_MS = {
11
+ spec: 2 * 60_000, // 2 min
12
+ arch: 3 * 60_000, // 3 min
13
+ impl: 10 * 60_000, // 10 min
14
+ review: 3 * 60_000, // 3 min
15
+ fix: 5 * 60_000, // 5 min
16
+ deploy: 2 * 60_000, // 2 min
17
+ default: 5 * 60_000,
18
+ };
19
+
20
+ export function startMonitor(bus, config = {}) {
21
+ _bus = bus;
22
+ const interval = config.checkInterval || 30_000;
23
+
24
+ stopMonitor(); // clear any existing
25
+ _monitorInterval = setInterval(() => {
26
+ try {
27
+ const status = getProviderStatus();
28
+ // Emit health status on each tick for interested parties
29
+ if (_bus) {
30
+ _bus.emitEvent('health_status', { providers: status, timestamp: new Date().toISOString() });
31
+ }
32
+ } catch { /* non-fatal */ }
33
+ }, interval);
34
+ }
35
+
36
+ export function stopMonitor() {
37
+ if (_monitorInterval) {
38
+ clearInterval(_monitorInterval);
39
+ _monitorInterval = null;
40
+ }
41
+ }
42
+
43
+ export function getProviderStatus() {
44
+ const result = { copilot: 'healthy', gemini: 'healthy', claude: 'healthy', ollama: 'healthy' };
45
+
46
+ // Read circuit-breaker state
47
+ try {
48
+ const cbFile = resolve(ROOT_DIR, '.ai-workflow/circuit-breakers.json');
49
+ if (existsSync(cbFile)) {
50
+ const cbs = JSON.parse(readFileSync(cbFile, 'utf8'));
51
+ for (const [provider, state] of Object.entries(cbs)) {
52
+ if (state.state === 'OPEN') result[provider] = 'down';
53
+ else if (state.state === 'HALF_OPEN') result[provider] = 'degraded';
54
+ }
55
+ }
56
+ } catch { /* non-fatal */ }
57
+
58
+ // Read model bans
59
+ try {
60
+ const bansFile = resolve(ROOT_DIR, '.ai-workflow/model-bans.json');
61
+ if (existsSync(bansFile)) {
62
+ const bans = JSON.parse(readFileSync(bansFile, 'utf8'));
63
+ const now = Date.now();
64
+ for (const [model, ban] of Object.entries(bans)) {
65
+ if (ban.expiresAt && ban.expiresAt > now) {
66
+ // Determine provider from model name
67
+ const provider = model.includes('gemini') ? 'gemini'
68
+ : model.includes('claude') ? 'claude'
69
+ : model.includes('copilot') ? 'copilot'
70
+ : null;
71
+ if (provider && result[provider] === 'healthy') {
72
+ result[provider] = 'degraded';
73
+ }
74
+ }
75
+ }
76
+ }
77
+ } catch { /* non-fatal */ }
78
+
79
+ return result;
80
+ }
81
+
82
+ export function getStageHealth(stage, stageStartTime) {
83
+ const expectedMs = STAGE_EXPECTED_MS[stage] || STAGE_EXPECTED_MS.default;
84
+ const durationMs = stageStartTime ? Date.now() - stageStartTime : 0;
85
+
86
+ let status = 'progressing';
87
+ if (durationMs > expectedMs * 3) status = 'stalled';
88
+ else if (durationMs > expectedMs * 1.5) status = 'slow';
89
+
90
+ return { status, durationMs, expectedMs };
91
+ }
92
+
93
+ export function getAvailableProviders(stage) {
94
+ try {
95
+ const config = getConfig();
96
+ const stageProviders = config.models?.stageProviders || {};
97
+ const providers = stageProviders[stage] || stageProviders.default || ['copilot', 'gemini', 'ollama'];
98
+ const status = getProviderStatus();
99
+ return providers.filter(p => status[p] !== 'down');
100
+ } catch {
101
+ return ['copilot', 'gemini', 'ollama'];
102
+ }
103
+ }
104
+
105
+ export function suggestRecoveryAction(stage, error) {
106
+ const errorMsg = error?.message || String(error || '');
107
+ const isRateLimit = /429|rate.?limit|quota/i.test(errorMsg);
108
+ const isCapacity = /capacity|exhausted|overload/i.test(errorMsg);
109
+ const isTimeout = /timeout|ETIMEDOUT|hung/i.test(errorMsg);
110
+ const isCircuitOpen = /circuit.?open|OPEN/i.test(errorMsg);
111
+
112
+ if (isCircuitOpen) return 'switch_provider';
113
+ if (isRateLimit && !isCapacity) return 'wait_and_retry'; // short rate limit
114
+ if (isCapacity) return 'switch_provider'; // capacity exhausted
115
+ if (isTimeout) return 'retry_same'; // retry once, then switch
116
+
117
+ const available = getAvailableProviders(stage);
118
+ if (available.length === 0) return 'escalate_ceo';
119
+ if (available.length === 1) return 'wait_and_retry';
120
+ return 'switch_provider';
121
+ }