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,1757 @@
1
+ /**
2
+ * Main — entry point for the AI Office Visualizer (Pixel Art RPG Edition).
3
+ * Restored from 3bfc3f7 and adapted to current hub-client event-emitter API.
4
+ * Integrates new modules: day-night, cost-overlay, progress-bars,
5
+ * achievements-ui, sound-effects, chat-bubbles, replay, ui.
6
+ */
7
+
8
+ import { Character, IDLE_BEHAVIORS, pickTimeAwareBehavior } from './character.js';
9
+ import { getEffects } from './effects.js';
10
+ import { Engine } from './engine.js';
11
+ import { TicketManager } from './feature-ticket.js';
12
+ import { HubClient } from './hub-client.js';
13
+ import { OfficeLayout } from './office-layout.js';
14
+ import { drawStaminaBar, initStamina, updateStamina } from './stamina-system.js';
15
+
16
+ // ── New modules ─────────────────────────────────────────────────────────────
17
+ import { DayNightCycle } from './day-night.js';
18
+ import { CostOverlay } from './cost-overlay.js';
19
+ import { ProgressBarSystem } from './progress-bars.js';
20
+ import { AchievementsUI } from './achievements-ui.js';
21
+ import { SoundEffects } from './sound-effects.js';
22
+ import { ChatBubbleSystem } from './chat-bubbles.js';
23
+ import { PipelineReplay } from './replay.js';
24
+
25
+ // ── State ───────────────────────────────────────────────────────────────────
26
+ let engine, layout, hubClient, effects;
27
+ let dayNight, costOverlay, progressBars, achievementsUI, soundFx, chatBubbles, replay;
28
+ let projectConfigs = []; // from hub config
29
+ let characters = new Map(); // key: "project:role", value: Character
30
+ let projectStates = []; // latest state from hub
31
+ let activityLog = []; // recent activity entries
32
+ let prevStageMap = new Map(); // project → last stage
33
+ let prevFeatureMap = new Map(); // project → last feature (Bug 2)
34
+ let rejectionCountMap = new Map(); // project → consecutive rejection count
35
+ let clickedPMProject = null; // for PM interaction menu
36
+ let firstLoadDone = false; // suppress fireworks on initial state replay
37
+ let projectSeenOnce = new Set(); // track projects that have had at least one state update processed
38
+ let ticketManager = new TicketManager();
39
+ let pendingHandoffs = []; // Phase 3: active handoff animations
40
+
41
+ // Phase 3: maps pipeline stage → which character carries the ticket to whom
42
+ const HANDOFF_MAP = {
43
+ architecture: { from: 'pm', to: 'arch' },
44
+ implementation: { from: 'arch', to: 'coder' },
45
+ review: { from: 'coder', to: 'pm' }, // coder submits for review to PM
46
+ rejected: { from: 'pm', to: 'coder' }, // PM rejects back to coder
47
+ approved: { from: 'pm', to: 'deployer' }, // PM approves, hand to deployer
48
+ deploying: { from: 'deployer', to: 'deployer' }, // deployer stays
49
+ };
50
+
51
+ // Estimated durations per pipeline stage (seconds of visual progress)
52
+ const STAGE_DURATIONS = {
53
+ spec: 30, architecture: 45, implementation: 60,
54
+ review: 20, rejected: 15, fixing: 40, deploying: 15,
55
+ approved: 5, deployed: 5, inbox: 3,
56
+ };
57
+
58
+ // ── Persistent shipped packages (survive page refresh) ─────────────────────
59
+ function loadShippedPackages() {
60
+ try {
61
+ return JSON.parse(localStorage.getItem('office_shipped_packages') || '{}');
62
+ } catch { return {}; }
63
+ }
64
+ function saveShippedPackage(projectName, featureName) {
65
+ const pkgs = loadShippedPackages();
66
+ if (!pkgs[projectName]) pkgs[projectName] = [];
67
+ if (!pkgs[projectName].includes(featureName)) {
68
+ pkgs[projectName].push(featureName);
69
+ if (pkgs[projectName].length > 20) pkgs[projectName].shift();
70
+ localStorage.setItem('office_shipped_packages', JSON.stringify(pkgs));
71
+ }
72
+ }
73
+
74
+ // ── Initialize ──────────────────────────────────────────────────────────────
75
+ function init() {
76
+ const canvas = document.getElementById('office-canvas');
77
+ engine = new Engine(canvas);
78
+ layout = new OfficeLayout();
79
+
80
+ effects = getEffects();
81
+
82
+ // ── Initialize new modules ──────────────────────────────────────────────
83
+ dayNight = new DayNightCycle(canvas);
84
+ costOverlay = new CostOverlay(document.getElementById('canvas-container'));
85
+ progressBars = new ProgressBarSystem();
86
+ achievementsUI = new AchievementsUI(document.getElementById('canvas-container'));
87
+ soundFx = new SoundEffects();
88
+ chatBubbles = new ChatBubbleSystem();
89
+ replay = new PipelineReplay(engine);
90
+
91
+ // Register render layers (order matters: layout → characters → effects → idle AI)
92
+ engine.onRender((ctx, dt) => {
93
+ layout.render(ctx, dt);
94
+ });
95
+
96
+ engine.onRender((ctx, dt) => {
97
+ updateIdleBehaviors(dt);
98
+ updateWorkVisits(dt);
99
+ updateHandoffs(dt);
100
+ for (const char of characters.values()) {
101
+ char.update(dt);
102
+ char.render(ctx);
103
+ }
104
+
105
+ // Chat bubbles (render on canvas in world space)
106
+ chatBubbles.update(dt);
107
+ // Build agents map for chat bubble rendering
108
+ const agentsMap = {};
109
+ for (const [key, char] of characters) {
110
+ agentsMap[key] = char;
111
+ }
112
+ chatBubbles.render(ctx, agentsMap);
113
+
114
+ // Feature tickets (render on top of characters)
115
+ ticketManager.update(dt);
116
+ ticketManager.render(ctx);
117
+ });
118
+
119
+ engine.onRender((ctx, dt) => {
120
+ effects.update(dt);
121
+ effects.render(ctx);
122
+ });
123
+
124
+ // ── Post-render overlays (screen space) ─────────────────────────────────
125
+ engine.onPostRender((ctx, dt) => {
126
+ // Day/night cycle overlay
127
+ dayNight.update(dt);
128
+ dayNight.applyLighting(ctx, engine.width, engine.height);
129
+ });
130
+
131
+ // Click handling
132
+ engine.onClick((wx, wy, e) => {
133
+ // Check character clicks first
134
+ for (const char of characters.values()) {
135
+ if (char.hitTest(wx, wy)) {
136
+ showCharPopup(char, e.clientX, e.clientY);
137
+ return;
138
+ }
139
+ }
140
+
141
+ // Bug 4: Check warehouse package clicks
142
+ const pkg = layout.hitTestPackage(wx, wy);
143
+ if (pkg) {
144
+ showPackagePopup(pkg, e.clientX, e.clientY);
145
+ return;
146
+ }
147
+
148
+ // Check team zone clicks
149
+ const zone = layout.hitTest(wx, wy);
150
+ if (zone) {
151
+ showTeamPanel(zone.project, zone.config);
152
+ return;
153
+ }
154
+
155
+ // Click on empty space — hide popups
156
+ hidePopups();
157
+ });
158
+
159
+ // ── Hub WebSocket (event-emitter API) ────────────────────────────────────
160
+ hubClient = new HubClient();
161
+
162
+ hubClient.on('connected', () => {
163
+ console.log('[Office] Hub connected');
164
+ document.getElementById('connection-status')?.classList.replace('offline', 'online');
165
+ });
166
+
167
+ hubClient.on('disconnected', () => {
168
+ console.log('[Office] Hub disconnected');
169
+ document.getElementById('connection-status')?.classList.replace('online', 'offline');
170
+ });
171
+
172
+ // ── Hub mode: multi-project state from hub server ──────────────────────
173
+ // Hub broadcasts: { type: 'state', projects: { "Name": {name,url,color,icon,status,online}, ... }, summary }
174
+ hubClient.on('state', (msg) => {
175
+ const projects = msg?.projects || {};
176
+ const projectList = Object.values(projects);
177
+ if (projectList.length === 0) return;
178
+ console.log(`[Office] Hub state received: ${projectList.map(p => `${p.name}=${p.status?.stage||'?'}`).join(', ')}`);
179
+
180
+ // Extract configs from hub state (first time or when projects change)
181
+ const configs = projectList.map(p => ({
182
+ name: p.name,
183
+ color: p.color || '#4A90D9',
184
+ icon: p.icon || '',
185
+ url: p.url || '',
186
+ }));
187
+
188
+ // Update config if project list changed
189
+ const configNames = configs.map(c => c.name).sort().join(',');
190
+ const currentNames = projectConfigs.map(c => c.name).sort().join(',');
191
+ if (configNames !== currentNames) {
192
+ onConfigUpdate(configs);
193
+ }
194
+
195
+ // Convert hub state to array format for onStateUpdate
196
+ const wrapped = projectList.map(p => ({
197
+ name: p.name,
198
+ online: p.online !== false,
199
+ status: {
200
+ stage: p.status?.stage || '',
201
+ current_feature: p.status?.current_feature || '',
202
+ _bannedModels: p.status?._bannedModels || [],
203
+ history: p.status?.history || [],
204
+ rejection_reason: p.status?.rejection_reason || '',
205
+ },
206
+ }));
207
+ onStateUpdate(wrapped);
208
+ });
209
+
210
+ // ── Standalone mode: single-project status from individual web server ──
211
+ hubClient.on('status', (msg) => {
212
+ const data = msg?.data || msg;
213
+ const status = data?.status || data;
214
+ if (!status) return;
215
+ console.log(`[Office] Standalone status: stage=${status.stage}, feature=${status.current_feature || '?'}`);
216
+
217
+ // Use known project name from config, or extract from status
218
+ const projectName = status.project || status.projectName || projectConfigs[0]?.name || 'Default';
219
+
220
+ // Auto-create project config if we haven't received one yet
221
+ if (projectConfigs.length === 0) {
222
+ onConfigUpdate([{ name: projectName, color: '#4A90D9', icon: '' }]);
223
+ }
224
+
225
+ // Server sends single-project status object — wrap it as array for our multi-project renderer
226
+ const wrapped = [{
227
+ name: projectName,
228
+ online: true,
229
+ status: {
230
+ stage: status.stage || '',
231
+ current_feature: status.current_feature || status.currentFeature || '',
232
+ _bannedModels: status._bannedModels || status.bannedModels || [],
233
+ history: status.history || [],
234
+ rejection_reason: status.rejection_reason || '',
235
+ },
236
+ }];
237
+ onStateUpdate(wrapped);
238
+ });
239
+
240
+ // Handle pipeline events (user actions, deploy, etc.)
241
+ hubClient.on('event', (msg) => {
242
+ const data = msg?.data || msg;
243
+ if (!data) return;
244
+ const projectName = data.project || data.projectName || projectConfigs[0]?.name || '';
245
+ const event = data.event || data.type || '';
246
+ onHubEvent(projectName, event, data.data || data);
247
+ });
248
+
249
+ // Handle log entries
250
+ hubClient.on('log', (msg) => {
251
+ const data = msg?.data || msg;
252
+ if (data?.message) {
253
+ addActivity(data.project || 'System', data.message);
254
+ }
255
+ });
256
+
257
+ // ── Connect to hub first, fallback to local WebSocket ──────────────────
258
+ connectToHub();
259
+
260
+ // UI buttons
261
+ document.getElementById('btn-submit')?.addEventListener('click', () => {
262
+ document.getElementById('submit-modal')?.classList.remove('hidden');
263
+ });
264
+
265
+ document.getElementById('submit-go')?.addEventListener('click', submitFeature);
266
+
267
+ // PM menu item clicks
268
+ document.querySelectorAll('.pm-menu-item').forEach(item => {
269
+ item.addEventListener('click', () => {
270
+ const action = item.dataset.action;
271
+ handlePMAction(action);
272
+ });
273
+ });
274
+
275
+ // Initialize slash command bar
276
+ initCommandBar();
277
+
278
+ // Start engine
279
+ engine.start();
280
+ console.log('[Office] AI Operations Center started (Pixel Art RPG Edition)');
281
+
282
+ // Show demo characters if no hub connection after 3s
283
+ setTimeout(() => {
284
+ if (projectConfigs.length === 0) {
285
+ loadDemoMode();
286
+ }
287
+ }, 3000);
288
+
289
+ // Fetch initial state (hub mode will override via WebSocket)
290
+ fetchInitialState();
291
+ }
292
+
293
+ // ── Hub / Standalone Connection ──────────────────────────────────────────────
294
+
295
+ let hubBaseUrl = null; // set when hub is detected
296
+
297
+ /**
298
+ * Try connecting to hub server first (port 3850 by convention).
299
+ * If hub is available, use it for multi-project state.
300
+ * Otherwise fall back to the local project's WebSocket.
301
+ */
302
+ async function connectToHub() {
303
+ const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
304
+
305
+ // Try hub on same host, default port 3850
306
+ const hubPort = 3850;
307
+ const hubWsUrl = `${wsProtocol}//${location.hostname}:${hubPort}/ws`;
308
+ const hubHttpUrl = `${location.protocol}//${location.hostname}:${hubPort}`;
309
+
310
+ try {
311
+ // Quick check if hub is running
312
+ const res = await fetch(`${hubHttpUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
313
+ if (res.ok) {
314
+ const health = await res.json();
315
+ if (health.hub === 'ok') {
316
+ console.log(`[Office] Hub detected at port ${hubPort} — multi-project mode (${health.projectCount} projects)`);
317
+ hubBaseUrl = hubHttpUrl;
318
+ hubClient.connect(hubWsUrl);
319
+ return;
320
+ }
321
+ }
322
+ } catch {
323
+ // Hub not available
324
+ }
325
+
326
+ // Fallback: connect to local project's WebSocket
327
+ console.log('[Office] No hub detected — single-project mode');
328
+ hubClient.connect(`${wsProtocol}//${location.host}/ws`);
329
+ }
330
+
331
+ /** Fetch initial state from REST API (for single-project mode bootstrap) */
332
+ async function fetchInitialState() {
333
+ // If hub is connected, it will push state via WebSocket — skip REST
334
+ if (hubBaseUrl) {
335
+ try {
336
+ const res = await fetch(`${hubBaseUrl}/api/projects`);
337
+ const data = await res.json();
338
+ if (data.projects?.length) {
339
+ const configs = data.projects.map(p => ({
340
+ name: p.name,
341
+ color: p.color || '#4A90D9',
342
+ icon: p.icon || '',
343
+ url: p.url || '',
344
+ }));
345
+ onConfigUpdate(configs);
346
+
347
+ const wrapped = data.projects.map(p => ({
348
+ name: p.name,
349
+ online: p.online !== false,
350
+ status: {
351
+ stage: p.status?.stage || '',
352
+ current_feature: p.status?.current_feature || '',
353
+ _bannedModels: p.status?._bannedModels || [],
354
+ history: p.status?.history || [],
355
+ rejection_reason: p.status?.rejection_reason || '',
356
+ },
357
+ }));
358
+ onStateUpdate(wrapped);
359
+ }
360
+ } catch { /* hub will push state via WS */ }
361
+ return;
362
+ }
363
+
364
+ // Single-project mode: fetch from local API
365
+ try {
366
+ const infoRes = await fetch('/api/info');
367
+ const info = await infoRes.json();
368
+ const projectName = info.name || 'Unknown Project';
369
+
370
+ if (projectConfigs.length === 0) {
371
+ onConfigUpdate([{ name: projectName, color: info.color || '#4A90D9', icon: info.icon || '' }]);
372
+ }
373
+
374
+ const statusRes = await fetch('/api/status');
375
+ const status = await statusRes.json();
376
+ if (status) {
377
+ onStateUpdate([{
378
+ name: projectName,
379
+ online: true,
380
+ status: {
381
+ stage: status.stage || '',
382
+ current_feature: status.current_feature || '',
383
+ _bannedModels: status._bannedModels || [],
384
+ history: status.history || [],
385
+ rejection_reason: status.rejection_reason || '',
386
+ },
387
+ }]);
388
+ }
389
+ } catch { /* will use demo mode after 3s timeout */ }
390
+ }
391
+
392
+ // ── Idle Behavior System ─────────────────────────────────────────────────────
393
+
394
+ function updateIdleBehaviors(_dt) {
395
+ for (const char of characters.values()) {
396
+ if (!char.wantsIdleAction()) continue;
397
+
398
+ // Pick an idle behavior with time-of-day awareness
399
+ const behavior = pickTimeAwareBehavior();
400
+ const idlePositions = layout.getIdlePositions(char.project);
401
+
402
+ let targetPos = null;
403
+ let gossipPartner = null;
404
+
405
+ switch (behavior) {
406
+ case 'wander': {
407
+ // Wander to a random spot near home
408
+ const wanderRange = 80;
409
+ targetPos = {
410
+ x: char.homeX + (Math.random() - 0.5) * wanderRange * 2,
411
+ y: char.homeY + (Math.random() - 0.5) * wanderRange * 2,
412
+ };
413
+ break;
414
+ }
415
+ case 'gossip': {
416
+ // Find another idle character from the same project to gossip with
417
+ gossipPartner = findGossipPartner(char);
418
+ if (!gossipPartner) {
419
+ // No partner available — wander instead
420
+ targetPos = {
421
+ x: char.homeX + (Math.random() - 0.5) * 60,
422
+ y: char.homeY + (Math.random() - 0.5) * 60,
423
+ };
424
+ } else {
425
+ // Meet near each other with a 16px gap so they don't overlap
426
+ const midX = (char.x + gossipPartner.x) / 2;
427
+ const midY = (char.y + gossipPartner.y) / 2;
428
+ const gdx = gossipPartner.x - char.x;
429
+ const gdy = gossipPartner.y - char.y;
430
+ const glen = Math.sqrt(gdx * gdx + gdy * gdy) || 1;
431
+ const GOSSIP_GAP = 16;
432
+ targetPos = {
433
+ x: midX - (gdx / glen) * GOSSIP_GAP,
434
+ y: midY - (gdy / glen) * GOSSIP_GAP,
435
+ };
436
+ gossipPartner.startIdleBehavior('gossip', {
437
+ x: midX + (gdx / glen) * GOSSIP_GAP,
438
+ y: midY + (gdy / glen) * GOSSIP_GAP,
439
+ }, char);
440
+ }
441
+ break;
442
+ }
443
+ case 'gym':
444
+ targetPos = idlePositions.gym;
445
+ break;
446
+ case 'breakRoom':
447
+ targetPos = idlePositions.breakRoom;
448
+ break;
449
+ case 'coffee':
450
+ targetPos = idlePositions.coffee;
451
+ break;
452
+ case 'poke':
453
+ case 'highfive': {
454
+ // Find a partner for social interaction
455
+ gossipPartner = findGossipPartner(char);
456
+ if (!gossipPartner) {
457
+ targetPos = { x: char.homeX + (Math.random() - 0.5) * 60, y: char.homeY + (Math.random() - 0.5) * 60 };
458
+ } else {
459
+ const midX = (char.x + gossipPartner.x) / 2;
460
+ const midY = (char.y + gossipPartner.y) / 2;
461
+ const gdx = gossipPartner.x - char.x;
462
+ const gdy = gossipPartner.y - char.y;
463
+ const glen = Math.sqrt(gdx * gdx + gdy * gdy) || 1;
464
+ const GAP = 14;
465
+ targetPos = { x: midX - (gdx / glen) * GAP, y: midY - (gdy / glen) * GAP };
466
+ gossipPartner.startIdleBehavior(behavior, { x: midX + (gdx / glen) * GAP, y: midY + (gdy / glen) * GAP }, char);
467
+ }
468
+ break;
469
+ }
470
+ case 'phone':
471
+ // Stay in place, check phone
472
+ targetPos = { x: char.x, y: char.y };
473
+ break;
474
+ case 'stretch':
475
+ // Stay in place, stretch
476
+ targetPos = { x: char.x, y: char.y };
477
+ break;
478
+ case 'meeting': {
479
+ // Two idle characters from the same project meet at break room
480
+ gossipPartner = findGossipPartner(char);
481
+ if (!gossipPartner) {
482
+ // No partner — just wander instead
483
+ targetPos = {
484
+ x: char.homeX + (Math.random() - 0.5) * 60,
485
+ y: char.homeY + (Math.random() - 0.5) * 60,
486
+ };
487
+ } else {
488
+ // Meet at break room
489
+ const meetPos = idlePositions.breakRoom || idlePositions.lounge1;
490
+ if (meetPos) {
491
+ const GAP = 16;
492
+ targetPos = { x: meetPos.x - GAP, y: meetPos.y };
493
+ gossipPartner.startIdleBehavior('meeting', { x: meetPos.x + GAP, y: meetPos.y }, char);
494
+ } else {
495
+ targetPos = { x: char.homeX, y: char.homeY };
496
+ }
497
+ }
498
+ break;
499
+ }
500
+ case 'sleep':
501
+ // Sleep at their desk
502
+ targetPos = { x: char.homeX, y: char.homeY };
503
+ break;
504
+ default:
505
+ targetPos = idlePositions.lounge1;
506
+ }
507
+
508
+ char.startIdleBehavior(behavior, targetPos, gossipPartner);
509
+ }
510
+ }
511
+
512
+ function findGossipPartner(char) {
513
+ // Find an idle character from the same project who isn't already gossiping
514
+ for (const other of characters.values()) {
515
+ if (other === char) continue;
516
+ if (other.project !== char.project) continue;
517
+ if (other.isWorking) continue;
518
+ if (other.idleBehavior === 'gossip') continue; // already gossiping
519
+ if (other.idleBehavior === 'meeting') continue; // in a meeting
520
+ if (other.idleBehavior === 'sleep') continue; // don't wake them up
521
+ if (other.idleBehavior === 'phone') continue; // on their phone
522
+ if (other.idleBehavior === 'stretch') continue; // stretching
523
+ return other;
524
+ }
525
+ return null;
526
+ }
527
+
528
+ // ── Work Visit System (feature discussions while working) ───────────────────
529
+ // DISABLED: Working characters must stay at their desk and focus.
530
+ let workVisitTimer = 0;
531
+
532
+ function updateWorkVisits(dt) {
533
+ // No-op: working characters should not leave their station
534
+ workVisitTimer += dt;
535
+ }
536
+
537
+ // ── Phase 3: Handoff Animation System ────────────────────────────────────────
538
+ const HANDOFF_TIMEOUT = 8; // seconds — if source doesn't arrive, force-complete
539
+
540
+ function completeHandoff(ho) {
541
+ // Drop the ticket at the target position
542
+ ho.ticket.detach();
543
+ ho.ticket.setRole(ho.targetRole);
544
+ ho.ticket.sendTo(ho.targetPos, ho.stayDuration);
545
+
546
+ // Release source character and send them home
547
+ ho.sourceChar.isHandingOff = false;
548
+ ho.sourceChar.idleTimer = 4 + Math.random() * 6;
549
+ ho.sourceChar.moveTo(ho.sourceChar.homeX, ho.sourceChar.homeY);
550
+
551
+ // Target character reacts
552
+ if (ho.targetChar && ho.targetChar !== ho.sourceChar) {
553
+ ho.targetChar.react('working', '\u{1F4CB} Got it!', 2);
554
+ }
555
+ }
556
+
557
+ function updateHandoffs(dt) {
558
+ for (let i = pendingHandoffs.length - 1; i >= 0; i--) {
559
+ const ho = pendingHandoffs[i];
560
+ ho.elapsed = (ho.elapsed || 0) + dt;
561
+
562
+ const dx = ho.targetPos.x - ho.sourceChar.x;
563
+ const dy = ho.targetPos.y - ho.sourceChar.y;
564
+ const dist = Math.sqrt(dx * dx + dy * dy);
565
+
566
+ if (dist < 12 || ho.elapsed > HANDOFF_TIMEOUT) {
567
+ completeHandoff(ho);
568
+ pendingHandoffs.splice(i, 1);
569
+ }
570
+ }
571
+ }
572
+
573
+ // ── Demo Mode (when hub isn't running) ──────────────────────────────────────
574
+
575
+ function loadDemoMode() {
576
+ console.log('[Office] No hub detected — loading demo mode');
577
+ const demoConfigs = [
578
+ { name: 'XConnector', color: '#4A90D9', icon: '' },
579
+ { name: 'PickleBall', color: '#27AE60', icon: '' },
580
+ ];
581
+ onConfigUpdate(demoConfigs);
582
+
583
+ // Simulate pipeline activity for XConnector
584
+ const demoStages = ['idle', 'spec', 'architecture', 'implementation', 'review', 'approved', 'deploying', 'deployed'];
585
+ let stageIdx = 0;
586
+ const demoFeature = 'Auto-Sync Dashboard';
587
+
588
+ setInterval(() => {
589
+ stageIdx = (stageIdx + 1) % demoStages.length;
590
+ const stage = demoStages[stageIdx];
591
+
592
+ // Update character states
593
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
594
+ const key = `XConnector:${role}`;
595
+ const char = characters.get(key);
596
+ if (char) {
597
+ const wasWorking = char.isWorking;
598
+ char.setState(stage);
599
+ if (!wasWorking && char.isWorking) {
600
+ char.moveTo(char.homeX, char.homeY);
601
+ }
602
+ }
603
+ }
604
+
605
+ // Update status board and whiteboard
606
+ layout.setProjectState('XConnector', demoFeature, stage, stage === 'deploying');
607
+
608
+ // Add shipped package when deployed
609
+ if (stage === 'deployed') {
610
+ layout.addShippedPackage('XConnector', demoFeature);
611
+ }
612
+
613
+ addActivity('XConnector', stageToMessage(stage, demoFeature));
614
+
615
+ // Sound effects for demo
616
+ if (soundFx) {
617
+ if (stage === 'spec' || stage === 'architecture' || stage === 'implementation') soundFx.playTaskStart();
618
+ if (stage === 'deployed') soundFx.playTaskComplete();
619
+ }
620
+ }, 4000);
621
+
622
+ // PickleBall team is idle — they'll use the idle behavior system naturally
623
+ layout.setProjectState('PickleBall', '', 'idle', false);
624
+
625
+ document.getElementById('connection-status')?.classList.replace('offline', 'online');
626
+ addActivity('System', 'Demo mode active — hub not connected');
627
+ }
628
+
629
+ // ── Hub State Updates ───────────────────────────────────────────────────────
630
+
631
+ function onConfigUpdate(configs) {
632
+ projectConfigs = configs || [];
633
+ layout.setupTeams(projectConfigs);
634
+ createCharacters();
635
+ updateProjectDropdown();
636
+
637
+ document.getElementById('project-count').textContent =
638
+ `${projectConfigs.length} PROJECT${projectConfigs.length !== 1 ? 'S' : ''}`;
639
+
640
+ document.getElementById('connection-status')?.classList.replace('offline', 'online');
641
+
642
+ // Restore shipped packages from localStorage
643
+ const savedPkgs = loadShippedPackages();
644
+ for (const [projName, features] of Object.entries(savedPkgs)) {
645
+ for (const feat of features) {
646
+ layout.addShippedPackage(projName, feat);
647
+ }
648
+ }
649
+
650
+ // Bug 2 fix: re-apply current pipeline state immediately after characters are created
651
+ if (projectStates.length > 0) {
652
+ prevStageMap.clear();
653
+ rejectionCountMap.clear();
654
+ onStateUpdate(projectStates);
655
+ }
656
+ }
657
+
658
+ function onStateUpdate(projects) {
659
+ projectStates = projects || [];
660
+
661
+ // Debug: log state updates to browser console
662
+ for (const p of (projects || [])) {
663
+ const s = p.status?.stage || '';
664
+ const prev = prevStageMap.get(p.name);
665
+ if (s !== prev) {
666
+ console.log(`[Office] Stage change: ${p.name} ${prev || '(none)'} → ${s}`);
667
+ }
668
+ }
669
+
670
+ // Snapshot which projects have been seen before this update cycle.
671
+ // Celebrations are only allowed for projects already in the set.
672
+ const alreadySeen = new Set(projectSeenOnce);
673
+
674
+ for (const p of projectStates) {
675
+ const stage = p.status?.stage || '';
676
+ const name = p.name;
677
+ const feature = p.status?.current_feature || '';
678
+ const isDeploying = stage === 'deploying';
679
+
680
+ // Update status board, whiteboard, and conveyor state
681
+ layout.setProjectState(name, feature, stage, isDeploying);
682
+
683
+ // ── Only update character states when stage CHANGES (Bug 1 fix) ──────────
684
+ const prevStage = prevStageMap.has(name) ? prevStageMap.get(name) : undefined;
685
+ if (stage !== prevStage) {
686
+ prevStageMap.set(name, stage);
687
+
688
+ // Cancel any pending handoffs for this project (stage moved on)
689
+ for (let hi = pendingHandoffs.length - 1; hi >= 0; hi--) {
690
+ if (pendingHandoffs[hi].projectName === name) {
691
+ completeHandoff(pendingHandoffs[hi]);
692
+ pendingHandoffs.splice(hi, 1);
693
+ }
694
+ }
695
+
696
+ // Track rejection count for escalating reactions
697
+ if (stage === 'rejected') {
698
+ rejectionCountMap.set(name, (rejectionCountMap.get(name) || 0) + 1);
699
+ } else if (stage === 'approved' || stage === 'deployed') {
700
+ rejectionCountMap.set(name, 0);
701
+ }
702
+ const rejCount = rejectionCountMap.get(name) || 0;
703
+
704
+ // Wake ALL characters when transitioning from idle to active stage
705
+ const idleStages = new Set(['', 'idle', 'inbox']);
706
+ const isNewTask = idleStages.has(prevStage || '') && !idleStages.has(stage);
707
+
708
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
709
+ const key = `${name}:${role}`;
710
+ const char = characters.get(key);
711
+ if (char) {
712
+ // Track current feature for work discussions vs idle gossip
713
+ char.currentFeature = feature || '';
714
+
715
+ // Wake sleeping/idle chars and send them home when new task starts
716
+ if (isNewTask) {
717
+ char.idleBehavior = null;
718
+ char.gossipTarget = null;
719
+ char.gossipLine = '';
720
+ char.moveTo(char.homeX, char.homeY);
721
+ }
722
+
723
+ const wasWorking = char.isWorking;
724
+ char.isHandingOff = false; // clear handoff flag on stage change
725
+ char.rejectionCount = rejCount;
726
+ char.setState(stage);
727
+
728
+ // Set task progress duration for progress bar
729
+ char.taskProgress = 0;
730
+ char.taskDuration = STAGE_DURATIONS[stage] || 0;
731
+
732
+ // When approved, always send deployer back to their dock to stand by
733
+ if (stage === 'approved' && char.role === 'deployer') {
734
+ char.moveTo(char.homeX, char.homeY);
735
+ char.workBubble = '📦 Package ready!';
736
+ char.workBubbleDuration = 3;
737
+ char.workBubbleTimer = 2;
738
+ } else if (!wasWorking && char.isWorking) {
739
+ char.moveTo(char.homeX, char.homeY);
740
+ // Show "going to work" bubble so user knows who's starting
741
+ const workLabels = {
742
+ working: '📝 On it!',
743
+ designing: '📐 Designing...',
744
+ reviewing: '🔍 Reviewing...',
745
+ deploying: '🚀 Deploying!',
746
+ fighting_bug: '🐛 Fixing bugs!',
747
+ celebrating: '🎉 Done!',
748
+ waiting_input: '⏳ Waiting...',
749
+ };
750
+ const label = workLabels[char.state] || '⚡ Working...';
751
+ char.workBubble = label;
752
+ char.workBubbleDuration = 4;
753
+ char.workBubbleTimer = 0;
754
+ }
755
+ }
756
+ }
757
+
758
+ // ── Progress bars for pipeline stages ──────────────────────────────────
759
+ if (progressBars) {
760
+ progressBars.handlePipelineEvent({
761
+ type: 'stage-start',
762
+ stage,
763
+ project: name,
764
+ });
765
+ }
766
+
767
+ // ── Sound effects on stage change ──────────────────────────────────────
768
+ if (soundFx && firstLoadDone && alreadySeen.has(name)) {
769
+ if (stage === 'spec' || stage === 'architecture' || stage === 'implementation') {
770
+ soundFx.playTaskStart();
771
+ } else if (stage === 'deployed') {
772
+ soundFx.playTaskComplete();
773
+ } else if (stage === 'rejected') {
774
+ soundFx.playError();
775
+ } else if (stage === 'approved') {
776
+ soundFx.playNotification();
777
+ }
778
+ }
779
+
780
+ // ── Feature Ticket management ──────────────────────────────────────────
781
+ const stageToRole = {
782
+ inbox: 'pm', spec: 'pm', spec_complete: 'pm',
783
+ architecture: 'arch', arch_complete: 'arch',
784
+ implementation: 'coder', implementation_complete: 'coder',
785
+ review: 'pm', review_complete: 'pm',
786
+ rejected: 'coder', fixing: 'coder',
787
+ approved: 'deployer', deploying: 'deployer',
788
+ };
789
+
790
+ if (isNewTask && feature) {
791
+ // Create a new ticket at PM's desk — but only if one doesn't already exist
792
+ const existingTicket = ticketManager.getByProject(name);
793
+ if (!existingTicket) {
794
+ const pmPos = layout.getCharacterPositions(name).pm;
795
+ const projColor = projectConfigs.find(c => c.name === name)?.color || '#4A90D9';
796
+ if (pmPos) {
797
+ ticketManager.createTicket(feature, name, pmPos, projColor);
798
+ }
799
+ }
800
+ }
801
+
802
+ // Move existing ticket to the role's station for the current stage
803
+ const ticket = ticketManager.getByProject(name);
804
+ if (ticket) {
805
+ const targetRole = stageToRole[stage];
806
+ if (targetRole) {
807
+ const positions = layout.getCharacterPositions(name);
808
+ const targetPos = positions[targetRole];
809
+ if (targetPos) {
810
+ const handoff = HANDOFF_MAP[stage];
811
+ const stayDuration = STAGE_DURATIONS[stage] || 10;
812
+
813
+ // Phase 3: Try handoff animation if a mapping exists
814
+ if (handoff && firstLoadDone && alreadySeen.has(name)) {
815
+ const sourceChar = characters.get(`${name}:${handoff.from}`);
816
+ const targetChar = characters.get(`${name}:${handoff.to}`);
817
+
818
+ // Hand-carry: source just finished their stage, so temporarily release them for handoff
819
+ if (sourceChar && handoff.from !== handoff.to) {
820
+ // Release source from working state and mark as handing off
821
+ sourceChar.isWorking = false;
822
+ sourceChar.isAtDesk = false;
823
+ sourceChar.isHandingOff = true; // prevents idle system from hijacking
824
+ sourceChar.idleBehavior = null;
825
+ sourceChar.gossipTarget = null;
826
+ sourceChar.gossipLine = '';
827
+ sourceChar.state = 'handing_off';
828
+
829
+ // Show delivery bubble on source
830
+ const roleLabels = { pm: 'PM', arch: 'Architect', coder: 'Coder', deployer: 'Deployer' };
831
+ sourceChar.workBubble = `📋→ ${roleLabels[handoff.to] || handoff.to}`;
832
+ sourceChar.workBubbleDuration = 5;
833
+ sourceChar.workBubbleTimer = 0;
834
+
835
+ // Attach ticket to source character
836
+ ticket.attachTo(sourceChar);
837
+
838
+ // Source character walks to the target desk
839
+ sourceChar.moveTo(targetPos.x, targetPos.y);
840
+
841
+ // Track this pending handoff
842
+ pendingHandoffs.push({
843
+ projectName: name,
844
+ sourceChar,
845
+ targetChar,
846
+ ticket,
847
+ targetPos,
848
+ targetRole,
849
+ stayDuration,
850
+ elapsed: 0,
851
+ });
852
+ } else {
853
+ // Source is busy or same role — fall back to direct send
854
+ ticket.setRole(targetRole);
855
+ ticket.sendTo(targetPos, stayDuration);
856
+ }
857
+ } else {
858
+ // No handoff mapping or initial page load — direct send
859
+ ticket.setRole(targetRole);
860
+ ticket.sendTo(targetPos, stayDuration);
861
+ }
862
+ }
863
+ }
864
+ if (stage === 'deploying') {
865
+ ticket.state = 'on_conveyor';
866
+ } else if (stage === 'deployed') {
867
+ ticket.delivered = true;
868
+ }
869
+ }
870
+
871
+ if (stage) addActivity(name, stageToMessage(stage, feature));
872
+
873
+ // ── Achievements on milestones ─────────────────────────────────────────
874
+ if (achievementsUI && firstLoadDone && alreadySeen.has(name)) {
875
+ if (stage === 'deployed') {
876
+ achievementsUI.showUnlockAnimation({
877
+ id: 'first_deploy',
878
+ name: 'First Deploy!',
879
+ description: `Shipped: ${feature}`,
880
+ icon: '🚀',
881
+ });
882
+ }
883
+ }
884
+
885
+ // ── Deployed: play deploying animation first, then celebrate ───────────
886
+ if (stage === 'deployed' && feature) {
887
+ if (firstLoadDone && alreadySeen.has(name)) {
888
+ // Live: inject a client-side "deploying" phase (10s) before celebration
889
+ const deployerChar = characters.get(`${name}:deployer`);
890
+ if (deployerChar) {
891
+ deployerChar.state = 'deploying';
892
+ deployerChar.isWorking = true;
893
+ deployerChar.taskProgress = 0;
894
+ deployerChar.taskDuration = 10;
895
+ deployerChar.workBubble = '🚀 Launching to prod!';
896
+ deployerChar.workBubbleDuration = 9;
897
+ deployerChar.moveTo(deployerChar.homeX, deployerChar.homeY);
898
+ }
899
+ // Run conveyor belt for the deploy duration
900
+ layout.setProjectState(name, feature, 'deploying', true);
901
+ // Start the truck driving animation (dock → conveyor → warehouse)
902
+ layout.startTruckAnimation(name);
903
+
904
+ const capturedName = name;
905
+ const capturedFeature = feature;
906
+ setTimeout(() => {
907
+ // Celebration phase — all characters react
908
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
909
+ const char = characters.get(`${capturedName}:${role}`);
910
+ if (char) {
911
+ const msg = role === 'deployer' ? '🚀 SHIPPED!' :
912
+ role === 'pm' ? '🎉 Amazing!' :
913
+ role === 'arch' ? '🏆 Nailed it!' : '🥳 We did it!';
914
+ char.react('celebrating', msg, 5);
915
+ }
916
+ }
917
+ // Fireworks + banner
918
+ if (effects) {
919
+ const origin = layout.getTeamOrigin(capturedName);
920
+ if (origin) effects.launchFireworks(origin.x + 160, origin.y + 80, 22);
921
+ effects.showBanner('🎉 SHIPPED!', `${capturedName}: ${capturedFeature.slice(0, 35)}`, 4.5);
922
+ }
923
+ layout.addShippedPackage(capturedName, capturedFeature);
924
+ saveShippedPackage(capturedName, capturedFeature);
925
+ // Mark ticket as delivered
926
+ const t = ticketManager.getByProject(capturedName);
927
+ if (t) t.delivered = true;
928
+ // Sound
929
+ if (soundFx) soundFx.playAchievement();
930
+ // Reset status board after celebration
931
+ setTimeout(() => {
932
+ layout.setProjectState(capturedName, '', 'idle', false);
933
+ prevStageMap.set(capturedName, 'idle');
934
+ }, 8000);
935
+ }, 10000);
936
+ } else {
937
+ // Page-load replay: silently add to warehouse, no animation
938
+ layout.addShippedPackage(name, feature);
939
+ saveShippedPackage(name, feature);
940
+ }
941
+ }
942
+ }
943
+
944
+ // ── Always: idle sleep check — only after 8+ seconds of idle ───────────
945
+ if (!stage || stage === '' || stage === 'inbox') {
946
+ if (!feature) {
947
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
948
+ const key = `${name}:${role}`;
949
+ const char = characters.get(key);
950
+ if (char && char.state === 'idle' && !char.idleBehavior && char.stateTime > 8) {
951
+ char.state = 'sleeping';
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ // ── Always: rate-limited character indicators ─────────────────────────────
958
+ const banned = p.status?._bannedModels || [];
959
+ for (const ban of banned) {
960
+ const model = ban.model?.toLowerCase() || '';
961
+ let role = null;
962
+ if (model.includes('gemini')) role = 'pm';
963
+ else if (model.includes('claude')) role = 'arch';
964
+ else if (model.includes('sonnet') || model.includes('opus')) role = 'coder';
965
+
966
+ if (role) {
967
+ const key = `${name}:${role}`;
968
+ const char = characters.get(key);
969
+ if (char && (char.state === 'idle' || char.state === 'sleeping')) {
970
+ char.state = 'rate_limited';
971
+ }
972
+ }
973
+ }
974
+
975
+ // ── Always: new feature arrives → recall idle chars home ─────────────────
976
+ const prevFeature = prevFeatureMap.get(name) || '';
977
+ if (feature && feature !== prevFeature) {
978
+ prevFeatureMap.set(name, feature);
979
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
980
+ const key = `${name}:${role}`;
981
+ const char = characters.get(key);
982
+ if (char && !char.isWorking) {
983
+ char.idleBehavior = null;
984
+ char.gossipTarget = null;
985
+ char.gossipLine = '';
986
+ char.workBubble = '';
987
+ char.moveTo(char.homeX, char.homeY);
988
+ }
989
+ }
990
+ }
991
+ } // end for (const p of projectStates)
992
+
993
+ // Mark all current projects as seen for future celebration gating
994
+ for (const p of projectStates) {
995
+ projectSeenOnce.add(p.name);
996
+ }
997
+ firstLoadDone = true;
998
+
999
+ // Update project count
1000
+ const online = projectStates.filter(p => p.online).length;
1001
+ document.getElementById('project-count').textContent =
1002
+ `${online}/${projectStates.length} ONLINE`;
1003
+ }
1004
+
1005
+ // ── Real-time event reactions (pushed instantly from Telegram/actions) ────────
1006
+ function onHubEvent(projectName, event, data) {
1007
+ // Map event → per-role reactions { role: [state, bubbleText, durationSec] }
1008
+ const REACTIONS = {
1009
+ // User actions
1010
+ 'user_action:approve': {
1011
+ pm: ['celebrating', '✅ APPROVED!', 4],
1012
+ arch: ['celebrating', '🎉 Let\'s go!', 4],
1013
+ coder: ['celebrating', '🙌 YES!!!', 4],
1014
+ deployer: ['waiting_input', '🚛 Ready!', 4],
1015
+ },
1016
+ 'user_action:reject': {
1017
+ pm: ['frustrated', '❌ REJECTED', 3],
1018
+ arch: ['idle', '😬 Oops...', 2],
1019
+ coder: ['fighting_bug', '😭 Again?!', 5],
1020
+ deployer: ['idle', '⏳ Waiting', 2],
1021
+ },
1022
+ 'user_action:deploy_confirm': {
1023
+ deployer: ['deploying', '🚀 DEPLOYING!', 6],
1024
+ coder: ['celebrating', '🙏 Finally!', 4],
1025
+ },
1026
+ 'user_action:feature_submitted': {
1027
+ pm: ['working', `📋 NEW TASK!`, 5],
1028
+ arch: ['idle', '👀 Incoming', 3],
1029
+ coder: ['idle', '☕ BRB...', 2],
1030
+ },
1031
+ // Pipeline events
1032
+ 'feature_approved': {
1033
+ pm: ['celebrating', '✅ APPROVED!', 4],
1034
+ arch: ['celebrating', '🎉 Let\'s go!', 4],
1035
+ coder: ['celebrating', '🙌 YES!!!', 4],
1036
+ },
1037
+ 'feature_rejected': {
1038
+ pm: ['frustrated', '❌ REJECTED', 3],
1039
+ coder: ['fighting_bug','😭 Fix time!', 5],
1040
+ },
1041
+ 'deploy_success': {
1042
+ deployer: ['celebrating', '🚀 SHIPPED!', 5],
1043
+ pm: ['celebrating', '🎉 Amazing!', 4],
1044
+ coder: ['celebrating', '🥳 We did it!',4],
1045
+ },
1046
+ 'deploy_failed': {
1047
+ deployer: ['frustrated', '💥 FAILED!', 4],
1048
+ pm: ['frustrated', '😱 Oh no!', 3],
1049
+ },
1050
+ 'feature_created': {
1051
+ pm: ['working', '📋 NEW TASK!', 5],
1052
+ },
1053
+ 'review_complete': {
1054
+ pm: ['waiting_input','🔍 Review done', 4],
1055
+ },
1056
+ 'autopilot_toggled': {
1057
+ pm: ['celebrating', data?.mode === 'auto' ? '🤖 AUTO ON!' : '👋 MANUAL', 3],
1058
+ },
1059
+ };
1060
+
1061
+ // Support "user_action:<action>" composite key
1062
+ let key = event;
1063
+ if (event === 'user_action' && data?.action) key = `user_action:${data.action}`;
1064
+
1065
+ const reactionMap = REACTIONS[key];
1066
+ if (!reactionMap) return;
1067
+
1068
+ // Apply reactions only to chars in this specific project
1069
+ for (const [role, [state, bubble, dur]] of Object.entries(reactionMap)) {
1070
+ const char = characters.get(`${projectName}:${role}`);
1071
+ if (char) char.react(state, bubble, dur);
1072
+ }
1073
+
1074
+ // Sound effects for events
1075
+ if (soundFx) {
1076
+ if (key.includes('approve') || key === 'feature_approved') soundFx.playNotification();
1077
+ if (key.includes('reject') || key === 'feature_rejected') soundFx.playError();
1078
+ if (key === 'deploy_success') soundFx.playAchievement();
1079
+ if (key === 'deploy_failed') soundFx.playError();
1080
+ }
1081
+
1082
+ // Show a brief notification banner
1083
+ if (effects) {
1084
+ const labels = {
1085
+ 'user_action:approve': '✅ User approved!',
1086
+ 'user_action:reject': '❌ User rejected',
1087
+ 'user_action:deploy_confirm': '🚀 Deploying...',
1088
+ 'user_action:feature_submitted':'📋 New task submitted',
1089
+ 'feature_approved': '✅ Approved',
1090
+ 'feature_rejected': '❌ Rejected — fixing...',
1091
+ 'deploy_success': '🎉 Deployed successfully!',
1092
+ 'deploy_failed': '💥 Deploy failed',
1093
+ };
1094
+ const label = labels[key];
1095
+ if (label) effects.showBanner(label, `${projectName}${data?.username ? ' (by @' + data.username + ')' : ''}`, 3);
1096
+ }
1097
+
1098
+ addActivity(projectName, `[${event}]${data?.username ? ' @' + data.username : ''}`);
1099
+ }
1100
+
1101
+ function stageToMessage(stage, feature) {
1102
+ const msgs = {
1103
+ idle: 'All agents idle',
1104
+ spec: 'PM writing spec...',
1105
+ spec_complete: 'Spec complete!',
1106
+ architecture: 'Architect designing...',
1107
+ arch_complete: 'Architecture ready!',
1108
+ implementation: 'Coder implementing...',
1109
+ implementation_complete: 'Implementation done!',
1110
+ review: 'Code review in progress...',
1111
+ review_complete: 'Review complete',
1112
+ approved: 'Approved!',
1113
+ deploying: 'Deploying...',
1114
+ deployed: 'Deployed successfully!',
1115
+ rejected: 'Rejected - fixing bugs...',
1116
+ };
1117
+ const suffix = feature ? ` [${feature}]` : '';
1118
+ return (msgs[stage] || `Stage: ${stage}`) + suffix;
1119
+ }
1120
+
1121
+ // ── Character Creation ──────────────────────────────────────────────────────
1122
+
1123
+ function createCharacters() {
1124
+ characters.clear();
1125
+
1126
+ // Default team colors if config doesn't supply one
1127
+ const DEFAULT_TEAM_COLORS = ['#4A90D9', '#27AE60', '#E8A020', '#D04040', '#8050D0'];
1128
+
1129
+ projectConfigs.forEach((config, projIdx) => {
1130
+ const positions = layout.getCharacterPositions(config.name);
1131
+ const teamColor = config.color || DEFAULT_TEAM_COLORS[projIdx % DEFAULT_TEAM_COLORS.length];
1132
+
1133
+ for (const role of ['pm', 'arch', 'coder', 'deployer']) {
1134
+ const pos = positions[role] || { x: 0, y: 0 };
1135
+ const char = new Character(role, config.name, pos.x, pos.y);
1136
+ char.setHome(pos.x, pos.y);
1137
+ char.projectColor = teamColor;
1138
+ char.projectIndex = projIdx;
1139
+ characters.set(`${config.name}:${role}`, char);
1140
+ }
1141
+ });
1142
+ }
1143
+
1144
+ // ── UI Interactions ─────────────────────────────────────────────────────────
1145
+
1146
+ function showCharPopup(char, screenX, screenY) {
1147
+ const popup = document.getElementById('char-popup');
1148
+ if (!popup) return;
1149
+
1150
+ const info = char.getInfo();
1151
+ const projectState = projectStates.find(p => p.name === char.project);
1152
+ const projConfig = projectConfigs.find(c => c.name === char.project);
1153
+ const projUrl = projConfig?.url || '';
1154
+
1155
+ document.getElementById('popup-title').textContent = `${info.role} (${info.ai})`;
1156
+ document.getElementById('popup-project').textContent = `${projConfig?.icon || ''} ${info.project}`.trim();
1157
+ document.getElementById('popup-status').textContent = formatState(info.state);
1158
+ document.getElementById('popup-model').textContent = info.ai;
1159
+ document.getElementById('popup-task').textContent = projectState?.status?.current_feature || '—';
1160
+ document.getElementById('popup-duration').textContent = formatDuration(char.stateTime);
1161
+
1162
+ // Extra details
1163
+ const stage = projectState?.status?.stage || '';
1164
+ const extra = document.getElementById('popup-extra');
1165
+ if (extra) {
1166
+ const parts = [];
1167
+ if (stage) parts.push(`Stage: ${stage}`);
1168
+ if (info.idleBehavior) parts.push(`Idle: ${info.idleBehavior}`);
1169
+ if (char.workBubble) parts.push(`Thinking: ${char.workBubble}`);
1170
+ const rejReason = projectState?.status?.rejection_reason;
1171
+ if (rejReason && info.state === 'fighting_bug') parts.push(`Last reject: ${rejReason.slice(0, 40)}`);
1172
+ extra.textContent = parts.join(' · ');
1173
+ }
1174
+
1175
+ // Show role-specific menu
1176
+ const allMenus = ['popup-pm-menu', 'popup-arch-menu', 'popup-coder-menu', 'popup-deployer-menu'];
1177
+ allMenus.forEach(id => document.getElementById(id)?.classList.add('hidden'));
1178
+
1179
+ const menuMap = { pm: 'popup-pm-menu', arch: 'popup-arch-menu', coder: 'popup-coder-menu', deployer: 'popup-deployer-menu' };
1180
+ const menuId = menuMap[char.role];
1181
+ if (menuId) document.getElementById(menuId)?.classList.remove('hidden');
1182
+
1183
+ if (char.role === 'pm') {
1184
+ clickedPMProject = char.project;
1185
+ }
1186
+
1187
+ // ── Pipeline action menu (stage-aware, shown for all roles) ──
1188
+ const pipelineMenu = document.getElementById('popup-pipeline-menu');
1189
+ if (pipelineMenu) {
1190
+ pipelineMenu.classList.remove('hidden');
1191
+ const currentStage = projectState?.status?.stage || 'idle';
1192
+
1193
+ // Define which actions are allowed per stage
1194
+ const stageActions = {
1195
+ idle: ['implement', 'cleanup', 'reset'],
1196
+ inbox: ['implement', 'cleanup', 'reset'],
1197
+ spec: ['cleanup', 'reset'],
1198
+ spec_complete: ['cleanup', 'reset'],
1199
+ architecture: ['cleanup', 'reset'],
1200
+ arch_complete: ['implement', 'cleanup', 'reset'],
1201
+ implementation: ['cleanup', 'reset'],
1202
+ implementation_complete: ['review', 'approve', 'reject', 'cleanup', 'reset'],
1203
+ review: ['cleanup', 'reset'],
1204
+ review_complete: ['approve', 'reject', 'fix', 'cleanup', 'reset'],
1205
+ approved: ['deploy', 'cleanup', 'reset'],
1206
+ deploying: ['cleanup', 'reset'],
1207
+ deployed: ['cleanup', 'reset'],
1208
+ rejected: ['fix', 'review', 'cleanup', 'reset'],
1209
+ fix: ['cleanup', 'reset'],
1210
+ };
1211
+ const allowed = new Set(stageActions[currentStage] || ['cleanup', 'reset']);
1212
+
1213
+ pipelineMenu.querySelectorAll('.btn-action').forEach(btn => {
1214
+ const action = btn.dataset.pipeline;
1215
+ if (allowed.has(action)) {
1216
+ btn.classList.remove('pipeline-hidden');
1217
+ } else {
1218
+ btn.classList.add('pipeline-hidden');
1219
+ }
1220
+ });
1221
+
1222
+ // Wire click handlers
1223
+ const projName = char.project;
1224
+ pipelineMenu.querySelectorAll('.btn-action').forEach(btn => {
1225
+ const originalLabel = btn.textContent;
1226
+ btn.onclick = async () => {
1227
+ const action = btn.dataset.pipeline;
1228
+ btn.disabled = true;
1229
+ btn.textContent = '⏳...';
1230
+ try {
1231
+ await sendAction(projName, action);
1232
+ addActivity(projName, `⚡ ${action.toUpperCase()} triggered from office UI`);
1233
+ hidePopups();
1234
+ } catch (e) {
1235
+ addActivity(projName, `❌ ${action} failed: ${e.message}`);
1236
+ } finally {
1237
+ btn.disabled = false;
1238
+ btn.textContent = originalLabel;
1239
+ }
1240
+ };
1241
+ });
1242
+ }
1243
+
1244
+ // Wire role-specific buttons
1245
+ const openLogs = () => { if (projUrl) window.open(projUrl, '_blank'); else showFeaturesPanel(char.project); };
1246
+ const openDocs = () => showFeaturesPanel(char.project);
1247
+
1248
+ // Architect
1249
+ const archDocs = document.getElementById('popup-arch-docs');
1250
+ const archLogs = document.getElementById('popup-arch-logs');
1251
+ if (archDocs) archDocs.onclick = openDocs;
1252
+ if (archLogs) archLogs.onclick = openLogs;
1253
+
1254
+ // Coder
1255
+ const coderReview = document.getElementById('popup-coder-review');
1256
+ const coderLogs = document.getElementById('popup-coder-logs');
1257
+ if (coderReview) coderReview.onclick = async () => {
1258
+ try {
1259
+ await sendAction(char.project, 'review');
1260
+ addActivity(char.project, '🔍 Review triggered from coder menu');
1261
+ hidePopups();
1262
+ } catch (e) { addActivity(char.project, `❌ Review failed: ${e.message}`); }
1263
+ };
1264
+ if (coderLogs) coderLogs.onclick = openLogs;
1265
+
1266
+ // Deployer
1267
+ const deployerDeploy = document.getElementById('popup-deployer-deploy');
1268
+ const deployerLogs = document.getElementById('popup-deployer-logs');
1269
+ if (deployerDeploy) deployerDeploy.onclick = async () => {
1270
+ try {
1271
+ await sendAction(char.project, 'deploy');
1272
+ addActivity(char.project, '🚀 Deploy triggered from deployer menu');
1273
+ hidePopups();
1274
+ } catch (e) { addActivity(char.project, `❌ Deploy failed: ${e.message}`); }
1275
+ };
1276
+ if (deployerLogs) deployerLogs.onclick = openLogs;
1277
+
1278
+ // Position popup
1279
+ const maxX = window.innerWidth - 300;
1280
+ const maxY = window.innerHeight - 300;
1281
+ popup.style.left = `${Math.min(screenX + 10, maxX)}px`;
1282
+ popup.style.top = `${Math.min(screenY - 10, maxY)}px`;
1283
+ popup.classList.remove('hidden');
1284
+ }
1285
+
1286
+ /** Send a pipeline action via REST API (routes through hub if available) */
1287
+ async function sendAction(project, action) {
1288
+ // Hub mode: route through hub proxy to avoid cross-origin issues
1289
+ if (hubBaseUrl) {
1290
+ const res = await fetch(`${hubBaseUrl}/api/projects/${encodeURIComponent(project)}/action/${encodeURIComponent(action)}`, {
1291
+ method: 'POST',
1292
+ headers: { 'Content-Type': 'application/json' },
1293
+ body: JSON.stringify({ action }),
1294
+ });
1295
+ if (!res.ok) {
1296
+ const err = await res.json().catch(() => ({}));
1297
+ throw new Error(err.error || `HTTP ${res.status}`);
1298
+ }
1299
+ return res.json();
1300
+ }
1301
+ // Standalone mode: send to local API
1302
+ const res = await fetch(`/api/${action}`, {
1303
+ method: 'POST',
1304
+ headers: { 'Content-Type': 'application/json' },
1305
+ body: JSON.stringify({ project, action }),
1306
+ });
1307
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1308
+ return res.json();
1309
+ }
1310
+
1311
+ function handlePMAction(action) {
1312
+ hidePopups();
1313
+
1314
+ if (action === 'bug' || action === 'feature') {
1315
+ // Open submit modal pre-filled
1316
+ const modal = document.getElementById('submit-modal');
1317
+ const typeSelect = document.getElementById('submit-type');
1318
+ const projectSelect = document.getElementById('submit-project');
1319
+
1320
+ if (typeSelect) typeSelect.value = action === 'bug' ? 'bug' : 'feature';
1321
+ if (projectSelect && clickedPMProject) {
1322
+ for (const opt of projectSelect.options) {
1323
+ if (opt.value === clickedPMProject) {
1324
+ opt.selected = true;
1325
+ break;
1326
+ }
1327
+ }
1328
+ }
1329
+ modal?.classList.remove('hidden');
1330
+ } else if (action === 'status') {
1331
+ const state = projectStates.find(p => p.name === clickedPMProject);
1332
+ if (state) {
1333
+ const stage = state.status?.stage || 'idle';
1334
+ const feature = state.status?.current_feature || 'none';
1335
+ addActivity(clickedPMProject, `Status check: ${stage} | Feature: ${feature}`);
1336
+ }
1337
+ }
1338
+ }
1339
+
1340
+ // Bug 4: Show package info popup
1341
+ function showPackagePopup(pkgInfo, screenX, screenY) {
1342
+ const panel = document.getElementById('features-panel');
1343
+ if (!panel) return;
1344
+
1345
+ const { project, pkg, index, total } = pkgInfo;
1346
+ const ts = new Date(pkg.time);
1347
+ const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1348
+
1349
+ document.getElementById('fp-title').textContent = `📦 ${pkg.name}`;
1350
+ document.getElementById('fp-body').innerHTML =
1351
+ `<div class="fp-row"><span class="label">Project:</span> <span>${project}</span></div>` +
1352
+ `<div class="fp-row"><span class="label">Feature:</span> <span>${pkg.name}</span></div>` +
1353
+ `<div class="fp-row"><span class="label">Shipped:</span> <span>${timeStr}</span></div>` +
1354
+ `<div class="fp-row"><span class="label">Package #:</span> <span>${index + 1} of ${total}</span></div>` +
1355
+ `<div class="fp-row"><span class="label">Status:</span> <span style="color:#40d060">✅ DEPLOYED</span></div>`;
1356
+
1357
+ const maxX = window.innerWidth - 280;
1358
+ panel.style.left = `${Math.min(screenX + 10, maxX)}px`;
1359
+ panel.style.top = `${Math.min(screenY - 10, window.innerHeight - 220)}px`;
1360
+ panel.classList.remove('hidden');
1361
+ }
1362
+
1363
+ // Bug 6: Show features history panel
1364
+ function showFeaturesPanel(projectName) {
1365
+ const panel = document.getElementById('features-panel');
1366
+ if (!panel) return;
1367
+
1368
+ const state = projectStates.find(p => p.name === projectName);
1369
+ const history = state?.status?.history || [];
1370
+ const current = state?.status?.current_feature || '';
1371
+ const stage = state?.status?.stage || 'idle';
1372
+
1373
+ document.getElementById('fp-title').textContent = `📋 ${projectName} — Features`;
1374
+
1375
+ const rows = [];
1376
+ if (current) {
1377
+ rows.push(`<div class="fp-row fp-current"><span class="label">🔄 Active:</span> <span>${current} [${stage}]</span></div>`);
1378
+ }
1379
+ if (history.length) {
1380
+ rows.push(`<div class="fp-row"><span class="label">History (${history.length}):</span></div>`);
1381
+ for (const h of [...history].reverse().slice(0, 12)) {
1382
+ const icon = h.stage === 'deployed' ? '✅' : h.stage === 'rejected' ? '❌' : '⏳';
1383
+ rows.push(`<div class="fp-row">${icon} <span style="font-size:9px">${h.id || h}</span></div>`);
1384
+ }
1385
+ } else {
1386
+ rows.push(`<div class="fp-row">No history yet.</div>`);
1387
+ }
1388
+ document.getElementById('fp-body').innerHTML = rows.join('');
1389
+
1390
+ panel.style.left = '50%';
1391
+ panel.style.top = '80px';
1392
+ panel.style.transform = 'translateX(-50%)';
1393
+ panel.classList.remove('hidden');
1394
+ }
1395
+
1396
+ function formatState(state) {
1397
+ const stateNames = {
1398
+ sleeping: 'Sleeping',
1399
+ walking: 'Walking',
1400
+ working: 'Working',
1401
+ thinking: 'Thinking',
1402
+ reviewing: 'Reviewing code',
1403
+ designing: 'Designing architecture',
1404
+ fighting_bug: 'Fighting bugs!',
1405
+ celebrating: 'Celebrating!',
1406
+ frustrated: 'Frustrated',
1407
+ rate_limited: 'Rate limited',
1408
+ deploying: 'Deploying',
1409
+ waiting_input: 'Waiting for input',
1410
+ handing_off: 'Handing off work',
1411
+ coffee_break: 'Coffee break',
1412
+ };
1413
+ return stateNames[state] || state;
1414
+ }
1415
+
1416
+ function formatDuration(seconds) {
1417
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
1418
+ const m = Math.floor(seconds / 60);
1419
+ const s = Math.floor(seconds % 60);
1420
+ return `${m}m ${s}s`;
1421
+ }
1422
+
1423
+ function showTeamPanel(projectName, _config) {
1424
+ const state = projectStates.find(p => p.name === projectName);
1425
+ const stage = state?.status?.stage || 'idle';
1426
+ const feature = state?.status?.current_feature || 'none';
1427
+ addActivity(projectName, `Team overview: ${formatState(stage)} | ${feature}`);
1428
+ }
1429
+
1430
+ function hidePopups() {
1431
+ document.getElementById('char-popup')?.classList.add('hidden');
1432
+ }
1433
+
1434
+ function updateProjectDropdown() {
1435
+ const select = document.getElementById('submit-project');
1436
+ if (!select) return;
1437
+ select.innerHTML = '';
1438
+ for (const config of projectConfigs) {
1439
+ const opt = document.createElement('option');
1440
+ opt.value = config.name;
1441
+ opt.textContent = `${config.icon || ''} ${config.name}`.trim();
1442
+ select.appendChild(opt);
1443
+ }
1444
+ }
1445
+
1446
+ async function submitFeature() {
1447
+ const project = document.getElementById('submit-project')?.value;
1448
+ const type = document.getElementById('submit-type')?.value;
1449
+ const mode = document.getElementById('submit-mode')?.value;
1450
+ const desc = document.getElementById('submit-desc')?.value?.trim();
1451
+
1452
+ if (!project || !desc) return;
1453
+
1454
+ try {
1455
+ // Hub mode: route to the specific project via hub
1456
+ let featureUrl = '/api/feature';
1457
+ if (hubBaseUrl) {
1458
+ featureUrl = `${hubBaseUrl}/api/projects/${encodeURIComponent(project)}/feature`;
1459
+ }
1460
+ const res = await fetch(featureUrl, {
1461
+ method: 'POST',
1462
+ headers: { 'Content-Type': 'application/json' },
1463
+ body: JSON.stringify({ description: desc, mode, type }),
1464
+ });
1465
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1466
+ addActivity(project, `Submitted ${type}: ${desc.slice(0, 60)}...`);
1467
+ document.getElementById('submit-modal')?.classList.add('hidden');
1468
+ document.getElementById('submit-desc').value = '';
1469
+ if (soundFx) soundFx.playNotification();
1470
+ } catch (e) {
1471
+ addActivity(project, `Submit failed: ${e.message}`);
1472
+ }
1473
+ }
1474
+
1475
+ // ── Activity Feed ───────────────────────────────────────────────────────────
1476
+
1477
+ function addActivity(project, message) {
1478
+ const now = new Date();
1479
+ const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
1480
+
1481
+ activityLog.unshift({ time, project, message });
1482
+ if (activityLog.length > 50) activityLog.length = 50;
1483
+
1484
+ const container = document.getElementById('feed-entries');
1485
+ if (!container) return;
1486
+
1487
+ const entry = document.createElement('div');
1488
+ entry.className = 'feed-entry';
1489
+ entry.innerHTML = `<span class="time">[${time}]</span> <span class="project">${project}:</span> <span class="msg">${message}</span>`;
1490
+ container.insertBefore(entry, container.firstChild);
1491
+
1492
+ while (container.children.length > 30) {
1493
+ container.removeChild(container.lastChild);
1494
+ }
1495
+ }
1496
+
1497
+ // ── Slash Command System ────────────────────────────────────────────────────
1498
+
1499
+ const SLASH_COMMANDS = [
1500
+ { cmd: '/status', icon: '\u{1F4CA}', desc: 'Show pipeline status' },
1501
+ { cmd: '/feature', icon: '\u{2728}', desc: 'Submit a new feature' },
1502
+ { cmd: '/approve', icon: '\u{2705}', desc: 'Approve current feature' },
1503
+ { cmd: '/reject', icon: '\u{274C}', desc: 'Reject current feature' },
1504
+ { cmd: '/deploy', icon: '\u{1F680}', desc: 'Deploy approved feature' },
1505
+ { cmd: '/reset', icon: '\u{1F504}', desc: 'Reset pipeline to idle' },
1506
+ { cmd: '/review', icon: '\u{1F50D}', desc: 'Trigger code review' },
1507
+ { cmd: '/health', icon: '\u{1F49A}', desc: 'Check system health' },
1508
+ { cmd: '/costs', icon: '\u{1F4B0}', desc: 'Show token costs' },
1509
+ { cmd: '/help', icon: '\u{2753}', desc: 'Show all commands' },
1510
+ ];
1511
+
1512
+ let cmdSelectedIndex = 0;
1513
+
1514
+ function initCommandBar() {
1515
+ const bar = document.getElementById('command-bar');
1516
+ const input = document.getElementById('command-input');
1517
+ const dropdown = document.getElementById('command-dropdown');
1518
+ if (!bar || !input || !dropdown) return;
1519
+
1520
+ // Global "/" key opens command bar (only when not already in a text input)
1521
+ document.addEventListener('keydown', (e) => {
1522
+ if (e.key === '/' && !isInTextInput(e.target)) {
1523
+ e.preventDefault();
1524
+ showCommandBar();
1525
+ }
1526
+ });
1527
+
1528
+ // Input events
1529
+ input.addEventListener('input', () => {
1530
+ const val = input.value;
1531
+ if (val.startsWith('/')) {
1532
+ const filtered = filterCommands(val);
1533
+ renderDropdown(filtered);
1534
+ } else {
1535
+ hideDropdown();
1536
+ }
1537
+ });
1538
+
1539
+ input.addEventListener('keydown', (e) => {
1540
+ const items = dropdown.querySelectorAll('.command-dropdown-item');
1541
+
1542
+ if (e.key === 'Escape') {
1543
+ e.preventDefault();
1544
+ hideCommandBar();
1545
+ return;
1546
+ }
1547
+
1548
+ if (e.key === 'ArrowDown') {
1549
+ e.preventDefault();
1550
+ cmdSelectedIndex = Math.min(cmdSelectedIndex + 1, items.length - 1);
1551
+ updateActiveItem(items);
1552
+ return;
1553
+ }
1554
+
1555
+ if (e.key === 'ArrowUp') {
1556
+ e.preventDefault();
1557
+ cmdSelectedIndex = Math.max(cmdSelectedIndex - 1, 0);
1558
+ updateActiveItem(items);
1559
+ return;
1560
+ }
1561
+
1562
+ if (e.key === 'Enter') {
1563
+ e.preventDefault();
1564
+ const activeItem = items[cmdSelectedIndex];
1565
+ if (activeItem) {
1566
+ executeSlashCommand(activeItem.dataset.cmd);
1567
+ } else {
1568
+ // Try to match typed text directly
1569
+ const val = input.value.trim().toLowerCase();
1570
+ const match = SLASH_COMMANDS.find(c => c.cmd === val);
1571
+ if (match) executeSlashCommand(match.cmd);
1572
+ }
1573
+ return;
1574
+ }
1575
+
1576
+ if (e.key === 'Tab') {
1577
+ e.preventDefault();
1578
+ const activeItem = items[cmdSelectedIndex];
1579
+ if (activeItem) {
1580
+ input.value = activeItem.dataset.cmd;
1581
+ const filtered = filterCommands(input.value);
1582
+ renderDropdown(filtered);
1583
+ }
1584
+ }
1585
+ });
1586
+
1587
+ // Click outside closes command bar
1588
+ document.addEventListener('mousedown', (e) => {
1589
+ if (bar.style.display !== 'none' && !bar.contains(e.target)) {
1590
+ hideCommandBar();
1591
+ }
1592
+ });
1593
+ }
1594
+
1595
+ function isInTextInput(el) {
1596
+ const tag = el.tagName?.toLowerCase();
1597
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
1598
+ }
1599
+
1600
+ function showCommandBar() {
1601
+ const bar = document.getElementById('command-bar');
1602
+ const input = document.getElementById('command-input');
1603
+ bar.style.display = 'block';
1604
+ input.value = '/';
1605
+ input.focus();
1606
+ // Set cursor to end
1607
+ input.setSelectionRange(1, 1);
1608
+ const filtered = filterCommands('/');
1609
+ renderDropdown(filtered);
1610
+ }
1611
+
1612
+ function hideCommandBar() {
1613
+ const bar = document.getElementById('command-bar');
1614
+ const input = document.getElementById('command-input');
1615
+ bar.style.display = 'none';
1616
+ input.value = '';
1617
+ hideDropdown();
1618
+ }
1619
+
1620
+ function filterCommands(text) {
1621
+ const query = text.slice(1).toLowerCase(); // remove leading "/"
1622
+ if (!query) return SLASH_COMMANDS;
1623
+ return SLASH_COMMANDS.filter(c =>
1624
+ c.cmd.slice(1).includes(query) || c.desc.toLowerCase().includes(query)
1625
+ );
1626
+ }
1627
+
1628
+ function renderDropdown(commands) {
1629
+ const dropdown = document.getElementById('command-dropdown');
1630
+ if (commands.length === 0) {
1631
+ dropdown.innerHTML = '<div class="command-dropdown-hint">NO MATCHING COMMANDS</div>';
1632
+ dropdown.classList.add('visible');
1633
+ return;
1634
+ }
1635
+
1636
+ cmdSelectedIndex = 0;
1637
+ dropdown.innerHTML = commands.map((c, i) =>
1638
+ `<div class="command-dropdown-item${i === 0 ? ' active' : ''}" data-cmd="${c.cmd}">
1639
+ <span class="cmd-icon">${c.icon}</span>
1640
+ <div class="cmd-info">
1641
+ <span class="cmd-name">${c.cmd}</span>
1642
+ <span class="cmd-desc">${c.desc}</span>
1643
+ </div>
1644
+ </div>`
1645
+ ).join('') + '<div class="command-dropdown-hint">ENTER to select / ESC to close</div>';
1646
+
1647
+ dropdown.classList.add('visible');
1648
+
1649
+ // Click handlers for dropdown items
1650
+ dropdown.querySelectorAll('.command-dropdown-item').forEach(item => {
1651
+ item.addEventListener('click', () => {
1652
+ executeSlashCommand(item.dataset.cmd);
1653
+ });
1654
+ });
1655
+ }
1656
+
1657
+ function hideDropdown() {
1658
+ const dropdown = document.getElementById('command-dropdown');
1659
+ dropdown.classList.remove('visible');
1660
+ dropdown.innerHTML = '';
1661
+ }
1662
+
1663
+ function updateActiveItem(items) {
1664
+ items.forEach((item, i) => {
1665
+ item.classList.toggle('active', i === cmdSelectedIndex);
1666
+ });
1667
+ // Scroll active item into view
1668
+ const active = items[cmdSelectedIndex];
1669
+ if (active) active.scrollIntoView({ block: 'nearest' });
1670
+ }
1671
+
1672
+ async function executeSlashCommand(cmd) {
1673
+ hideCommandBar();
1674
+
1675
+ // Determine which project to target (first available or clicked PM project)
1676
+ const project = clickedPMProject || projectConfigs[0]?.name || 'default';
1677
+
1678
+ switch (cmd) {
1679
+ case '/feature': {
1680
+ // Open the submit modal for feature input
1681
+ const modal = document.getElementById('submit-modal');
1682
+ const typeSelect = document.getElementById('submit-type');
1683
+ if (typeSelect) typeSelect.value = 'feature';
1684
+ modal?.classList.remove('hidden');
1685
+ // Focus the description field
1686
+ setTimeout(() => document.getElementById('submit-desc')?.focus(), 100);
1687
+ return;
1688
+ }
1689
+
1690
+ case '/help': {
1691
+ // Show all commands in activity feed
1692
+ addActivity('System', '--- SLASH COMMANDS ---');
1693
+ for (const c of SLASH_COMMANDS) {
1694
+ addActivity('System', `${c.icon} ${c.cmd} - ${c.desc}`);
1695
+ }
1696
+ return;
1697
+ }
1698
+
1699
+ case '/status': {
1700
+ // Show status for all projects
1701
+ if (projectStates.length === 0) {
1702
+ addActivity('System', 'No projects connected');
1703
+ return;
1704
+ }
1705
+ for (const state of projectStates) {
1706
+ const stage = state.status?.stage || 'idle';
1707
+ const feature = state.status?.current_feature || 'none';
1708
+ addActivity(state.name, `Status: ${stage} | Feature: ${feature}`);
1709
+ }
1710
+ return;
1711
+ }
1712
+
1713
+ case '/health': {
1714
+ // Fetch health from API
1715
+ try {
1716
+ const url = hubBaseUrl ? `${hubBaseUrl}/api/health` : '/api/health';
1717
+ const res = await fetch(url);
1718
+ const data = await res.json();
1719
+ addActivity('System', `Health: ${JSON.stringify(data).slice(0, 120)}`);
1720
+ } catch (e) {
1721
+ addActivity('System', `Health check failed: ${e.message}`);
1722
+ }
1723
+ return;
1724
+ }
1725
+
1726
+ case '/costs': {
1727
+ // Fetch costs from API
1728
+ try {
1729
+ const url = hubBaseUrl ? `${hubBaseUrl}/api/costs` : '/api/costs';
1730
+ const res = await fetch(url);
1731
+ const data = await res.json();
1732
+ const total = data.totalCost != null ? `$${data.totalCost.toFixed(4)}` : JSON.stringify(data).slice(0, 100);
1733
+ addActivity('System', `Token costs: ${total}`);
1734
+ } catch (e) {
1735
+ addActivity('System', `Costs fetch failed: ${e.message}`);
1736
+ }
1737
+ return;
1738
+ }
1739
+
1740
+ // Simple action commands: approve, reject, deploy, reset, review
1741
+ default: {
1742
+ const action = cmd.slice(1); // remove "/"
1743
+ try {
1744
+ addActivity(project, `${cmd} triggered from command bar`);
1745
+ await sendAction(project, action);
1746
+ addActivity(project, `${cmd} completed`);
1747
+ if (soundFx) soundFx.playNotification();
1748
+ } catch (e) {
1749
+ addActivity(project, `${cmd} failed: ${e.message}`);
1750
+ }
1751
+ return;
1752
+ }
1753
+ }
1754
+ }
1755
+
1756
+ // ── Boot ────────────────────────────────────────────────────────────────────
1757
+ document.addEventListener('DOMContentLoaded', init);