refacil-sdd-ai 2.8.2 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -679,11 +679,39 @@ refacil-sdd-ai bus start # Arranca el broker (opcional; las skills
679
679
  refacil-sdd-ai bus stop # Detiene el broker
680
680
  refacil-sdd-ai bus status # Puerto, pid, uptime
681
681
  refacil-sdd-ai bus rooms # Salas activas + miembros
682
- refacil-sdd-ai bus watch <session> # Panel en vivo (sin tokens)
682
+ refacil-sdd-ai bus watch <session> # Panel en vivo en terminal (sin tokens)
683
+ refacil-sdd-ai bus view # Abre la UI web en el navegador (sin tokens)
683
684
  refacil-sdd-ai bus leave # Salir de la sala (limpieza manual)
684
685
  refacil-sdd-ai bus history [--n N] # Ultimos N mensajes
685
686
  ```
686
687
 
688
+ ### Vista web en vivo — `bus view`
689
+
690
+ Ademas del panel en terminal (`bus watch`), refacil-bus incluye una UI web minimalista. Se abre con:
691
+
692
+ ```bash
693
+ refacil-sdd-ai bus view
694
+ ```
695
+
696
+ El broker (que ya expone el puerto `127.0.0.1:7821` para WebSocket) tambien sirve HTTP desde ahi — **cero deps nuevas, cero servicios externos**. El comando:
697
+
698
+ 1. Auto-arranca el broker si no estaba corriendo
699
+ 2. Abre el navegador por default del OS en `http://127.0.0.1:7821/`
700
+ 3. Imprime la URL por stdout por si el navegador no abre solo
701
+
702
+ **Que muestra la UI**:
703
+
704
+ - Sidebar con lista de salas activas y sus miembros
705
+ - Feed en vivo con todos los mensajes, coloreados por tipo (`ask` cian, `reply` verde, `say` ambar, `system` gris)
706
+ - Indicador visual de menciones dirigidas (`→ @<session>`)
707
+ - Emparejamiento ask/reply: cada pregunta muestra `pending` hasta que llega la respuesta con su `correlationId`, entonces cambia a `answered`
708
+ - Filtros por tipo (ocultar system, ver solo ask/reply, etc.)
709
+ - Totalizadores fijos abajo: mensajes recibidos, pares cerrados, preguntas pendientes
710
+ - Selector de sala o vista "todas las salas" en la sidebar
711
+ - Auto-scroll al ultimo mensaje; si haces scroll arriba para ver historial, no te interrumpe con los nuevos
712
+
713
+ **Util para**: presentar la feature al equipo, hacer demos didacticas, supervisar sesiones en paralelo mientras trabajas, ver en tiempo real que pasa cuando dos agentes conversan. **No consume tokens** — es un observador puro vía WebSocket read-only.
714
+
687
715
  ### Presentacion automatica al hacer join
688
716
 
689
717
  Al unirse a una sala, cada sesion envia un mensaje de presentacion. Para que sea util, la skill `/refacil:join` instruye al LLM a generar un bloque en `AGENTS.md` la primera vez, entre los marcadores:
package/bin/cli.js CHANGED
@@ -1097,6 +1097,44 @@ async function busWatchCmd(positional, args) {
1097
1097
  }
1098
1098
  }
1099
1099
 
