refacil-sdd-ai 4.0.9 → 4.0.10

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
@@ -99,7 +99,7 @@ npm uninstall -g refacil-sdd-ai
99
99
  | `refacil-sdd-ai bus join --room <sala> [--session <s>] [--intro "..."]` | Unirse a sala (las skills lo hacen) |
100
100
  | `refacil-sdd-ai bus leave [--session <s>]` | Salir de la sala |
101
101
  | `refacil-sdd-ai bus say --text "..." [--session <s>]` | Broadcast (las skills lo hacen) |
102
- | `refacil-sdd-ai bus ask --to <name> --text "..." [--wait N]` | Pregunta dirigida (las skills lo hacen) |
102
+ | `refacil-sdd-ai bus ask --to <sesión> --text "..." [--wait N]` | Pregunta dirigida; `--to all` (también `*` o `everyone`) envía un ask a cada miembro de la sala excepto tú |
103
103
  | `refacil-sdd-ai bus reply --text "..." [--correlation <id>]` | Responder (las skills lo hacen) |
104
104
  | `refacil-sdd-ai bus attend [--timeout N]` | Escucha preguntas dirigidas (las skills lo hacen) |
105
105
  | `refacil-sdd-ai bus inbox [--session <s>]` | Ver mensajes nuevos |
@@ -149,7 +149,7 @@ Algunos skills delegan su trabajo pesado a **sub-agentes** que corren en context
149
149
  |---|---|
150
150
  | `/refacil:join <sala>` | Unirse o crear sala |
151
151
  | `/refacil:say "..."` | Broadcast |
152
- | `/refacil:ask @nombre "..." [--wait N]` | Pregunta dirigida (bloquea con `--wait`) |
152
+ | `/refacil:ask @nombre "..." [--wait N]` | Pregunta dirigida; `@all` pregunta a todos en la sala (bloquea con `--wait` hasta la **primera** respuesta) |
153
153
  | `/refacil:reply "..."` | Responder ultima pregunta (autocompleta `correlationId`) |
154
154
  | `/refacil:attend` | Modo escucha activa |
155
155
  | `/refacil:inbox` | Mensajes nuevos desde la ultima lectura |
