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,113 @@
1
+ /**
2
+ * Inline keyboard helpers for Telegram bot.
3
+ */
4
+ import { InlineKeyboard } from 'grammy';
5
+
6
+ /** Main menu — mirrors all terminal CLI options */
7
+ export function mainMenuKeyboard() {
8
+ return new InlineKeyboard()
9
+ .text('New Feature', 'menu:feature').text('Fix a Bug', 'menu:bug').row()
10
+ .text('Review Code', 'menu:review').text('Pipeline Status', 'menu:status').row()
11
+ .text('View Docs', 'menu:docs').text('Debug / Logs', 'menu:logs').row()
12
+ .text('Approve', 'menu:approve').text('Reject', 'menu:reject').row()
13
+ .text('Deploy', 'menu:deploy').text('Toggle Auto-Pilot', 'menu:autopilot').row()
14
+ .text('Health Check', 'menu:health').text('Reset / Abandon', 'menu:reset').row()
15
+ .text('Ask AI', 'menu:ask').text('AI Mode', 'menu:aimode');
16
+ }
17
+
18
+ /** Deploy test level selection keyboard */
19
+ export function deployKeyboard() {
20
+ return new InlineKeyboard()
21
+ .text('No Tests (fast)', 'deploy:NoTestRun')
22
+ .text('Run Tests', 'deploy:RunLocalTests');
23
+ }
24
+
25
+ /** Post-review action keyboard */
26
+ export function reviewActionsKeyboard() {
27
+ return new InlineKeyboard()
28
+ .text('Approve', 'action:approve')
29
+ .text('Reject', 'action:reject');
30
+ }
31
+
32
+ /** Feature mode selection */
33
+ export function featureModeKeyboard(type = 'feature') {
34
+ return new InlineKeyboard()
35
+ .text('Manual', `fmode:${type}:manual`)
36
+ .text('Auto-Pilot', `fmode:${type}:auto`);
37
+ }
38
+
39
+ /** Document picker — shows available workflow documents */
40
+ export function docsKeyboard(docs) {
41
+ const kb = new InlineKeyboard();
42
+ docs.forEach(({ label, key }, i) => {
43
+ kb.text(label, `doc:view:${key}:0`);
44
+ if (i % 2 === 1) kb.row();
45
+ });
46
+ if (docs.length % 2 !== 0) kb.row();
47
+ return kb;
48
+ }
49
+
50
+ /** Pagination keyboard for a document */
51
+ export function docPageKeyboard(key, page, totalPages) {
52
+ const kb = new InlineKeyboard();
53
+ if (page > 0) kb.text('⬅ Prev', `doc:view:${key}:${page - 1}`);
54
+ if (page < totalPages - 1) kb.text('Next ➡', `doc:view:${key}:${page + 1}`);
55
+ kb.row().text('📋 Back to Docs', 'doc:list');
56
+ return kb;
57
+ }
58
+
59
+ /** Log session picker */
60
+ export function logsKeyboard(sessions) {
61
+ const kb = new InlineKeyboard();
62
+ sessions.forEach(({ label, key }, i) => {
63
+ kb.text(label, `log:view:${key}:0`);
64
+ if (i % 2 === 1) kb.row();
65
+ });
66
+ if (sessions.length % 2 !== 0) kb.row();
67
+ return kb;
68
+ }
69
+
70
+ /** Pagination keyboard for a log file */
71
+ export function logPageKeyboard(key, page, totalPages) {
72
+ const kb = new InlineKeyboard();
73
+ if (page > 0) kb.text('⬅ Prev', `log:view:${key}:${page - 1}`);
74
+ if (page < totalPages - 1) kb.text('Next ➡', `log:view:${key}:${page + 1}`);
75
+ kb.row().text('🗒 Back to Logs', 'log:list');
76
+ return kb;
77
+ }
78
+
79
+ /**
80
+ * AI confirmation keyboard — shown when AI detects intent (bug/feature/action)
81
+ * so the user can confirm with a tap instead of a second AI request.
82
+ * @param {'bug'|'feature'} type - The feature type detected
83
+ * @param {string} description - Short summary of what was detected
84
+ */
85
+ export function confirmActionKeyboard(type = 'feature', description = '') {
86
+ const label = type === 'bug' ? '🐛 Yes, create bug fix' : '✨ Yes, create feature';
87
+ // Encode description into callback data (trimmed to 50 chars for Telegram limit)
88
+ const desc = encodeURIComponent((description || '').slice(0, 50));
89
+ return new InlineKeyboard()
90
+ .text(label, `confirm:${type}:yes:${desc}`).row()
91
+ .text('✏️ Let me refine it', `confirm:${type}:edit:${desc}`).row()
92
+ .text('💬 Just discussing', 'confirm:no');
93
+ }
94
+
95
+ /**
96
+ * Pipeline action confirmation keyboard — shown when AI recognises a
97
+ * pipeline command (fix/review/approve/deploy) so user can one-tap it.
98
+ * @param {string} action - The pipeline action (e.g. run_fix, run_review)
99
+ */
100
+ export function confirmPipelineKeyboard(action) {
101
+ const labels = {
102
+ run_fix: '🔧 Yes, fix it',
103
+ run_review: '🔍 Yes, review',
104
+ run_fix_and_review: '🔧🔍 Yes, fix + review',
105
+ run_approve: '✅ Yes, approve',
106
+ run_deploy: '🚀 Yes, deploy',
107
+ run_implement: '⚡ Yes, implement'
108
+ };
109
+ const label = labels[action] || `✅ Confirm ${action}`;
110
+ return new InlineKeyboard()
111
+ .text(label, `pipeconfirm:${action}:yes`).row()
112
+ .text('💬 No, just chatting', 'pipeconfirm:no');
113
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Push notifications — sends Telegram messages on pipeline events.
3
+ *
4
+ * Requires TELEGRAM_CHAT_ID to be set (auto-prefixed via env()).
5
+ */
6
+
7
+ // Debounce: don't spam on rapid status.json writes
8
+ let lastNotifiedStage = null;
9
+ let lastNotifyTime = 0;
10
+ const DEBOUNCE_MS = 3000;
11
+
12
+ export function setupNotifications(bot, bus, chatId) {
13
+ if (!chatId) return;
14
+
15
+ // Typing indicator management — fires while AI is working
16
+ let typingInterval = null;
17
+
18
+ function startTyping() {
19
+ clearInterval(typingInterval);
20
+ typingInterval = setInterval(async () => {
21
+ try { await bot.api.sendChatAction(chatId, 'typing'); } catch { /* ignore */ }
22
+ }, 4000); // Telegram typing action lasts 5s, refresh every 4s
23
+ }
24
+
25
+ function stopTyping() {
26
+ clearInterval(typingInterval);
27
+ typingInterval = null;
28
+ }
29
+
30
+ // Pipeline events (explicit actions)
31
+ bus.on('pipeline-event', async ({ event, data }) => {
32
+ // Room events are handled by the dedicated handler below — skip here
33
+ if (event.startsWith('room:')) return;
34
+
35
+ // Start/stop typing indicator based on stage lifecycle
36
+ if (event === 'stage_start') {
37
+ startTyping();
38
+ } else if (event === 'stage_complete' || event === 'stage_error' || event === 'stage_fallback') {
39
+ stopTyping();
40
+ }
41
+
42
+ // Opus confirmation request — show inline keyboard
43
+ if (event === 'opus_confirm_request') {
44
+ stopTyping();
45
+ const { InlineKeyboard } = await import('grammy');
46
+ const kb = new InlineKeyboard()
47
+ .text('✅ Use Opus 4.6 (premium)', `opus:confirm:${data.featureId}`)
48
+ .text('⚡ Use Sonnet 4.6 (free)', `opus:decline:${data.featureId}`);
49
+ await safeSend(bot, chatId, data.message, { parse_mode: 'HTML', reply_markup: kb });
50
+ return;
51
+ }
52
+
53
+ // Loop limit reached — ask user to continue or stop
54
+ if (event === 'loop_confirm_request') {
55
+ stopTyping();
56
+ const { InlineKeyboard } = await import('grammy');
57
+ const kb = new InlineKeyboard()
58
+ .text('🔁 Continue fixing', `loop:continue:${data.featureId}`)
59
+ .text('🛑 Stop auto-pilot', `loop:stop:${data.featureId}`);
60
+ await safeSend(bot, chatId, data.message, { parse_mode: 'HTML', reply_markup: kb });
61
+ return;
62
+ }
63
+
64
+ // Auto-reset after deployment — inform user pipeline is ready for next task
65
+ if (event === 'auto_reset') {
66
+ stopTyping();
67
+ await safeSend(bot, chatId,
68
+ data.message || `🔄 Pipeline auto-reset to idle. Ready for the next task.`,
69
+ { parse_mode: 'HTML' }
70
+ );
71
+ return;
72
+ }
73
+
74
+ // Pipeline stall warning — fires if a stage hasn't progressed in 5+ minutes
75
+ if (event === 'pipeline_stalled') {
76
+ stopTyping();
77
+ await safeSend(bot, chatId,
78
+ data.message || `⚠️ Pipeline appears stalled at stage <b>${data.stage}</b> (${data.minutesElapsed || '?'} min elapsed).\nUse /status to check.`,
79
+ { parse_mode: 'HTML' }
80
+ );
81
+ return;
82
+ }
83
+
84
+ // Pipeline stall auto-recovery — fires after consecutive stall detections
85
+ if (event === 'pipeline_stall_recovery') {
86
+ stopTyping();
87
+ await safeSend(bot, chatId,
88
+ data.message || `🔄 Pipeline stalled for 10min on <code>${data.stage}</code>. Auto-retrying...`,
89
+ { parse_mode: 'HTML' }
90
+ );
91
+ // Trigger the actual recovery
92
+ try {
93
+ const actions = await import('../shared/action-runner.js');
94
+ actions.autoResumePipeline();
95
+ } catch { /* non-fatal */ }
96
+ return;
97
+ }
98
+
99
+ // Long-idle heartbeat — periodic status update so user knows bot is alive
100
+ if (event === 'idle_heartbeat') {
101
+ await safeSend(bot, chatId, data.message || `📋 Bot is online and idle.`, { parse_mode: 'HTML' });
102
+ return;
103
+ }
104
+
105
+ // Use explicit message field if provided, otherwise look up in table
106
+ let msg = data.message || null;
107
+
108
+ if (!msg) {
109
+ // Only generate messages for events NOT already covered by action-runner notify().
110
+ // feature_approved, review_complete, deploy_success are already sent via notify()
111
+ // in the auto-pipeline flow, so skip them here to avoid duplicates.
112
+ const messages = {
113
+ feature_created: `🚀 New feature started: ${data.description?.slice(0, 80) || data.feature}`,
114
+ feature_rejected: `❌ Feature rejected: ${data.reason?.slice(0, 100) || 'no reason'}`,
115
+ deploy_failed: `💥 Deploy failed: ${data.error?.slice(0, 200) || 'unknown error'}`,
116
+ tasks_ready: `✅ Tasks ready — Coder is ready to implement.`,
117
+ };
118
+ msg = messages[event] || null;
119
+ }
120
+
121
+ if (msg) {
122
+ // Attach context-driven action buttons where helpful
123
+ const { InlineKeyboard } = await import('grammy');
124
+ let kb = null;
125
+
126
+ if (event === 'qa_report_ready') {
127
+ if (data.summary?.failed > 0) {
128
+ kb = new InlineKeyboard().text('🔧 Fix bugs', 'action:fix').text('📋 QA Report', 'action:status');
129
+ } else {
130
+ kb = new InlineKeyboard().text('📋 Status', 'action:status');
131
+ }
132
+ } else if (event === 'bugfix_loop_complete') {
133
+ if (data.success) {
134
+ kb = new InlineKeyboard().text('📋 Status', 'action:status');
135
+ } else {
136
+ kb = new InlineKeyboard().text('🔧 Retry fix', 'action:fix').text('📋 QA Report', 'action:status');
137
+ }
138
+ } else if (event === 'feature_approved' || (msg && msg.includes('approved') && msg.includes('deploy'))) {
139
+ kb = new InlineKeyboard().text('🚀 Deploy now', 'action:deploy').text('📋 Status', 'action:status');
140
+ } else if (event === 'tasks_ready' || event === 'arch_complete') {
141
+ kb = new InlineKeyboard().text('⚡ Start Coder', 'action:approve').text('🔍 Review first', 'action:review').text('📋 Status', 'action:status');
142
+ } else if (event === 'review_complete') {
143
+ const verdict = data.verdict || '';
144
+ if (verdict === 'REJECTED' || (msg && msg.includes('REJECTED'))) {
145
+ kb = new InlineKeyboard().text('🔧 Auto-fix', 'action:fix').text('📄 View review', 'action:docs').text('📋 Status', 'action:status');
146
+ } else if (verdict === 'APPROVED' || (msg && msg.includes('APPROVED'))) {
147
+ kb = new InlineKeyboard().text('✅ Approve', 'action:approve').text('🚀 Deploy', 'action:deploy').text('📋 Status', 'action:status');
148
+ }
149
+ } else if (event === 'feature_rejected') {
150
+ // No keyboard here — the 'rejected' stage watcher already sends the Auto-fix button
151
+ } else if (event === 'deploy_failed') {
152
+ kb = new InlineKeyboard().text('🔄 Retry deploy', 'action:deploy').text('📋 Status', 'action:status');
153
+ }
154
+
155
+ await safeSend(bot, chatId, msg, kb ? { reply_markup: kb } : {});
156
+ }
157
+ });
158
+
159
+ // Room messages — forward agent chat to Telegram
160
+ bus.on('pipeline-event', async ({ event, data }) => {
161
+ if (event === 'room:message') {
162
+ const { formatForTelegram } = await import('../roleplay/agent-messenger.js');
163
+ const { isRoleplayEnabled } = await import('../roleplay/roleplay-config.js');
164
+ if (!isRoleplayEnabled()) return;
165
+ const formatted = formatForTelegram(data.role, data.message, data.type);
166
+ const opts = { parse_mode: 'Markdown' };
167
+ // Support inline buttons on room messages (e.g., feature confirmation from PM agent)
168
+ if (data.buttons && data.buttons.length) {
169
+ const { InlineKeyboard } = await import('grammy');
170
+ const kb = new InlineKeyboard();
171
+ for (const btn of data.buttons) {
172
+ kb.text(btn.text || btn.label, btn.callback || btn.action || btn.text).row();
173
+ }
174
+ opts.reply_markup = kb;
175
+ }
176
+ await safeSend(bot, chatId, formatted, opts);
177
+ } else if (event === 'room:ask_ceo') {
178
+ const { isRoleplayEnabled } = await import('../roleplay/roleplay-config.js');
179
+ if (!isRoleplayEnabled()) return;
180
+ const { formatForTelegram } = await import('../roleplay/agent-messenger.js');
181
+ const formatted = formatForTelegram('pm', data.message, 'question');
182
+ const opts = { parse_mode: 'Markdown' };
183
+ if (data.buttons && data.buttons.length) {
184
+ const { InlineKeyboard } = await import('grammy');
185
+ const kb = new InlineKeyboard();
186
+ for (const btn of data.buttons) {
187
+ kb.text(btn.label, btn.callback || btn.label);
188
+ }
189
+ opts.reply_markup = kb;
190
+ }
191
+ await safeSend(bot, chatId, formatted, opts);
192
+ }
193
+ });
194
+
195
+ // Status changes (from file watcher) — ONLY for manual mode transitions.
196
+ // In auto mode, action-runner.js notify() already sends detailed messages for
197
+ // every stage. The watcher only fires for stages triggered by manual commands
198
+ // (e.g., user clicks /review, /approve, /deploy directly).
199
+ bus.on('status', async ({ status, previousStage }) => {
200
+ // Only notify on meaningful stage transitions
201
+ if (!status.stage || status.stage === previousStage) return;
202
+ if (status.stage === lastNotifiedStage && Date.now() - lastNotifyTime < DEBOUNCE_MS) return;
203
+
204
+ // Skip stages that are covered by action-runner notify() in auto-pipeline flow.
205
+ // These stages generate their own Telegram messages via the heartbeat/stage events.
206
+ const autoHandledStages = new Set([
207
+ 'spec', 'spec_complete', 'architecture', 'arch_complete',
208
+ 'implementation', 'implementation_complete',
209
+ 'review', 'review_complete', 'approved', 'deploying', 'deployed',
210
+ 'rejected', 'fixing',
211
+ ]);
212
+ if (status.pipeline_mode === 'auto' && autoHandledStages.has(status.stage)) return;
213
+
214
+ lastNotifiedStage = status.stage;
215
+ lastNotifyTime = Date.now();
216
+
217
+ const { InlineKeyboard } = await import('grammy');
218
+
219
+ // Stage → {message, keyboard} — only for manual-mode transitions
220
+ const stageConfig = {
221
+ implementation_complete: { msg: '⚡ Coder implementation complete', kb: new InlineKeyboard().text('🔍 Review code', 'action:review').text('📋 Status', 'action:status') },
222
+ implementation_failed: { msg: `⚠️ Coder implementation failed: ${status.error?.slice(0, 100) || 'unknown error'}`, kb: new InlineKeyboard().text('🔄 Retry', 'action:approve').text('📋 Status', 'action:status') },
223
+ review_complete: { msg: `🔍 Review complete: ${status.latest_review || ''}`, kb: new InlineKeyboard().text('✅ Approve', 'action:approve').text('❌ Reject', 'action:reject').text('📄 Docs', 'action:docs') },
224
+ approved: { msg: '✅ Feature approved — ready to deploy!', kb: new InlineKeyboard().text('🚀 Deploy now', 'action:deploy').text('📋 Status', 'action:status') },
225
+ rejected: { msg: '❌ Feature rejected — fixes needed', kb: new InlineKeyboard().text('🔧 Auto-fix', 'action:fix').text('📄 View review', 'action:docs') },
226
+ deployed: { msg: '🚀 Deployment complete! 🎉', kb: new InlineKeyboard().text('📋 Status', 'action:status').text('📄 Docs', 'action:docs') },
227
+ };
228
+
229
+ const cfg = stageConfig[status.stage];
230
+ if (cfg) {
231
+ const feature = status.current_feature ? `\n<code>${status.current_feature}</code>` : '';
232
+ await safeSend(bot, chatId, `${cfg.msg}${feature}`, cfg.kb ? { reply_markup: cfg.kb } : {});
233
+ }
234
+ });
235
+ }
236
+
237
+ async function safeSend(bot, chatId, text, options = {}) {
238
+ try {
239
+ await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', ...options });
240
+ } catch (err) {
241
+ // Make notification failures clearly visible — silent failures hide config problems
242
+ console.error(` ⚠️ Telegram send FAILED to chat ${chatId}: ${err.message}`);
243
+ if (err.message?.includes('chat not found') || err.message?.includes('forbidden')) {
244
+ console.error(` ⚠️ CHAT_ID ${chatId} is invalid. Check TELEGRAM_CHAT_ID in .env`);
245
+ }
246
+ }
247
+ }