1100
+ function openInBrowser(url) {
1101
+ const { spawn } = require('child_process');
1102
+ const platform = process.platform;
1103
+ let cmd;
1104
+ let args;
1105
+ if (platform === 'win32') {
1106
+ cmd = 'cmd';
1107
+ args = ['/c', 'start', '""', url];
1108
+ } else if (platform === 'darwin') {
1109
+ cmd = 'open';
1110
+ args = [url];
1111
+ } else {
1112
+ cmd = 'xdg-open';
1113
+ args = [url];
1114
+ }
1115
+ try {
1116
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
1117
+ return true;
1118
+ } catch (_) {
1119
+ return false;
1120
+ }
1121
+ }
1122
+
1123
+ async function busView() {
1124
+ try {
1125
+ const { info } = await busSpawn.ensureBroker(packageRoot);
1126
+ const url = `http://127.0.0.1:${info.port}/`;
1127
+ console.log(` refacil-bus view disponible en: ${url}`);
1128
+ const opened = openInBrowser(url);
1129
+ if (!opened) {
1130
+ console.log(' (no se pudo abrir el navegador automáticamente, abre la URL manualmente)');
1131
+ }
1132
+ } catch (err) {
1133
+ console.error(` No se pudo iniciar la vista: ${err.message}`);
1134
+ process.exit(1);
1135
+ }
1136
+ }
1137
+
1100
1138
  async function busRooms() {
1101
1139
  const { ws } = await connectOrDie();
1102
1140
  const reply = await busClient.sendAndWait(
@@ -1171,8 +1209,11 @@ function handleBusSubcommand(sub) {
1171
1209
  case 'attend':
1172
1210
  busAttend(args);
1173
1211
  break;
1212
+ case 'view':
1213
+ busView();
1214
+ break;
1174
1215
  default:
1175
- console.log('Uso: refacil-sdd-ai bus <start|stop|status|serve|join|leave|say|ask|reply|history|inbox|rooms|watch|attend>');
1216
+ console.log('Uso: refacil-sdd-ai bus <start|stop|status|serve|join|leave|say|ask|reply|history|inbox|rooms|watch|attend|view>');
1176
1217
  }
1177
1218
  }
1178
1219
 
@@ -1206,6 +1247,7 @@ function help() {
1206
1247
  bus rooms
1207
1248
  bus watch <session> [--room <sala>] (panel en vivo, sin tokens)
1208
1249
  bus attend [--timeout N] (escucha preguntas dirigidas)
1250
+ bus view (abre la UI web en el navegador)
1209
1251
  clean Elimina skills y remueve hooks SDD-AI de .claude/settings.json
1210
1252
  help Muestra esta ayuda
1211
1253
 
package/lib/bus/broker.js CHANGED
@@ -139,10 +139,11 @@ function broadcast(state, roomName, msg, exceptSession = null) {
139
139
  for (const sockets of s.wss) send(sockets, { type: 'msg', ...msg });
140
140
  }
141
141
  }
142
- // Watchers no están en rooms, pero sí en sessions con watchOnly=true
142
+ // Watchers no están en rooms, pero sí en sessions con watchOnly=true.
143
+ // Si watchRoom es null, recibe msgs de TODAS las salas (modo UI / observador global).
143
144
  for (const [, s] of state.sessions.entries()) {
144
145
  if (!s.watchOnly) continue;
145
- if (s.watchRoom && s.watchRoom === roomName) {
146
+ if (s.watchRoom === null || s.watchRoom === undefined || s.watchRoom === roomName) {
146
147
  if (s.ws) send(s.ws, { type: 'msg', ...msg });
147
148
  }
148
149
  }
@@ -445,6 +446,39 @@ function onClose(state, ws) {
445
446
  // "estar unido" sobrevive entre invocaciones. Solo `leave` explícito saca.
446
447
  }
447
448
 
449
+ const UI_DIR = path.join(__dirname, 'ui');
450
+ const UI_MIME = {
451
+ '.html': 'text/html; charset=utf-8',
452
+ '.css': 'text/css; charset=utf-8',
453
+ '.js': 'application/javascript; charset=utf-8',
454
+ '.svg': 'image/svg+xml',
455
+ '.png': 'image/png',
456
+ '.ico': 'image/x-icon',
457
+ };
458
+
459
+ function serveUi(req, res) {
460
+ let urlPath = req.url.split('?')[0];
461
+ if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
462
+ const safePath = urlPath.replace(/^\/+/, '').replace(/\.\./g, '');
463
+ const filePath = path.join(UI_DIR, safePath);
464
+ if (!filePath.startsWith(UI_DIR)) {
465
+ res.writeHead(403);
466
+ res.end('forbidden');
467
+ return;
468
+ }
469
+ fs.readFile(filePath, (err, data) => {
470
+ if (err) {
471
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
472
+ res.end('not found');
473
+ return;
474
+ }
475
+ const ext = path.extname(filePath).toLowerCase();
476
+ const mime = UI_MIME[ext] || 'application/octet-stream';
477
+ res.writeHead(200, { 'content-type': mime });
478
+ res.end(data);
479
+ });
480
+ }
481
+
448
482
  async function start() {
449
483
  if (!WebSocketServer) {
450
484
  throw new Error(
@@ -472,7 +506,14 @@ async function start() {
472
506
  // sin persistencia previa
473
507
  }
474
508
 
475
- const server = http.createServer();
509
+ const server = http.createServer((req, res) => {
510
+ if (req.method === 'GET') {
511
+ serveUi(req, res);
512
+ return;
513
+ }
514
+ res.writeHead(405, { 'content-type': 'text/plain' });
515
+ res.end('method not allowed');
516
+ });
476
517
 
477
518
  const port = await pickPort(server);
478
519
 
@@ -0,0 +1,302 @@
1
+ (function () {
2
+ const els = {
3
+ connDot: document.getElementById('conn-dot'),
4
+ connLabel: document.getElementById('conn-label'),
5
+ port: document.getElementById('port'),
6
+ roomsList: document.getElementById('rooms-list'),
7
+ roomsCount: document.getElementById('rooms-count'),
8
+ msgCount: document.getElementById('msg-count'),
9
+ pairCount: document.getElementById('pair-count'),
10
+ pendingCount: document.getElementById('pending-count'),
11
+ feedTitle: document.getElementById('feed-title'),
12
+ feedBody: document.getElementById('feed-body'),
13
+ feedEmpty: document.getElementById('feed-empty'),
14
+ clearBtn: document.getElementById('clear-btn'),
15
+ filters: {
16
+ ask: document.getElementById('filter-ask'),
17
+ reply: document.getElementById('filter-reply'),
18
+ say: document.getElementById('filter-say'),
19
+ system: document.getElementById('filter-system'),
20
+ },
21
+ };
22
+
23
+ const state = {
24
+ ws: null,
25
+ port: location.port || '7821',
26
+ messages: [],
27
+ messageIds: new Set(),
28
+ rooms: {},
29
+ knownRooms: new Set(),
30
+ selectedRoom: '*',
31
+ askIndex: new Map(),
32
+ answeredCorrelations: new Set(),
33
+ msgCount: 0,
34
+ };
35
+
36
+ els.port.textContent = 'puerto ' + state.port;
37
+
38
+ function setConn(kind, label) {
39
+ els.connDot.className = 'dot ' + (kind || '');
40
+ els.connLabel.textContent = label;
41
+ }
42
+
43
+ function formatTime(ts) {
44
+ try {
45
+ const d = new Date(ts);
46
+ return d.toLocaleTimeString('es', { hour12: false });
47
+ } catch (_) {
48
+ return ts || '';
49
+ }
50
+ }
51
+
52
+ function kindLabel(m) {
53
+ if (m.kind === 'ask') return 'ask';
54
+ if (m.kind === 'reply') return 'reply';
55
+ if (m.kind === 'broadcast') return 'say';
56
+ return m.kind || 'msg';
57
+ }
58
+
59
+ function filterAllows(m) {
60
+ if (m.kind === 'ask') return els.filters.ask.checked;
61
+ if (m.kind === 'reply') return els.filters.reply.checked;
62
+ if (m.kind === 'broadcast') return els.filters.say.checked;
63
+ if (m.kind === 'system') return els.filters.system.checked;
64
+ return true;
65
+ }
66
+
67
+ function roomMatches(m) {
68
+ return state.selectedRoom === '*' || m.room === state.selectedRoom;
69
+ }
70
+
71
+ function renderMsg(m) {
72
+ if (!roomMatches(m)) return null;
73
+ if (!filterAllows(m)) return null;
74
+ const div = document.createElement('div');
75
+ const classes = ['msg', m.kind || 'msg'];
76
+ // mention: lo marcamos si `to` coincide con alguien conocido de la sala
77
+ // (simple: si hay `to` lo consideramos mention visual).
78
+ if (m.to) classes.push('mention');
79
+ div.className = classes.join(' ');
80
+ div.dataset.corr = m.correlationId || '';
81
+ div.dataset.kind = m.kind || '';
82
+
83
+ const head = document.createElement('div');
84
+ head.className = 'msg-head';
85
+ const left = document.createElement('div');
86
+ left.innerHTML =
87
+ `<span class="msg-from">${escape(m.from || '?')}</span>` +
88
+ (m.to ? ` <span class="msg-to">${escape(m.to)}</span>` : '') +
89
+ ` <span class="msg-kind ${m.kind || ''}">${kindLabel(m)}</span>`;
90
+ const right = document.createElement('div');
91
+ right.innerHTML =
92
+ `<span class="msg-room">${escape(m.room || '')}</span> ` +
93
+ `<span class="msg-ts">${formatTime(m.ts)}</span>`;
94
+ head.appendChild(left);
95
+ head.appendChild(right);
96
+
97
+ const body = document.createElement('div');
98
+ body.className = 'msg-text';
99
+ body.textContent = m.text || '';
100
+
101
+ div.appendChild(head);
102
+ div.appendChild(body);
103
+
104
+ if (m.correlationId) {
105
+ const corr = document.createElement('div');
106
+ const isAnswered = state.answeredCorrelations.has(m.correlationId);
107
+ corr.className = 'msg-corr ' + (m.kind === 'ask' ? (isAnswered ? 'answered' : 'pending') : '');
108
+ corr.textContent = m.correlationId.slice(0, 8);
109
+ div.appendChild(corr);
110
+ }
111
+ return div;
112
+ }
113
+
114
+ function escape(s) {
115
+ return String(s).replace(/[&<>"']/g, (c) => ({
116
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
117
+ }[c]));
118
+ }
119
+
120
+ function removeEmptyPlaceholder() {
121
+ const emp = els.feedBody.querySelector('.empty');
122
+ if (emp) emp.remove();
123
+ }
124
+
125
+ function isNearBottom(el, slack = 150) {
126
+ return el.scrollHeight - el.scrollTop - el.clientHeight < slack;
127
+ }
128
+
129
+ function scrollToBottom(el) {
130
+ el.scrollTop = el.scrollHeight;
131
+ }
132
+
133
+ function ingestMsg(m) {
134
+ if (!m || !m.id) return false;
135
+ if (state.messageIds.has(m.id)) return false;
136
+ state.messageIds.add(m.id);
137
+ state.messages.push(m);
138
+ state.msgCount++;
139
+ if (m.kind === 'ask' && m.correlationId) {
140
+ state.askIndex.set(m.correlationId, m);
141
+ }
142
+ if (m.kind === 'reply' && m.correlationId) {
143
+ state.answeredCorrelations.add(m.correlationId);
144
+ }
145
+ return true;
146
+ }
147
+
148
+ function appendMsg(m) {
149
+ if (!ingestMsg(m)) return; // dedup
150
+ if (m.kind === 'reply' && m.correlationId) {
151
+ const prev = els.feedBody.querySelector(`.msg.ask[data-corr="${m.correlationId}"] .msg-corr`);
152
+ if (prev) { prev.className = 'msg-corr answered'; prev.textContent = m.correlationId.slice(0, 8); }
153
+ }
154
+ const node = renderMsg(m);
155
+ if (node) {
156
+ const pinnedBottom = isNearBottom(els.feedBody);
157
+ removeEmptyPlaceholder();
158
+ els.feedBody.appendChild(node);
159
+ if (pinnedBottom) scrollToBottom(els.feedBody);
160
+ }
161
+ updateStats();
162
+ }
163
+
164
+ function ingestHistory(messages) {
165
+ if (!Array.isArray(messages) || messages.length === 0) return;
166
+ let added = 0;
167
+ for (const m of messages) {
168
+ if (ingestMsg(m)) added++;
169
+ }
170
+ if (!added) return;
171
+ // Re-ordenar por ts (los históricos pueden llegar mezclados con el live).
172
+ state.messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
173
+ rerenderFeed();
174
+ updateStats();
175
+ }
176
+
177
+ function updateStats() {
178
+ els.msgCount.textContent = state.msgCount;
179
+ els.pairCount.textContent = state.answeredCorrelations.size;
180
+ let pending = 0;
181
+ for (const [cid] of state.askIndex) {
182
+ if (!state.answeredCorrelations.has(cid)) pending++;
183
+ }
184
+ els.pendingCount.textContent = pending;
185
+ }
186
+
187
+ function renderRooms() {
188
+ const names = Object.keys(state.rooms);
189
+ els.roomsCount.textContent = names.length;
190
+ els.roomsList.innerHTML = '';
191
+
192
+ const liAll = document.createElement('li');
193
+ liAll.className = 'all' + (state.selectedRoom === '*' ? ' active' : '');
194
+ liAll.innerHTML = `<div class="room-name">★ todas las salas</div><div class="members">${state.msgCount} mensajes totales</div>`;
195
+ liAll.addEventListener('click', () => selectRoom('*'));
196
+ els.roomsList.appendChild(liAll);
197
+
198
+ for (const name of names.sort()) {
199
+ const members = state.rooms[name] || [];
200
+ const li = document.createElement('li');
201
+ li.className = state.selectedRoom === name ? 'active' : '';
202
+ li.innerHTML = `<div class="room-name">${escape(name)}</div>` +
203
+ `<div class="members">${members.map((m) => `<span class="member">${escape(m)}</span>`).join('')}</div>`;
204
+ li.addEventListener('click', () => selectRoom(name));
205
+ els.roomsList.appendChild(li);
206
+ }
207
+ }
208
+
209
+ function selectRoom(room) {
210
+ state.selectedRoom = room;
211
+ els.feedTitle.textContent = room === '*' ? 'Todas las salas' : `Sala: ${room}`;
212
+ renderRooms();
213
+ rerenderFeed();
214
+ }
215
+
216
+ function rerenderFeed() {
217
+ els.feedBody.innerHTML = '';
218
+ let any = false;
219
+ for (const m of state.messages) {
220
+ const node = renderMsg(m);
221
+ if (node) { els.feedBody.appendChild(node); any = true; }
222
+ }
223
+ if (!any) {
224
+ const e = document.createElement('div');
225
+ e.className = 'empty';
226
+ e.textContent = state.selectedRoom === '*'
227
+ ? 'Esperando mensajes...'
228
+ : `Sin mensajes en la sala "${state.selectedRoom}".`;
229
+ els.feedBody.appendChild(e);
230
+ }
231
+ // Tras un rerender (cambio de sala o ingest de history) siempre al último.
232
+ scrollToBottom(els.feedBody);
233
+ }
234
+
235
+ function wireFilters() {
236
+ Object.values(els.filters).forEach((el) => {
237
+ el.addEventListener('change', rerenderFeed);
238
+ });
239
+ els.clearBtn.addEventListener('click', () => {
240
+ state.messages = [];
241
+ state.askIndex.clear();
242
+ state.answeredCorrelations.clear();
243
+ state.msgCount = 0;
244
+ rerenderFeed();
245
+ updateStats();
246
+ });
247
+ }
248
+
249
+ async function pollStatus() {
250
+ if (!state.ws || state.ws.readyState !== 1) return;
251
+ state.ws.send(JSON.stringify({ op: 'status' }));
252
+ }
253
+
254
+ function connect() {
255
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
256
+ const url = `${proto}//${location.host}`;
257
+ setConn('', 'conectando...');
258
+ const ws = new WebSocket(url);
259
+ state.ws = ws;
260
+
261
+ ws.addEventListener('open', () => {
262
+ setConn('ok', 'en vivo');
263
+ // Suscribirse a todas las salas
264
+ ws.send(JSON.stringify({ op: 'watch', room: null }));
265
+ ws.send(JSON.stringify({ op: 'status' }));
266
+ });
267
+
268
+ ws.addEventListener('message', (ev) => {
269
+ let data;
270
+ try { data = JSON.parse(ev.data); } catch (_) { return; }
271
+ if (data.type === 'msg') {
272
+ appendMsg(data);
273
+ } else if (data.type === 'system' && data.event === 'status') {
274
+ const detail = data.detail || {};
275
+ state.rooms = detail.rooms || {};
276
+ renderRooms();
277
+ // Pedir history de cada sala nueva detectada
278
+ for (const room of Object.keys(state.rooms)) {
279
+ if (!state.knownRooms.has(room)) {
280
+ state.knownRooms.add(room);
281
+ ws.send(JSON.stringify({ op: 'history', room, n: 100 }));
282
+ }
283
+ }
284
+ } else if (data.type === 'history') {
285
+ ingestHistory(data.messages || []);
286
+ }
287
+ });
288
+
289
+ ws.addEventListener('close', () => {
290
+ setConn('err', 'desconectado · reintentando...');
291
+ setTimeout(connect, 1500);
292
+ });
293
+
294
+ ws.addEventListener('error', () => {
295
+ setConn('err', 'error de conexión');
296
+ });
297
+ }
298
+
299
+ wireFilters();
300
+ connect();
301
+ setInterval(pollStatus, 3000);
302
+ })();
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>refacil-bus · vista en vivo</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <div class="brand">
12
+ <span class="logo">●</span>
13
+ <h1>refacil-bus</h1>
14
+ <span class="tag">vista en vivo</span>
15
+ </div>
16
+ <div class="status">
17
+ <span id="conn-dot" class="dot"></span>
18
+ <span id="conn-label">conectando...</span>
19
+ <span id="port" class="muted"></span>
20
+ </div>
21
+ </header>
22
+
23
+ <main>
24
+ <aside id="sidebar">
25
+ <div class="side-head">
26
+ <h2>Salas activas</h2>
27
+ <span id="rooms-count" class="pill">0</span>
28
+ </div>
29
+ <ul id="rooms-list"></ul>
30
+ <div class="side-foot">
31
+ <div class="stat"><span class="label">Mensajes recibidos</span><span id="msg-count" class="val">0</span></div>
32
+ <div class="stat"><span class="label">Pares ask/reply</span><span id="pair-count" class="val">0</span></div>
33
+ <div class="stat"><span class="label">Pendientes</span><span id="pending-count" class="val">0</span></div>
34
+ </div>
35
+ </aside>
36
+
37
+ <section id="feed">
38
+ <div class="feed-head">
39
+ <h2 id="feed-title">Todas las salas</h2>
40
+ <div class="feed-controls">
41
+ <label><input type="checkbox" id="filter-ask" checked> ask</label>
42
+ <label><input type="checkbox" id="filter-reply" checked> reply</label>
43
+ <label><input type="checkbox" id="filter-say" checked> say</label>
44
+ <label><input type="checkbox" id="filter-system" checked> system</label>
45
+ <button id="clear-btn" title="limpiar vista (no borra el historial)">limpiar</button>
46
+ </div>
47
+ </div>
48
+ <div id="feed-body">
49
+ <div id="feed-empty" class="empty">
50
+ Esperando mensajes... si tus sesiones están en una sala, deberían aparecer aquí cuando alguien envíe algo.
51
+ </div>
52
+ </div>
53
+ </section>
54
+ </main>
55
+
56
+ <script src="/app.js"></script>
57
+ </body>
58
+ </html>
@@ -0,0 +1,122 @@
1
+ :root {
2
+ --bg: #0f1419;
3
+ --panel: #151c23;
4
+ --border: #242c35;
5
+ --muted: #6b7a88;
6
+ --text: #d7dde4;
7
+ --head: #ffffff;
8
+ --accent: #4fc3f7;
9
+ --ask: #4fc3f7;
10
+ --reply: #81c784;
11
+ --say: #ffb74d;
12
+ --system: #78909c;
13
+ --mention: #ffd54f;
14
+ --danger: #ef5350;
15
+ --ok: #66bb6a;
16
+ --pending: #ffa726;
17
+ font-family: -apple-system, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
18
+ font-size: 14px;
19
+ color: var(--text);
20
+ background: var(--bg);
21
+ }
22
+ * { box-sizing: border-box; margin: 0; padding: 0; }
23
+ html, body { height: 100vh; overflow: hidden; }
24
+ body { display: flex; flex-direction: column; }
25
+ header {
26
+ display: flex; justify-content: space-between; align-items: center;
27
+ padding: 10px 20px; border-bottom: 1px solid var(--border);
28
+ background: var(--panel);
29
+ }
30
+ .brand { display: flex; align-items: center; gap: 10px; }
31
+ .brand .logo { color: var(--accent); font-size: 22px; }
32
+ .brand h1 { font-size: 16px; font-weight: 600; color: var(--head); }
33
+ .brand .tag { color: var(--muted); font-size: 12px; }
34
+ .status { display: flex; align-items: center; gap: 8px; font-size: 12px; }
35
+ .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: background .3s; }
36
+ .status .dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
37
+ .status .dot.err { background: var(--danger); }
38
+ .muted { color: var(--muted); }
39
+
40
+ main { flex: 1; display: flex; overflow: hidden; }
41
+ #sidebar {
42
+ width: 280px; min-width: 280px;
43
+ border-right: 1px solid var(--border);
44
+ background: var(--panel);
45
+ display: flex; flex-direction: column;
46
+ }
47
+ .side-head, .side-foot {
48
+ padding: 12px 16px; border-bottom: 1px solid var(--border);
49
+ display: flex; justify-content: space-between; align-items: center;
50
+ }
51
+ .side-foot {
52
+ border-bottom: none; border-top: 1px solid var(--border); margin-top: auto;
53
+ flex-direction: column; gap: 6px; align-items: stretch;
54
+ }
55
+ .side-head h2 { font-size: 13px; font-weight: 600; color: var(--head); text-transform: uppercase; letter-spacing: .5px; }
56
+ .pill { background: var(--border); color: var(--muted); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
57
+ .stat { display: flex; justify-content: space-between; font-size: 12px; }
58
+ .stat .label { color: var(--muted); }
59
+ .stat .val { color: var(--head); font-weight: 600; }
60
+
61
+ #rooms-list { list-style: none; overflow-y: auto; flex: 1; }
62
+ #rooms-list li {
63
+ padding: 10px 16px; border-bottom: 1px solid var(--border);
64
+ cursor: pointer; transition: background .15s;
65
+ }
66
+ #rooms-list li:hover { background: rgba(255,255,255,.02); }
67
+ #rooms-list li.active { background: rgba(79,195,247,.08); border-left: 2px solid var(--accent); padding-left: 14px; }
68
+ #rooms-list li.all { background: rgba(255,255,255,.03); }
69
+ #rooms-list .room-name { color: var(--head); font-weight: 500; font-size: 13px; margin-bottom: 4px; }
70
+ #rooms-list .members { color: var(--muted); font-size: 11px; line-height: 1.6; }
71
+ #rooms-list .member { display: inline-block; margin-right: 6px; }
72
+ #rooms-list .member::before { content: '● '; color: var(--ok); font-size: 8px; vertical-align: middle; }
73
+
74
+ #feed { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
75
+ .feed-head {
76
+ padding: 12px 20px; border-bottom: 1px solid var(--border);
77
+ display: flex; justify-content: space-between; align-items: center;
78
+ background: var(--panel);
79
+ }
80
+ .feed-head h2 { font-size: 14px; color: var(--head); font-weight: 600; }
81
+ .feed-controls { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--muted); }
82
+ .feed-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
83
+ .feed-controls input { cursor: pointer; }
84
+ #clear-btn {
85
+ background: var(--border); color: var(--muted); border: none;
86
+ padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
87
+ }
88
+ #clear-btn:hover { background: rgba(255,255,255,.06); color: var(--text); }
89
+
90
+ #feed-body { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
91
+ .empty { color: var(--muted); text-align: center; padding: 40px 20px; font-size: 13px; }
92
+
93
+ .msg {
94
+ background: var(--panel); border-left: 3px solid var(--system);
95
+ padding: 10px 14px; border-radius: 0 6px 6px 0;
96
+ animation: slide-in .25s ease-out;
97
+ }
98
+ @keyframes slide-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
99
+ .msg.ask { border-left-color: var(--ask); }
100
+ .msg.reply { border-left-color: var(--reply); margin-left: 30px; }
101
+ .msg.broadcast { border-left-color: var(--say); }
102
+ .msg.system { border-left-color: var(--system); opacity: .65; font-style: italic; }
103
+ .msg.mention { box-shadow: inset 3px 0 0 var(--mention), 0 0 0 1px rgba(255,213,79,.3); }
104
+
105
+ .msg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
106
+ .msg-from { color: var(--head); font-weight: 600; font-size: 13px; }
107
+ .msg-to { color: var(--mention); font-weight: 500; font-size: 12px; }
108
+ .msg-to::before { content: '→ @'; color: var(--muted); font-weight: 400; }
109
+ .msg-kind { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
110
+ .msg-kind.ask { color: var(--ask); }
111
+ .msg-kind.reply { color: var(--reply); }
112
+ .msg-kind.broadcast { color: var(--say); }
113
+ .msg-ts { color: var(--text); font-size: 11px; font-variant-numeric: tabular-nums; font-weight: 600; }
114
+ .msg-room { color: var(--text); font-size: 11px; font-family: monospace; font-weight: 600; }
115
+ .msg-text { color: var(--text); white-space: pre-wrap; line-height: 1.4; font-size: 13px; }
116
+ .msg-corr {
117
+ margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
118
+ font-family: monospace; font-size: 10px; color: var(--muted);
119
+ }
120
+ .msg-corr.pending { color: var(--pending); }
121
+ .msg-corr.pending::before { content: '⏳ pending · '; }
122
+ .msg-corr.answered::before { content: '✓ answered · '; color: var(--ok); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "refacil-sdd-ai",
3
- "version": "2.8.2",
3
+ "version": "2.9.1",
4
4
  "description": "SDD-AI: Specification-Driven Development with AI — metodologia de desarrollo con IA usando OpenSpec, Claude Code y Cursor",
5
5
  "bin": {
6
6
  "refacil-sdd-ai": "./bin/cli.js"
@@ -25,9 +25,29 @@ Eres un guia **breve**: eliges el siguiente comando; el detalle de cada flujo es
25
25
  6. Review de calidad → `/refacil:review`
26
26
  7. Subir codigo y crear PR → `/refacil:up-code`
27
27
  8. Configurar repo → `refacil-sdd-ai init` (global + por repo) y `/refacil:setup`
28
+ 9. Coordinar con otros repos (sin copy/paste manual) → ver bloque **Bus entre agentes** abajo
28
29
 
29
30
  > **Nota**: `/refacil:up-code` verifica que el review este aprobado (`.review-passed`). Si falta y hay un solo cambio pendiente, lanza `/refacil:review` automaticamente (sin re-ejecutar si no hay cambios nuevos). Si hay multiples cambios sin review, pide seleccion explicita. Toda integracion a ramas protegidas (incluyendo `testing`) requiere PR. Nunca se hacen ajustes directos en `master` o `main`. La validacion de rama se hace en `/refacil:apply` o `/refacil:bug`, no en `up-code`.
30
31
 
32
+ ## Bus entre agentes (refacil-bus)
33
+
34
+ Para cuando el dev tiene varias ventanas de Claude Code / Cursor abiertas (una por repo) y necesita que los agentes se consulten entre si sin que el dev sea el transcriptor manual:
35
+
36
+ | Comando | Cuando usarlo |
37
+ |---------|---------------|
38
+ | `/refacil:join <sala>` | Primer paso: une esta sesion a una sala (crea el bloque de presentacion en `AGENTS.md` si falta). |
39
+ | `/refacil:say "..."` | Anuncio a toda la sala. |
40
+ | `/refacil:ask @<repo> "..." [--wait N]` | Pregunta dirigida a otro agente. Con `--wait N` bloquea hasta respuesta o N seg. |
41
+ | `/refacil:reply "..."` | Responde la ultima pregunta dirigida a esta sesion. |
42
+ | `/refacil:attend` | Deja esta sesion escuchando el bus: cuando llegue una pregunta dirigida, la respondes y vuelves a escuchar. |
43
+ | `/refacil:inbox` | Ver mensajes nuevos desde la ultima lectura. |
44
+
45
+ **Patron tipico**: antes de una tarea que puede necesitar contexto de otros repos, el dev va a las otras ventanas y dice *"atiende el bus"*. Luego en su repo de trabajo, `/refacil:ask @<otro-repo> "..." --wait 180` trae la respuesta automatica sin saltar entre ventanas.
46
+
47
+ Para supervision: `refacil-sdd-ai bus view` (UI web) o `refacil-sdd-ai bus watch <session>` (terminal). No consumen tokens.
48
+
49
+ Detalle completo en el README de refacil-sdd-ai (seccion `refacil-bus`).
50
+
31
51
  ## Tips (una linea por herramienta)
32
52
 
33
53
  - **Claude Code:** comandos `/refacil:*` en el chat.
@@ -20,6 +20,23 @@ Skills identicas en `.claude/skills/refacil-*/` (Claude Code) y `.cursor/skills/
20
20
 
21
21
  Flujo: `setup` → `propose` → `apply` → `test` → `verify` → `review` → `archive` → `up-code`
22
22
 
23
+ ## Bus entre agentes (refacil-bus)
24
+
25
+ Canal local de texto plano entre sesiones de Claude Code / Cursor corriendo en distintos repos. Evita que el dev haga de transcriptor manual entre sus propios agentes.
26
+
27
+ | Comando | Descripcion |
28
+ |---------|-------------|
29
+ | `/refacil:join <sala>` | Crear o unirse a una sala. La primera vez genera un bloque de presentacion en `AGENTS.md`. |
30
+ | `/refacil:say "..."` | Anuncio a toda la sala. |
31
+ | `/refacil:ask @nombre "..." [--wait N]` | Pregunta dirigida a otra sesion. `--wait N` bloquea hasta respuesta o N seg. |
32
+ | `/refacil:reply "..."` | Responde la ultima pregunta dirigida (autocompleta `correlationId`). |
33
+ | `/refacil:attend` | Modo escucha activa: recibe preguntas y el LLM las responde, luego re-invoca para seguir escuchando. |
34
+ | `/refacil:inbox` | Ver mensajes nuevos desde la ultima lectura. |
35
+
36
+ Uso tipico: antes de arrancar una tarea que puede requerir contexto de otros repos, el dev pone al agente de cada otro repo en `/refacil:attend`. Despues, en su repo de trabajo, el LLM puede pedir contexto con `/refacil:ask @<repo> "..." --wait` y recibir la respuesta automaticamente sin que el dev salte entre ventanas.
37
+
38
+ CLI util para el dev: `refacil-sdd-ai bus view` (abre UI web en el navegador), `bus watch <session>` (panel en terminal), `bus status`, `bus rooms`. No consumen tokens.
39
+
23
40
  ## Eficiencia de tokens (automatica)
24
41
 
25
42
  - El hook `check-update` en `SessionStart` sincroniza el bloque `compact-guidance` en `AGENTS.md`.