package/bin/cli.js CHANGED
@@ -418,7 +418,7 @@ function help() {
418
418
  bus join --room <sala> [--session <s>] [--intro "..."]
419
419
  bus leave [--session <s>]
420
420
  bus say --text "..." [--session <s>]
421
- bus ask --to <name> --text "..." [--wait N] [--session <s>]
421
+ bus ask --to <name|all|*|everyone> --text "..." [--wait N] [--session <s>]
422
422
  bus reply --text "..." [--correlation <id>] [--to <name>]
423
423
  bus history [--n N] [--session <s>]
424
424
  bus inbox [--session <s>]
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Indica si el `ask` ya tiene una respuesta que lo cierra para este hilo.
5
+ * Para el mismo correlationId con varios destinatarios (@all), solo cuenta
6
+ * la respuesta emitida por la sesión que recibió ese ask (ask.to).
7
+ */
8
+ function askHasMatchingReply(messages, ask) {
9
+ if (!ask || ask.kind !== 'ask' || !ask.correlationId) return true;
10
+ return messages.some((m) => {
11
+ if (m.kind !== 'reply' || m.correlationId !== ask.correlationId) return false;
12
+ if (ask.to) return m.from === ask.to;
13
+ return m.to === ask.from;
14
+ });
15
+ }
16
+
17
+ module.exports = { askHasMatchingReply };
package/lib/bus/broker.js CHANGED
@@ -12,6 +12,7 @@ const PORT_CANDIDATES = [7821, 7822, 7823];
12
12
  const HOST = '127.0.0.1';
13
13
 
14
14
  const storage = require('./storage');
15
+ const { askHasMatchingReply } = require('./askFulfillment');
15
16
 
16
17
  let WebSocketServer;
17
18
  try {
@@ -258,6 +259,24 @@ function handleLeave(state, ws, data) {
258
259
  persistSessions(state);
259
260
  }
260
261
 
262
+ /** Destinos especiales: un ask por cada miembro de la sala excepto el emisor. */
263
+ const ASK_ALL_ALIASES = new Set(['all', '*', 'everyone']);
264
+
265
+ function resolveAskTargets(state, room, fromSession, rawTo) {
266
+ if (rawTo === undefined || rawTo === null) return [null];
267
+ const trimmed = String(rawTo).trim();
268
+ if (trimmed === '') return [null];
269
+ const bare = trimmed.replace(/^@/, '');
270
+ const key = bare.toLowerCase();
271
+ if (ASK_ALL_ALIASES.has(key)) {
272
+ const members = state.rooms.get(room);
273
+ if (!members || members.size === 0) return [];
274
+ const others = Array.from(members).filter((n) => n !== fromSession);
275
+ return others.length ? others.sort() : [];
276
+ }
277
+ return [bare];
278
+ }
279
+
261
280
  function handleSay(state, ws, data) {
262
281
  const session = data.session || ws._refacilSession;
263
282
  const s = session && state.sessions.get(session);
@@ -284,19 +303,36 @@ function handleAsk(state, ws, data) {
284
303
  if (!s || !s.room) {
285
304
  return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
286
305
  }
287
- const msg = {
288
- id: newId(),
289
- ts: nowIso(),
290
- from: session,
291
- to: data.to || null,
292
- room: s.room,
293
- text: data.text || '',
294
- kind: 'ask',
295
- correlationId: data.correlationId || newId(),
296
- };
297
- appendHistory(state, s.room, msg);
298
- broadcast(state, s.room, msg);
299
- send(ws, { type: 'system', event: 'sent', detail: { id: msg.id, correlationId: msg.correlationId } });
306
+ const correlationId = data.correlationId || newId();
307
+ const targets = resolveAskTargets(state, s.room, session, data.to);
308
+ if (targets.length === 0) {
309
+ return send(ws, {
310
+ type: 'system',
311
+ event: 'error',
312
+ detail: 'sin destinatarios (@all en sala vacía o solo tú)',
313
+ });
314
+ }
315
+ let firstId = null;
316
+ for (const to of targets) {
317
+ const msg = {
318
+ id: newId(),
319
+ ts: nowIso(),
320
+ from: session,
321
+ to,
322
+ room: s.room,
323
+ text: data.text || '',
324
+ kind: 'ask',
325
+ correlationId,
326
+ };
327
+ if (!firstId) firstId = msg.id;
328
+ appendHistory(state, s.room, msg);
329
+ broadcast(state, s.room, msg);
330
+ }
331
+ send(ws, {
332
+ type: 'system',
333
+ event: 'sent',
334
+ detail: { id: firstId, correlationId, fanOut: targets.length },
335
+ });
300
336
  }
301
337
 
302
338
  function handleReply(state, ws, data) {
@@ -315,9 +351,7 @@ function handleReply(state, ws, data) {
315
351
  for (const ask of hist) {
316
352
  if (ask.kind !== 'ask') continue;
317
353
  if (ask.to && ask.to !== session) continue;
318
- const replied = hist.some(
319
- (m) => m.kind === 'reply' && m.correlationId === ask.correlationId,
320
- );
354
+ const replied = askHasMatchingReply(hist, ask);
321
355
  if (replied) continue;
322
356
  correlationId = ask.correlationId || null;
323
357
  if (!toOverride) toOverride = ask.from;
package/lib/bus/ui/app.js CHANGED
@@ -28,11 +28,23 @@
28
28
  rooms: {},
29
29
  knownRooms: new Set(),
30
30
  selectedRoom: '*',
31
- askIndex: new Map(),
32
- answeredCorrelations: new Set(),
31
+ answeredByResponder: new Set(),
33
32
  msgCount: 0,
34
33
  };
35
34
 
35
+ function cssEsc(s) {
36
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(String(s));
37
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
38
+ }
39
+
40
+ function isAskAnswered(m) {
41
+ if (m.kind !== 'ask' || !m.correlationId) return false;
42
+ if (m.to) return state.answeredByResponder.has(`${m.correlationId}\t${m.to}`);
43
+ return state.messages.some(
44
+ (r) => r.kind === 'reply' && r.correlationId === m.correlationId,
45
+ );
46
+ }
47
+
36
48
  els.port.textContent = 'puerto ' + state.port;
37
49
 
38
50
  function setConn(kind, label) {
@@ -79,6 +91,9 @@
79
91
  div.className = classes.join(' ');
80
92
  div.dataset.corr = m.correlationId || '';
81
93
  div.dataset.kind = m.kind || '';
94
+ if (m.kind === 'ask' && m.correlationId && m.to) {
95
+ div.dataset.askTo = m.to;
96
+ }
82
97
 
83
98
  const head = document.createElement('div');
84
99
  head.className = 'msg-head';
@@ -103,7 +118,7 @@
103
118
 
104
119
  if (m.correlationId) {
105
120
  const corr = document.createElement('div');
106
- const isAnswered = state.answeredCorrelations.has(m.correlationId);
121
+ const isAnswered = m.kind === 'ask' ? isAskAnswered(m) : false;
107
122
  corr.className = 'msg-corr ' + (m.kind === 'ask' ? (isAnswered ? 'answered' : 'pending') : '');
108
123
  corr.textContent = m.correlationId.slice(0, 8);
109
124
  div.appendChild(corr);
@@ -136,20 +151,21 @@
136
151
  state.messageIds.add(m.id);
137
152
  state.messages.push(m);
138
153
  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);
154
+ if (m.kind === 'reply' && m.correlationId && m.from) {
155
+ state.answeredByResponder.add(`${m.correlationId}\t${m.from}`);
144
156
  }
145
157
  return true;
146
158
  }
147
159
 
148
160
  function appendMsg(m) {
149
161
  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); }
162
+ if (m.kind === 'reply' && m.correlationId && m.from) {
163
+ const sel = `.msg.ask[data-corr="${cssEsc(m.correlationId)}"][data-ask-to="${cssEsc(m.from)}"] .msg-corr`;
164
+ const prev = els.feedBody.querySelector(sel);
165
+ if (prev) {
166
+ prev.className = 'msg-corr answered';
167
+ prev.textContent = m.correlationId.slice(0, 8);
168
+ }
153
169
  }
154
170
  const node = renderMsg(m);
155
171
  if (node) {
@@ -176,10 +192,10 @@
176
192
 
177
193
  function updateStats() {
178
194
  els.msgCount.textContent = state.msgCount;
179
- els.pairCount.textContent = state.answeredCorrelations.size;
195
+ els.pairCount.textContent = state.messages.filter((x) => x.kind === 'reply').length;
180
196
  let pending = 0;
181
- for (const [cid] of state.askIndex) {
182
- if (!state.answeredCorrelations.has(cid)) pending++;
197
+ for (const m of state.messages) {
198
+ if (m.kind === 'ask' && !isAskAnswered(m)) pending++;
183
199
  }
184
200
  els.pendingCount.textContent = pending;
185
201
  }
@@ -238,8 +254,8 @@
238
254
  });
239
255
  els.clearBtn.addEventListener('click', () => {
240
256
  state.messages = [];
241
- state.askIndex.clear();
242
- state.answeredCorrelations.clear();
257
+ state.messageIds.clear();
258
+ state.answeredByResponder.clear();
243
259
  state.msgCount = 0;
244
260
  rerenderFeed();
245
261
  updateStats();
@@ -7,6 +7,7 @@ const busSpawn = require('../bus/spawn');
7
7
  const busClient = require('../bus/client');
8
8
  const busWatch = require('../bus/watch');
9
9
  const busPresenter = require('../bus/presenter');
10
+ const { askHasMatchingReply } = require('../bus/askFulfillment');
10
11
 
11
12
  function parseBusArgs(argv) {
12
13
  const args = {};
@@ -185,7 +186,7 @@ async function busAsk(args, packageRoot) {
185
186
  const text = args.text;
186
187
  const waitSec = args.wait ? parseInt(args.wait, 10) : 0;
187
188
  if (!to || !text) {
188
- console.error(' Uso: refacil-sdd-ai bus ask --to <name> --text "..." [--wait N] [--session <s>]');
189
+ console.error(' Uso: refacil-sdd-ai bus ask --to <name|all> --text "..." [--wait N] [--session <s>]');
189
190
  process.exit(1);
190
191
  }
191
192
  const { ws } = await connectOrDie(packageRoot);
@@ -203,7 +204,9 @@ async function busAsk(args, packageRoot) {
203
204
  process.exit(1);
204
205
  }
205
206
  const correlationId = ack.detail.correlationId;
206
- console.log(` Pregunta enviada a @${to.replace(/^@/, '')} (correlationId ${correlationId}).`);
207
+ const fanOut = ack.detail && ack.detail.fanOut;
208
+ const destLabel = fanOut && fanOut > 1 ? `${fanOut} miembros (@all)` : `@${to.replace(/^@/, '')}`;
209
+ console.log(` Pregunta enviada a ${destLabel} (correlationId ${correlationId}).`);
207
210
 
208
211
  if (waitSec > 0) {
209
212
  console.log(` Esperando respuesta hasta ${waitSec}s...`);
@@ -306,10 +309,7 @@ async function busInbox(args, packageRoot) {
306
309
  function findFirstUnansweredAsk(messages, session) {
307
310
  const asks = messages.filter((m) => m.kind === 'ask' && m.to === session);
308
311
  for (const ask of asks) {
309
- const hasReply = messages.some(
310
- (m) => m.kind === 'reply' && m.correlationId === ask.correlationId,
311
- );
312
- if (!hasReply) return ask;
312
+ if (!askHasMatchingReply(messages, ask)) return ask;
313
313
  }
314
314
  return null;
315
315
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "refacil-sdd-ai",
3
- "version": "4.0.9",
3
+ "version": "4.0.10",
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"
@@ -6,7 +6,7 @@ user-invocable: true
6
6
 
7
7
  # refacil:ask — Pregunta dirigida a otra sesión
8
8
 
9
- Envía una pregunta dirigida a una sesión específica de la sala. **$ARGUMENTS** incluye `@<destino>` y el texto.
9
+ Envía una pregunta dirigida a una sesión de la sala, o a **todas las demás** sesiones de la sala con `@all` (alias: `*`, `everyone`). **$ARGUMENTS** incluye `@<destino>` y el texto.
10
10
 
11
11
  ## Instrucciones
12
12
 
@@ -23,6 +23,8 @@ Extrae:
23
23
 
24
24
  Si el destino no es claro, usa `/refacil:rooms` (vía `refacil-sdd-ai bus rooms`) para listar sesiones activas y pide aclaración al usuario.
25
25
 
26
+ **Broadcast dirigido (`@all`)**: el broker emite un `ask` por cada miembro (excepto quien pregunta), mismo `correlationId`. Cada receptor puede `/refacil:reply` por su lado. Con `--wait`, el CLI sigue devolviendo **la primera** respuesta que llegue (no espera a todos).
27
+
26
28
  ### Paso 2: Decidir si usar `--wait`
27
29
 
28
30
  Dos modos:
@@ -29,7 +29,7 @@ Canal local de texto plano entre sesiones de Claude Code / Cursor corriendo en d
29
29
  |---------|-------------|
30
30
  | `/refacil:join <sala>` | Crear o unirse a una sala. La primera vez genera un bloque de presentacion en `AGENTS.md`. |
31
31
  | `/refacil:say "..."` | Anuncio a toda la sala. |
32
- | `/refacil:ask @nombre "..." [--wait N]` | Pregunta dirigida a otra sesion. `--wait N` bloquea hasta respuesta o N seg. |
32
+ | `/refacil:ask @nombre "..." [--wait N]` | Pregunta dirigida; `@all` (o `*`) pregunta a todos en la sala. `--wait N` bloquea hasta la primera respuesta o N seg. |
33
33
  | `/refacil:reply "..."` | Responde la ultima pregunta dirigida (autocompleta `correlationId`). |
34
34
  | `/refacil:attend` | Modo escucha activa: recibe preguntas y el LLM las responde, luego re-invoca para seguir escuchando. |
35
35
  | `/refacil:inbox` | Ver mensajes nuevos desde la ultima lectura. |